chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { Badge, Button } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { formatDateTime } from '@sim/utils/formatting'
|
||||
import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
ActivityLog,
|
||||
type ActivityLogEntry,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/activity-log'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { useWorkspaceBackgroundWork } from '@/ee/workspace-forking/hooks/background-work'
|
||||
|
||||
const logger = createLogger('ForkActivityPanel')
|
||||
|
||||
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
|
||||
|
||||
/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */
|
||||
function countList(pairs: Array<[number | undefined, string]>): string {
|
||||
return pairs
|
||||
.filter(([n]) => (n ?? 0) > 0)
|
||||
.map(([n, verb]) => `${n} ${verb}`)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
/** A named group (one resource kind or change action) of a job's report. */
|
||||
interface ReportGroup {
|
||||
label: string
|
||||
names: string[]
|
||||
}
|
||||
|
||||
/** A job's expanded report: named groups plus plain notes (counts / warnings). */
|
||||
interface JobReport {
|
||||
groups: ReportGroup[]
|
||||
notes: Array<{ value: string; warning?: boolean }>
|
||||
}
|
||||
|
||||
/** The workspace whose activity is being viewed, for phrasing rows recorded on either side of an edge. */
|
||||
interface ActivityView {
|
||||
workspaceId: string
|
||||
/** Lineage partner names by id (the parent + this workspace's forks). */
|
||||
workspaceNames: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** Display name of the workspace a partner-recorded row was keyed to (the edge's other side). */
|
||||
function partnerName(job: BackgroundWorkItem, view: ActivityView): string {
|
||||
return view.workspaceNames.get(job.workspaceId) ?? 'another workspace'
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity-row title, derived per kind from the job's metadata. Every event is
|
||||
* recorded once, keyed to the workspace it was initiated from, so a row keyed to an
|
||||
* edge partner is phrased from THIS workspace's side (e.g. the parent's "Pushed to X"
|
||||
* row reads "Received push from <fork>" when X views it).
|
||||
*/
|
||||
function jobTitle(job: BackgroundWorkItem, view: ActivityView): string {
|
||||
const m = job.metadata
|
||||
const recordedHere = job.workspaceId === view.workspaceId
|
||||
switch (job.kind) {
|
||||
case 'fork_content_copy':
|
||||
// A partner-recorded copy row is either this workspace's own creation (recorded
|
||||
// on the parent, carrying our id as the child) or a sync's resource fill.
|
||||
if (!recordedHere && m?.childWorkspaceId === view.workspaceId) {
|
||||
return `Forked from "${partnerName(job, view)}"`
|
||||
}
|
||||
return m?.childWorkspaceName
|
||||
? `Forked into "${m.childWorkspaceName}"`
|
||||
: (job.message ?? 'Fork')
|
||||
case 'fork_sync':
|
||||
if (!recordedHere) {
|
||||
return m?.direction === 'pull'
|
||||
? `Pulled by "${partnerName(job, view)}"`
|
||||
: `Received push from "${partnerName(job, view)}"`
|
||||
}
|
||||
if (!m?.otherWorkspaceName) return job.message ?? 'Sync'
|
||||
return m.direction === 'pull'
|
||||
? `Pulled from "${m.otherWorkspaceName}"`
|
||||
: `Pushed to "${m.otherWorkspaceName}"`
|
||||
case 'fork_rollback':
|
||||
if (!recordedHere) return `Sync undone in "${partnerName(job, view)}"`
|
||||
return m?.otherWorkspaceName
|
||||
? `Undid sync from "${m.otherWorkspaceName}"`
|
||||
: (job.message ?? 'Rollback')
|
||||
default:
|
||||
return job.message ?? 'Activity'
|
||||
}
|
||||
}
|
||||
|
||||
/** Short action label for the Event badge, per job kind. */
|
||||
function jobEventLabel(job: BackgroundWorkItem): string {
|
||||
switch (job.kind) {
|
||||
case 'fork_content_copy':
|
||||
return 'Fork'
|
||||
case 'fork_sync':
|
||||
return job.metadata?.direction === 'pull' ? 'Pull' : 'Push'
|
||||
case 'fork_rollback':
|
||||
return 'Rollback'
|
||||
default:
|
||||
return 'Activity'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge variant: bad outcomes keep the status colors (red/amber), while successful
|
||||
* rows are colored by operation so Fork / Push / Pull / Rollback are distinguishable
|
||||
* at a glance.
|
||||
*/
|
||||
function jobBadgeVariant(job: BackgroundWorkItem) {
|
||||
if (job.status === 'failed') return 'red' as const
|
||||
if (job.status === 'completed_with_warnings') return 'amber' as const
|
||||
if (job.status !== 'completed') return 'gray-secondary' as const
|
||||
switch (job.kind) {
|
||||
case 'fork_content_copy':
|
||||
return 'blue' as const
|
||||
case 'fork_sync':
|
||||
return job.metadata?.direction === 'pull' ? ('cyan' as const) : ('green' as const)
|
||||
case 'fork_rollback':
|
||||
return 'purple' as const
|
||||
default:
|
||||
return 'gray-secondary' as const
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a job's report (named groups + plain notes) from its metadata. */
|
||||
function jobReport(job: BackgroundWorkItem): JobReport {
|
||||
const m = job.metadata
|
||||
const groups: ReportGroup[] = []
|
||||
const notes: JobReport['notes'] = []
|
||||
if (!m) return { groups, notes }
|
||||
|
||||
const addGroup = (label: string, names: string[] | undefined) => {
|
||||
if (names && names.length > 0) groups.push({ label, names })
|
||||
}
|
||||
|
||||
if (job.kind === 'fork_sync') {
|
||||
addGroup('Updated', m.updatedNames)
|
||||
addGroup('Created', m.createdNames)
|
||||
addGroup('Archived', m.archivedNames)
|
||||
// Pre-names entries fall back to the count summary (redeployed mirrors updated).
|
||||
if (groups.length === 0) {
|
||||
const counts = countList([
|
||||
[m.updated, 'updated'],
|
||||
[m.created, 'created'],
|
||||
[m.archived, 'archived'],
|
||||
])
|
||||
if (counts) notes.push({ value: counts })
|
||||
}
|
||||
if (m.needsConfiguration && m.needsConfiguration.length > 0) {
|
||||
for (const item of m.needsConfiguration) {
|
||||
notes.push({
|
||||
value: `${item.workflowName} — re-check ${item.blocks.join(', ')}`,
|
||||
warning: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (m.clearedOptional && m.clearedOptional.length > 0) {
|
||||
for (const item of m.clearedOptional) {
|
||||
notes.push({
|
||||
value: `${item.workflowName} — optional cleared in ${item.blocks.join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (m.deployFailed && m.deployFailed > 0) {
|
||||
notes.push({ value: `${plural(m.deployFailed, 'workflow')} failed to deploy`, warning: true })
|
||||
}
|
||||
return { groups, notes }
|
||||
}
|
||||
|
||||
if (job.kind === 'fork_rollback') {
|
||||
const counts = countList([
|
||||
[m.restored, 'restored'],
|
||||
[m.unarchived, 'unarchived'],
|
||||
[m.removed, 'removed'],
|
||||
[m.skipped, 'skipped'],
|
||||
])
|
||||
if (counts) notes.push({ value: counts })
|
||||
return { groups, notes }
|
||||
}
|
||||
|
||||
// fork_content_copy: a named breakdown of everything copied, by kind.
|
||||
addGroup('Workflows', m.workflowNames)
|
||||
addGroup('Knowledge bases', m.knowledgeBaseNames)
|
||||
addGroup('Tables', m.tableNames)
|
||||
addGroup('Files', m.fileNames)
|
||||
addGroup('Custom tools', m.customToolNames)
|
||||
addGroup('Skills', m.skillNames)
|
||||
addGroup('MCP servers', m.mcpServerNames)
|
||||
addGroup('Workflow MCP servers', m.workflowMcpServerNames)
|
||||
// Sync content-copy rows record per-kind COUNTS only (fork rows carry names), so fall back
|
||||
// to the counts when no named group rendered.
|
||||
if (groups.length === 0) {
|
||||
const counts = [
|
||||
[m.workflowsCopied, 'workflow'],
|
||||
[m.knowledgeBases, 'knowledge base'],
|
||||
[m.tables, 'table'],
|
||||
[m.files, 'file'],
|
||||
]
|
||||
.filter(([n]) => ((n as number | undefined) ?? 0) > 0)
|
||||
.map(([n, noun]) => plural(n as number, noun as string))
|
||||
.join(' · ')
|
||||
if (counts) notes.push({ value: counts })
|
||||
}
|
||||
if (m.failed && m.failed > 0) {
|
||||
notes.push({ value: `${plural(m.failed, 'resource')} failed to copy`, warning: true })
|
||||
}
|
||||
if (m.clearingFailed) {
|
||||
notes.push({ value: 'Reference cleanup incomplete', warning: true })
|
||||
}
|
||||
return { groups, notes }
|
||||
}
|
||||
|
||||
/** The expanded detail box content for one job: named groups, notes, and any error. */
|
||||
function jobDetails(job: BackgroundWorkItem, report: JobReport) {
|
||||
return (
|
||||
<>
|
||||
{report.groups.map((group) => (
|
||||
<div key={group.label} className='flex gap-2'>
|
||||
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>{group.label}</span>
|
||||
<span className='min-w-0 flex-1 text-[var(--text-primary)]'>
|
||||
{group.names.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{report.notes.map((note, index) => (
|
||||
<span
|
||||
key={`${index}:${note.value}`}
|
||||
className={note.warning ? 'text-[var(--text-error)]' : 'text-[var(--text-secondary)]'}
|
||||
>
|
||||
{note.value}
|
||||
</span>
|
||||
))}
|
||||
{job.error ? <span className='text-[var(--text-error)]'>{job.error}</span> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps a background job to the shared {@link ActivityLog} row shape. */
|
||||
function toActivityEntry(job: BackgroundWorkItem, view: ActivityView): ActivityLogEntry {
|
||||
const report = jobReport(job)
|
||||
const hasDetails = report.groups.length > 0 || report.notes.length > 0 || Boolean(job.error)
|
||||
return {
|
||||
id: job.id,
|
||||
timestamp: formatDateTime(new Date(job.startedAt)),
|
||||
event: (
|
||||
<Badge variant={jobBadgeVariant(job)} size='sm' className='shrink-0'>
|
||||
{jobEventLabel(job)}
|
||||
</Badge>
|
||||
),
|
||||
description: jobTitle(job, view),
|
||||
actor: job.metadata?.actorName || 'System',
|
||||
details: hasDetails ? jobDetails(job, report) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
interface ForkActivityPanelProps {
|
||||
/** Poll the durable fork-job audit trail involving this workspace. */
|
||||
workspaceId: string
|
||||
/** Lineage partner names by id (the parent + forks), for phrasing partner-recorded rows. */
|
||||
workspaceNames: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* A durable audit log of every fork, sync, and rollback involving the workspace
|
||||
* (both sides of each fork edge), rendered through the shared {@link ActivityLog}
|
||||
* so it reads identically to the enterprise audit log: each row (timestamp, action
|
||||
* badge, description, actor) expands to a per-kind breakdown of what changed.
|
||||
*/
|
||||
export function ForkActivityPanel({ workspaceId, workspaceNames }: ForkActivityPanelProps) {
|
||||
const { data, isPending, isError, hasNextPage, fetchNextPage, isFetchingNextPage } =
|
||||
useWorkspaceBackgroundWork(workspaceId)
|
||||
const view: ActivityView = { workspaceId, workspaceNames }
|
||||
|
||||
const jobs = useMemo(() => {
|
||||
if (!data?.pages) return []
|
||||
return data.pages.flatMap((page) => page.items)
|
||||
}, [data])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage().catch((error: unknown) => {
|
||||
logger.error('Failed to load more fork activity', { error })
|
||||
})
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
return (
|
||||
<ActivityLog
|
||||
entries={jobs.map((job) => toActivityEntry(job, view))}
|
||||
eventColumn='compact'
|
||||
// A failed or still-loading feed must never claim "nothing here yet".
|
||||
emptyState={
|
||||
isError ? (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
Couldn't load activity. Try again shortly.
|
||||
</SettingsEmptyState>
|
||||
) : isPending ? undefined : (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
Nothing here yet. Forks, syncs, and rollbacks will appear here.
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
hasNextPage ? (
|
||||
<div className='flex justify-center py-4'>
|
||||
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
|
||||
{isFetchingNextPage ? 'Loading...' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { groupForkFilesIntoFolders } from '@/ee/workspace-forking/components/fork-file-tree/fork-file-tree'
|
||||
|
||||
describe('groupForkFilesIntoFolders', () => {
|
||||
it('groups files under their folder and lifts un-foldered files to the root bucket', () => {
|
||||
const { folders, rootFiles } = groupForkFilesIntoFolders([
|
||||
{ id: 'f1', label: 'b.png', folderId: 'fld-1', folderName: 'Images' },
|
||||
{ id: 'f2', label: 'a.png', folderId: 'fld-1', folderName: 'Images' },
|
||||
{ id: 'f3', label: 'root.txt', folderId: null, folderName: null },
|
||||
{ id: 'f4', label: 'doc.pdf', folderId: 'fld-2', folderName: 'Docs' },
|
||||
])
|
||||
// Folders are sorted by name; each folder's files are sorted by label.
|
||||
expect(folders.map((folder) => folder.name)).toEqual(['Docs', 'Images'])
|
||||
expect(folders[1].files.map((file) => file.label)).toEqual(['a.png', 'b.png'])
|
||||
expect(rootFiles.map((file) => file.label)).toEqual(['root.txt'])
|
||||
})
|
||||
|
||||
it('treats a file whose folder was deleted (id set, name null) as a root file', () => {
|
||||
const { folders, rootFiles } = groupForkFilesIntoFolders([
|
||||
{ id: 'f1', label: 'orphan.png', folderId: 'fld-deleted', folderName: null },
|
||||
])
|
||||
expect(folders).toEqual([])
|
||||
expect(rootFiles.map((file) => file.id)).toEqual(['f1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
|
||||
|
||||
export interface ForkFileTreeItem {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface ForkFileTreeFolder {
|
||||
id: string
|
||||
name: string
|
||||
files: ForkFileTreeItem[]
|
||||
}
|
||||
|
||||
/** A flat copyable file with its folder grouping, before {@link groupForkFilesIntoFolders}. */
|
||||
export interface ForkFlatFile {
|
||||
id: string
|
||||
label: string
|
||||
folderId: string | null
|
||||
folderName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Group flat files into folders (sorted by name, each file sorted by label) plus a root bucket
|
||||
* for files with no folder. A file whose folder id is set but whose name is null (its folder was
|
||||
* deleted) falls into the root bucket, so it stays selectable rather than hiding under a phantom.
|
||||
*/
|
||||
export function groupForkFilesIntoFolders(files: ForkFlatFile[]): {
|
||||
folders: ForkFileTreeFolder[]
|
||||
rootFiles: ForkFileTreeItem[]
|
||||
} {
|
||||
const folderById = new Map<string, ForkFileTreeFolder>()
|
||||
const rootFiles: ForkFileTreeItem[] = []
|
||||
for (const file of files) {
|
||||
const item: ForkFileTreeItem = { id: file.id, label: file.label }
|
||||
if (file.folderId && file.folderName) {
|
||||
let folder = folderById.get(file.folderId)
|
||||
if (!folder) {
|
||||
folder = { id: file.folderId, name: file.folderName, files: [] }
|
||||
folderById.set(file.folderId, folder)
|
||||
}
|
||||
folder.files.push(item)
|
||||
} else {
|
||||
rootFiles.push(item)
|
||||
}
|
||||
}
|
||||
const folders = Array.from(folderById.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||
for (const folder of folders) folder.files.sort((a, b) => a.label.localeCompare(b.label))
|
||||
rootFiles.sort((a, b) => a.label.localeCompare(b.label))
|
||||
return { folders, rootFiles }
|
||||
}
|
||||
|
||||
interface ForkFileTreeProps {
|
||||
folders: ForkFileTreeFolder[]
|
||||
rootFiles: ForkFileTreeItem[]
|
||||
isSelected: (fileId: string) => boolean
|
||||
onToggleFile: (fileId: string, checked: boolean) => void
|
||||
/** Toggle every file in a folder at once (the folder-level select-all). */
|
||||
onToggleMany: (fileIds: string[], checked: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder ▸ file collapsible tree shared by the fork picker and the sync copy selector, so both
|
||||
* surfaces group files identically. Each folder is a tri-state select-all header that expands to
|
||||
* its files; files with no folder render at the top level. Files are the only copyable kind that
|
||||
* nests - tables, knowledge bases, custom tools, and skills stay flat at the top level.
|
||||
*/
|
||||
export function ForkFileTree({
|
||||
folders,
|
||||
rootFiles,
|
||||
isSelected,
|
||||
onToggleFile,
|
||||
onToggleMany,
|
||||
disabled = false,
|
||||
}: ForkFileTreeProps) {
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
{folders.map((folder) => (
|
||||
<ForkFileFolderRow
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
isSelected={isSelected}
|
||||
onToggleFile={onToggleFile}
|
||||
onToggleMany={onToggleMany}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{rootFiles.map((file) => (
|
||||
<ForkFileRow
|
||||
key={file.id}
|
||||
file={file}
|
||||
checked={isSelected(file.id)}
|
||||
onToggle={onToggleFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ForkFileFolderRowProps {
|
||||
folder: ForkFileTreeFolder
|
||||
isSelected: (fileId: string) => boolean
|
||||
onToggleFile: (fileId: string, checked: boolean) => void
|
||||
onToggleMany: (fileIds: string[], checked: boolean) => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function ForkFileFolderRow({
|
||||
folder,
|
||||
isSelected,
|
||||
onToggleFile,
|
||||
onToggleMany,
|
||||
disabled,
|
||||
}: ForkFileFolderRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const fileIds = folder.files.map((file) => file.id)
|
||||
const total = fileIds.length
|
||||
const selectedCount = fileIds.filter(isSelected).length
|
||||
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
aria-label={`Copy all in ${folder.name}`}
|
||||
checked={headerState}
|
||||
onCheckedChange={() => onToggleMany(fileIds, headerState !== true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{folder.name} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className='ml-6 flex flex-col gap-0.5'>
|
||||
{folder.files.map((file) => (
|
||||
<ForkFileRow
|
||||
key={file.id}
|
||||
file={file}
|
||||
checked={isSelected(file.id)}
|
||||
onToggle={onToggleFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ForkFileRowProps {
|
||||
file: ForkFileTreeItem
|
||||
checked: boolean
|
||||
onToggle: (fileId: string, checked: boolean) => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function ForkFileRow({ file, checked, onToggle, disabled }: ForkFileRowProps) {
|
||||
const itemId = useId()
|
||||
return (
|
||||
<label
|
||||
htmlFor={itemId}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={itemId}
|
||||
size='sm'
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => onToggle(file.id, value === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate'>{file.label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
|
||||
import {
|
||||
ForkFileTree,
|
||||
type ForkFlatFile,
|
||||
groupForkFilesIntoFolders,
|
||||
} from '@/ee/workspace-forking/components/fork-file-tree/fork-file-tree'
|
||||
|
||||
/** A flat copyable resource (table / KB / custom tool / skill / MCP server) in the picker. */
|
||||
export interface ForkResourcePickerItem {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ResourceKindRowProps {
|
||||
label: string
|
||||
items: ForkResourcePickerItem[]
|
||||
selected: Set<string>
|
||||
/** Toggle the given ids on/off. Used by the select-all header checkbox. */
|
||||
onToggleMany: (ids: string[], checked: boolean) => void
|
||||
onToggleItem: (id: string, checked: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One expandable resource kind in the fork / sync copy picker: a tri-state "select all" header
|
||||
* (count of selected / total) plus, when expanded, a scrollable list of individual resources so
|
||||
* the user can copy a specific subset. Shared by the fork modal's "Copy resources" and the sync
|
||||
* modal's "Copy resources" so the two surfaces stay identical. Files nest in a folder tree
|
||||
* instead - use {@link FileKindRow}.
|
||||
*
|
||||
* Expanded body uses the settings MCP expand pattern (`border-t` + `--surface-2`) so items
|
||||
* read as contained in the kind rather than indented siblings of other kinds.
|
||||
*/
|
||||
export function ResourceKindRow({
|
||||
label,
|
||||
items,
|
||||
selected,
|
||||
onToggleMany,
|
||||
onToggleItem,
|
||||
disabled = false,
|
||||
}: ResourceKindRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const fieldId = useId()
|
||||
|
||||
const total = items.length
|
||||
const selectedCount = items.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0)
|
||||
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
aria-label={`Copy all ${label}`}
|
||||
checked={headerState}
|
||||
onCheckedChange={() =>
|
||||
onToggleMany(
|
||||
items.map((item) => item.id),
|
||||
headerState !== true
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className='mt-1 max-h-44 overflow-y-auto border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{items.map((item) => {
|
||||
const isChecked = selected.has(item.id)
|
||||
const itemId = `${fieldId}-${item.id}`
|
||||
return (
|
||||
<label
|
||||
key={item.id}
|
||||
htmlFor={itemId}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={itemId}
|
||||
size='sm'
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className='truncate'>{item.label}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileKindRowProps {
|
||||
label: string
|
||||
files: ForkFlatFile[]
|
||||
selected: Set<string>
|
||||
onToggleAll: (selectAll: boolean) => void
|
||||
onToggleItem: (id: string, checked: boolean) => void
|
||||
onToggleMany: (ids: string[], checked: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The Files kind: a tri-state "select all" header (count selected / total) that expands to a
|
||||
* folder ▸ file tree, so the user can copy a whole folder or a specific file. Files are the only
|
||||
* copyable kind that nests; every other kind uses the flat {@link ResourceKindRow}. Shared by the
|
||||
* fork and sync copy pickers so both group files identically.
|
||||
*/
|
||||
export function FileKindRow({
|
||||
label,
|
||||
files,
|
||||
selected,
|
||||
onToggleAll,
|
||||
onToggleItem,
|
||||
onToggleMany,
|
||||
disabled = false,
|
||||
}: FileKindRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const total = files.length
|
||||
const selectedCount = files.filter((file) => selected.has(file.id)).length
|
||||
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
|
||||
|
||||
const { folders, rootFiles } = useMemo(() => groupForkFilesIntoFolders(files), [files])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
aria-label={`Copy all ${label}`}
|
||||
checked={headerState}
|
||||
onCheckedChange={() => onToggleAll(headerState !== true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className='mt-1 border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
|
||||
<ForkFileTree
|
||||
folders={folders}
|
||||
rootFiles={rootFiles}
|
||||
isSelected={(id) => selected.has(id)}
|
||||
onToggleFile={onToggleItem}
|
||||
onToggleMany={onToggleMany}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
forkBlockerResolution,
|
||||
selectVisibleClearedRefs,
|
||||
splitForkClearedRefs,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
|
||||
|
||||
type ReferenceRef = Extract<ForkClearedRef, { cause: 'reference' }>
|
||||
type WorkflowRef = Extract<ForkClearedRef, { cause: 'workflow' }>
|
||||
type DependentRef = Extract<ForkClearedRef, { cause: 'dependent' }>
|
||||
|
||||
const base = {
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
workflowName: 'Workflow',
|
||||
blockId: 'block-1',
|
||||
blockLabel: 'Block',
|
||||
sourceLabel: 'Source',
|
||||
}
|
||||
|
||||
const referenceRef = (
|
||||
kind: ReferenceRef['kind'],
|
||||
sourceId: string,
|
||||
fieldLabel = 'Field',
|
||||
sourceDeleted = false
|
||||
): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId, sourceDeleted })
|
||||
|
||||
const workflowRef = (sourceId: string, fieldLabel = 'Workflow'): WorkflowRef => ({
|
||||
...base,
|
||||
fieldLabel,
|
||||
cause: 'workflow',
|
||||
kind: 'workflow',
|
||||
sourceId,
|
||||
})
|
||||
|
||||
const dependentRef = (
|
||||
parentKind: DependentRef['parentKind'],
|
||||
parentSourceId: string,
|
||||
fieldLabel = 'Field'
|
||||
): DependentRef => ({
|
||||
...base,
|
||||
fieldLabel,
|
||||
cause: 'dependent',
|
||||
kind: parentKind,
|
||||
sourceId: parentSourceId,
|
||||
parentKind,
|
||||
parentSourceId,
|
||||
})
|
||||
|
||||
// The page's predicate is `mapped || copied`; here we model each disposition as a resolved key so
|
||||
// the document-under-KB case is exercised for both a copied parent and a mapped parent.
|
||||
const resolvedKeys = (...keys: string[]) => {
|
||||
const set = new Set(keys)
|
||||
return (kind: string, sourceId: string) => set.has(`${kind}:${sourceId}`)
|
||||
}
|
||||
|
||||
const documentDependent = dependentRef('knowledge-base', 'kb-1', 'Document')
|
||||
|
||||
describe('selectVisibleClearedRefs', () => {
|
||||
it('drops a document dependent when its parent KB is selected for copy', () => {
|
||||
expect(
|
||||
selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a document dependent when its parent KB is mapped', () => {
|
||||
// Map vs copy both resolve the parent through the same predicate; modeled identically here.
|
||||
expect(
|
||||
selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a document dependent while its parent KB is neither mapped nor copied', () => {
|
||||
expect(selectVisibleClearedRefs([documentDependent], resolvedKeys())).toEqual([
|
||||
documentDependent,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a credential-anchored dependent even when the credential is mapped (label still clears)', () => {
|
||||
const labelDependent = dependentRef('credential', 'cred-1', 'Label')
|
||||
// A mapped credential remaps to a different account, so the account-scoped label is cleared
|
||||
// regardless - the entry must stay even though the parent is "resolved".
|
||||
expect(selectVisibleClearedRefs([labelDependent], resolvedKeys('credential:cred-1'))).toEqual([
|
||||
labelDependent,
|
||||
])
|
||||
expect(selectVisibleClearedRefs([labelDependent], resolvedKeys())).toEqual([labelDependent])
|
||||
})
|
||||
|
||||
it('keeps a table-anchored dependent even when the table is copied/mapped (column still clears)', () => {
|
||||
const columnDependent = dependentRef('table', 'tbl-1', 'Column')
|
||||
expect(selectVisibleClearedRefs([columnDependent], resolvedKeys('table:tbl-1'))).toEqual([
|
||||
columnDependent,
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the parent KB reference AND its child document together when the KB is resolved', () => {
|
||||
const kbReference = referenceRef('knowledge-base', 'kb-1', 'Knowledge Base')
|
||||
expect(
|
||||
selectVisibleClearedRefs(
|
||||
[kbReference, documentDependent],
|
||||
resolvedKeys('knowledge-base:kb-1')
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('applies the same predicate to a reference entry (drops resolved, keeps unresolved)', () => {
|
||||
const credentialReference = referenceRef('credential', 'cred-1')
|
||||
expect(
|
||||
selectVisibleClearedRefs([credentialReference], resolvedKeys('credential:cred-1'))
|
||||
).toEqual([])
|
||||
expect(selectVisibleClearedRefs([credentialReference], resolvedKeys())).toEqual([
|
||||
credentialReference,
|
||||
])
|
||||
})
|
||||
|
||||
it('always keeps a workflow reference (it cannot be resolved on the page)', () => {
|
||||
const workflowReference = workflowRef('wf-other')
|
||||
expect(
|
||||
selectVisibleClearedRefs([workflowReference], resolvedKeys('workflow:wf-other'))
|
||||
).toEqual([workflowReference])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitForkClearedRefs', () => {
|
||||
it('splits reference/workflow causes into blockers and dependents into informational', () => {
|
||||
const tableReference = referenceRef('table', 'tbl-1')
|
||||
const workflowReference = workflowRef('wf-other')
|
||||
const labelDependent = dependentRef('credential', 'cred-1', 'Label')
|
||||
const { blockers, informational } = splitForkClearedRefs([
|
||||
tableReference,
|
||||
workflowReference,
|
||||
labelDependent,
|
||||
])
|
||||
expect(blockers).toEqual([tableReference, workflowReference])
|
||||
expect(informational).toEqual([labelDependent])
|
||||
})
|
||||
|
||||
it('treats an unmapped MCP server and a source-deleted reference as blockers', () => {
|
||||
const mcpReference = referenceRef('mcp-server', 'srv-1')
|
||||
const deletedReference = referenceRef('skill', 'sk-gone', 'Skill', true)
|
||||
const { blockers, informational } = splitForkClearedRefs([mcpReference, deletedReference])
|
||||
expect(blockers).toEqual([mcpReference, deletedReference])
|
||||
expect(informational).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkBlockerResolution', () => {
|
||||
it('phrases each blocker reason with its actionable resolution', () => {
|
||||
expect(forkBlockerResolution(referenceRef('table', 'tbl-1'))).toBe(
|
||||
'map it to a target or select it for copy'
|
||||
)
|
||||
expect(forkBlockerResolution(referenceRef('mcp-server', 'srv-1'))).toBe(
|
||||
'map it to a target or select it for copy'
|
||||
)
|
||||
expect(forkBlockerResolution(referenceRef('knowledge-base', 'kb-gone', 'KB', true))).toBe(
|
||||
'deleted in the source — map it to an existing knowledge base in the target'
|
||||
)
|
||||
expect(forkBlockerResolution(workflowRef('wf-other', 'Workflow'))).toBe(
|
||||
'deploy "Source" in the source or remove the reference'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for non-blocking dependent entries', () => {
|
||||
expect(forkBlockerResolution(dependentRef('credential', 'cred-1'))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
|
||||
import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers'
|
||||
|
||||
/** Whether a resource is resolved by the current selection (mapped to a target OR selected for copy). */
|
||||
export type ClearedRefResolvedPredicate = (kind: string, sourceId: string) => boolean
|
||||
|
||||
/**
|
||||
* Parent kinds whose dependent child is itself a resource carried alongside the parent, so
|
||||
* resolving the parent (mapping or copying it) PRESERVES the child rather than clearing it -
|
||||
* currently just knowledge bases: a referenced document is copied with its KB, or auto-copied into
|
||||
* a mapped KB, and remapped in place. Any other parent's child (a credential's label, a table's
|
||||
* column) is account/table-scoped and cleared whenever the parent is remapped (the engine's
|
||||
* `clearDependentsOnRemap`), so those entries stay regardless of the parent's disposition.
|
||||
*/
|
||||
const PARENT_KINDS_THAT_PRESERVE_CHILD: ReadonlySet<string> = new Set(['knowledge-base'])
|
||||
|
||||
/**
|
||||
* Narrow the diff's cleared-ref candidates to those still cleared under the live selection:
|
||||
* - `reference`: drops off once its own resource is resolved (mapped or copied).
|
||||
* - `dependent`: drops off once its PARENT resource (`parentKind`/`parentSourceId`) is resolved -
|
||||
* using the SAME predicate as `reference` - but ONLY when the child follows that parent (a
|
||||
* document under a KB). A credential- or table-anchored dependent is cleared on any parent remap,
|
||||
* so it stays even after the parent is mapped.
|
||||
* - `workflow`: always stays - a cross-workflow reference cannot be resolved here.
|
||||
*
|
||||
* Pure so the reactive list is unit-testable independent of the page's selection state.
|
||||
*/
|
||||
export function selectVisibleClearedRefs(
|
||||
clearedRefs: ForkClearedRef[],
|
||||
isResolved: ClearedRefResolvedPredicate
|
||||
): ForkClearedRef[] {
|
||||
return clearedRefs.filter((ref) => {
|
||||
if (ref.cause === 'reference') return !isResolved(ref.kind, ref.sourceId)
|
||||
// The discriminated union guarantees `parentKind`/`parentSourceId` on a `dependent` variant.
|
||||
if (ref.cause === 'dependent' && PARENT_KINDS_THAT_PRESERVE_CHILD.has(ref.parentKind)) {
|
||||
return !isResolved(ref.parentKind, ref.parentSourceId)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the visible would-clear entries into sync BLOCKERS (cause `reference`/`workflow` - the
|
||||
* sync is disabled while any remain) and the informational remainder (`dependent` entries, owned
|
||||
* by the reconfigure flow - they clear but never block). Pure, so the page's gate and the two
|
||||
* sections stay one testable rule.
|
||||
*/
|
||||
export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): {
|
||||
blockers: ForkClearedRef[]
|
||||
informational: ForkClearedRef[]
|
||||
} {
|
||||
const blockers: ForkClearedRef[] = []
|
||||
const informational: ForkClearedRef[] = []
|
||||
for (const ref of visibleRefs) {
|
||||
if (forkSyncBlockerReasonFor(ref)) blockers.push(ref)
|
||||
else informational.push(ref)
|
||||
}
|
||||
return { blockers, informational }
|
||||
}
|
||||
|
||||
/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */
|
||||
const BLOCKER_KIND_LABEL: Record<string, string> = {
|
||||
table: 'table',
|
||||
'knowledge-base': 'knowledge base',
|
||||
file: 'file',
|
||||
'custom-tool': 'custom tool',
|
||||
skill: 'skill',
|
||||
'mcp-server': 'MCP server',
|
||||
}
|
||||
|
||||
/**
|
||||
* The actionable resolution line for a blocking entry, phrased for "{block} would lose {field}
|
||||
* in {workflow} - {resolution}". Null for non-blocking (dependent) entries.
|
||||
*/
|
||||
export function forkBlockerResolution(ref: ForkClearedRef): string | null {
|
||||
const reason = forkSyncBlockerReasonFor(ref)
|
||||
if (!reason) return null
|
||||
switch (reason) {
|
||||
case 'unmapped-copyable':
|
||||
return 'map it to a target or select it for copy'
|
||||
case 'source-deleted':
|
||||
return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
|
||||
case 'workflow-missing':
|
||||
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
effectiveForkTarget,
|
||||
forkCopyingKeys,
|
||||
forkDefaultCopySelection,
|
||||
forkMappedCopyableKeys,
|
||||
forkParentResolution,
|
||||
forkRefKey,
|
||||
forkRequiredKindsLabel,
|
||||
forkRequiredPending,
|
||||
forkVisibleCopyables,
|
||||
isForkRequiredComplete,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
|
||||
|
||||
const entry = (overrides: Partial<ForkMappingEntry>): ForkMappingEntry => ({
|
||||
kind: 'credential',
|
||||
resourceType: 'oauth_credential',
|
||||
sourceId: 'src',
|
||||
sourceLabel: 'Src',
|
||||
targetId: null,
|
||||
suggested: false,
|
||||
required: false,
|
||||
candidates: [],
|
||||
candidatesTruncated: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const copyable = (overrides: Partial<ForkCopyableUnmapped>): ForkCopyableUnmapped => ({
|
||||
kind: 'knowledge-base',
|
||||
sourceId: 'kb',
|
||||
label: 'KB',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('forkRefKey / effectiveForkTarget', () => {
|
||||
it('shares the kind:sourceId keyspace for entries and copyables', () => {
|
||||
expect(forkRefKey({ kind: 'knowledge-base', sourceId: 'kb-1' })).toBe('knowledge-base:kb-1')
|
||||
expect(forkRefKey(entry({ kind: 'table', sourceId: 't-1' }))).toBe('table:t-1')
|
||||
})
|
||||
|
||||
it('prefers an in-session override, else the persisted target, else empty', () => {
|
||||
const e = entry({ kind: 'table', sourceId: 't-1', targetId: 'persisted' })
|
||||
expect(effectiveForkTarget(e, { 'table:t-1': 'in-session' })).toBe('in-session')
|
||||
expect(effectiveForkTarget(e, {})).toBe('persisted')
|
||||
expect(effectiveForkTarget(entry({ kind: 'table', sourceId: 't-1' }), {})).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy-vs-map reconciliation', () => {
|
||||
it('drops a mapped copyable from the visible copy list (maps win over copy)', () => {
|
||||
const entries = [
|
||||
entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }),
|
||||
entry({ kind: 'table', sourceId: 'tbl-1' }),
|
||||
]
|
||||
const candidates = [
|
||||
copyable({ kind: 'knowledge-base', sourceId: 'kb-1' }),
|
||||
copyable({ kind: 'table', sourceId: 'tbl-1' }),
|
||||
]
|
||||
const mapped = forkMappedCopyableKeys(entries, {})
|
||||
expect(mapped.has('knowledge-base:kb-1')).toBe(true)
|
||||
expect(mapped.has('table:tbl-1')).toBe(false)
|
||||
expect(forkVisibleCopyables(candidates, mapped).map(forkRefKey)).toEqual(['table:tbl-1'])
|
||||
})
|
||||
|
||||
it('an in-session mapping target also drops the copyable', () => {
|
||||
const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1' })]
|
||||
const mapped = forkMappedCopyableKeys(entries, { 'knowledge-base:kb-1': 'kb-tgt' })
|
||||
expect(
|
||||
forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mapped)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('copyingKeys = the visible candidates that are selected for copy', () => {
|
||||
const visible = [
|
||||
copyable({ kind: 'table', sourceId: 'tbl-1' }),
|
||||
copyable({ kind: 'skill', sourceId: 'sk-1' }),
|
||||
]
|
||||
expect([...forkCopyingKeys(visible, new Set(['table:tbl-1']))]).toEqual(['table:tbl-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkDefaultCopySelection', () => {
|
||||
it('seeds every referenced candidate and leaves unreferenced ones unselected', () => {
|
||||
const selection = forkDefaultCopySelection([
|
||||
copyable({ kind: 'knowledge-base', sourceId: 'kb-1', referenced: true }),
|
||||
copyable({ kind: 'table', sourceId: 'tbl-new', referenced: false }),
|
||||
copyable({ kind: 'file', sourceId: 'workspace/SRC/new.png', referenced: false }),
|
||||
])
|
||||
expect(selection).toEqual(new Set(['knowledge-base:kb-1']))
|
||||
})
|
||||
|
||||
it('seeds nothing when every candidate is unreferenced', () => {
|
||||
expect(
|
||||
forkDefaultCopySelection([copyable({ kind: 'skill', sourceId: 'sk-new', referenced: false })])
|
||||
).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('isForkRequiredComplete', () => {
|
||||
it('a required ref is satisfied by a mapping target', () => {
|
||||
const entries = [
|
||||
entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' }),
|
||||
]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('a required ref is satisfied by a copy selection (server accepts copy as resolving it)', () => {
|
||||
const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set(['knowledge-base:kb-1']))).toBe(true)
|
||||
})
|
||||
|
||||
it('a required ref neither mapped nor copied blocks', () => {
|
||||
const entries = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
|
||||
})
|
||||
|
||||
it('a referenced MCP server (map-only, required) blocks until mapped - copy cannot satisfy it', () => {
|
||||
const entries = [
|
||||
entry({ kind: 'mcp-server', resourceType: 'mcp_server', sourceId: 'srv-1', required: true }),
|
||||
]
|
||||
// MCP servers are never copy candidates, so the copy set can't contain them; only a
|
||||
// mapping target resolves the entry.
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
|
||||
expect(isForkRequiredComplete(entries, { 'mcp-server:srv-1': 'srv-tgt' }, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('a source-deleted referenced resource (required, no copy candidate) blocks until mapped', () => {
|
||||
// A deleted source is dropped from the copy candidates (its label lookup fails), so the
|
||||
// only resolution is mapping the dead id to a live target resource.
|
||||
const entries = [
|
||||
entry({ kind: 'table', resourceType: 'table', sourceId: 'tbl-gone', required: true }),
|
||||
]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
|
||||
expect(isForkRequiredComplete(entries, { 'table:tbl-gone': 'tbl-live' }, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('optional refs never block', () => {
|
||||
const entries = [entry({ kind: 'table', sourceId: 't1', required: false })]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkRequiredKindsLabel', () => {
|
||||
it('names credentials and secrets by kind, together or alone', () => {
|
||||
expect(forkRequiredKindsLabel(new Set(['credential', 'env-var']))).toBe(
|
||||
'credentials and secrets'
|
||||
)
|
||||
expect(forkRequiredKindsLabel(new Set(['credential']))).toBe('credentials')
|
||||
expect(forkRequiredKindsLabel(new Set(['env-var']))).toBe('secrets')
|
||||
})
|
||||
|
||||
it('falls back to "references" for any other (or empty) kind set', () => {
|
||||
expect(forkRequiredKindsLabel(new Set(['table']))).toBe('references')
|
||||
expect(forkRequiredKindsLabel(new Set())).toBe('references')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkParentResolution', () => {
|
||||
const kb = entry({ kind: 'knowledge-base', sourceId: 'kb-1' })
|
||||
|
||||
it('is copied when the entry is selected for copy', () => {
|
||||
expect(forkParentResolution(kb, {}, new Set(['knowledge-base:kb-1']))).toBe('copied')
|
||||
})
|
||||
|
||||
it('is mapped with a persisted or in-session target, unresolved with neither', () => {
|
||||
expect(
|
||||
forkParentResolution(
|
||||
entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }),
|
||||
{},
|
||||
new Set()
|
||||
)
|
||||
).toBe('mapped')
|
||||
expect(forkParentResolution(kb, { 'knowledge-base:kb-1': 'kb-tgt' }, new Set())).toBe('mapped')
|
||||
expect(forkParentResolution(kb, {}, new Set())).toBe('unresolved')
|
||||
})
|
||||
|
||||
it('toggling copy⇄map flips the resolution (the selector scope + seed follow it)', () => {
|
||||
// Copy-selected: the dependent selectors browse the SOURCE parent.
|
||||
const copying = new Set(['knowledge-base:kb-1'])
|
||||
expect(forkParentResolution(kb, {}, copying)).toBe('copied')
|
||||
// The user maps a target instead: the mapped entry drops out of the visible copyables
|
||||
// (copy-vs-map reconciliation), so its copying key disappears and the resolution flips.
|
||||
const targets = { 'knowledge-base:kb-1': 'kb-tgt' }
|
||||
const mappedKeys = forkMappedCopyableKeys([kb], targets)
|
||||
const copyingAfterMap = forkCopyingKeys(
|
||||
forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mappedKeys),
|
||||
copying
|
||||
)
|
||||
expect(forkParentResolution(kb, targets, copyingAfterMap)).toBe('mapped')
|
||||
// Back to copy ('' target override): the copyable is visible + still selected again.
|
||||
const cleared = { 'knowledge-base:kb-1': '' }
|
||||
const copyingAfterClear = forkCopyingKeys(
|
||||
forkVisibleCopyables(
|
||||
[copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })],
|
||||
forkMappedCopyableKeys([kb], cleared)
|
||||
),
|
||||
copying
|
||||
)
|
||||
expect(forkParentResolution(kb, cleared, copyingAfterClear)).toBe('copied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkRequiredPending', () => {
|
||||
it('is true when a required ref is neither mapped nor selected for copy', () => {
|
||||
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
|
||||
expect(forkRequiredPending(items, {}, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the required ref is selected for copy', () => {
|
||||
const items = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })]
|
||||
expect(forkRequiredPending(items, {}, new Set(['knowledge-base:kb-1']))).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when the required ref is mapped', () => {
|
||||
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' })]
|
||||
expect(forkRequiredPending(items, {}, new Set())).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
/** `${kind}:${sourceId}` - the shared key for a mapping entry and its copy candidate. */
|
||||
export const forkRefKey = (ref: { kind: string; sourceId: string }): string =>
|
||||
`${ref.kind}:${ref.sourceId}`
|
||||
|
||||
/** Effective mapping target: the in-session override, else the persisted target, else ''. */
|
||||
export function effectiveForkTarget(
|
||||
entry: ForkMappingEntry,
|
||||
targets: Record<string, string>
|
||||
): string {
|
||||
return targets[forkRefKey(entry)] ?? entry.targetId ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys of copyable resources that already have a mapping target (in-session or persisted). Maps win
|
||||
* over copy, so these drop out of the copy list - the copy-vs-map reconciliation.
|
||||
*/
|
||||
export function forkMappedCopyableKeys(
|
||||
entries: ForkMappingEntry[],
|
||||
targets: Record<string, string>
|
||||
): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
if (effectiveForkTarget(entry, targets) !== '') keys.add(forkRefKey(entry))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Copy candidates the user has not mapped (a mapped copyable is excluded - copy-vs-map reconcile). */
|
||||
export function forkVisibleCopyables(
|
||||
copyableUnmapped: ForkCopyableUnmapped[],
|
||||
mappedKeys: ReadonlySet<string>
|
||||
): ForkCopyableUnmapped[] {
|
||||
return copyableUnmapped.filter((candidate) => !mappedKeys.has(forkRefKey(candidate)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy selection seeded once the diff settles: every REFERENCED candidate (deselecting one
|
||||
* clears its references, so the common case needs no clicks). Unreferenced candidates - used by
|
||||
* no synced workflow - start unselected: copying them is opt-in, so scratch data created in the
|
||||
* source is never pushed by surprise.
|
||||
*/
|
||||
export function forkDefaultCopySelection(copyableUnmapped: ForkCopyableUnmapped[]): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const candidate of copyableUnmapped) {
|
||||
if (candidate.referenced) keys.add(forkRefKey(candidate))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Keys of the visible copy candidates actually selected for copy. */
|
||||
export function forkCopyingKeys(
|
||||
visibleCopyables: ForkCopyableUnmapped[],
|
||||
copySelected: ReadonlySet<string>
|
||||
): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const candidate of visibleCopyables) {
|
||||
const key = forkRefKey(candidate)
|
||||
if (copySelected.has(key)) keys.add(key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* How a mapping entry is resolved under the live selection, for its dependents' behavior:
|
||||
* - `copied`: selected for copy - dependents stay editable against the SOURCE parent (the copy
|
||||
* will contain the source's children), seeded from the source reference.
|
||||
* - `mapped`: has an effective target - dependents re-pick against the TARGET parent.
|
||||
* - `unresolved`: neither - dependents are disabled; the parent's own gate owns the block.
|
||||
* Mapped and copied are mutually exclusive by construction (a mapped copyable is excluded from
|
||||
* the copy candidates), so the branch order is not load-bearing.
|
||||
*/
|
||||
export type ForkParentResolution = 'mapped' | 'copied' | 'unresolved'
|
||||
|
||||
export function forkParentResolution(
|
||||
entry: ForkMappingEntry,
|
||||
targets: Record<string, string>,
|
||||
copyingKeys: ReadonlySet<string>
|
||||
): ForkParentResolution {
|
||||
if (copyingKeys.has(forkRefKey(entry))) return 'copied'
|
||||
return effectiveForkTarget(entry, targets) !== '' ? 'mapped' : 'unresolved'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every required reference is satisfied - it has a mapping target OR is selected for copy.
|
||||
* The server accepts a copy as resolving a required ref (promote.ts `willResolve`), so the client
|
||||
* gate must too. No double-count: a mapped copyable is excluded from the copy candidates, so the two
|
||||
* branches are mutually exclusive.
|
||||
*/
|
||||
export function isForkRequiredComplete(
|
||||
entries: ForkMappingEntry[],
|
||||
targets: Record<string, string>,
|
||||
copyingKeys: ReadonlySet<string>
|
||||
): boolean {
|
||||
return entries.every(
|
||||
(entry) =>
|
||||
!entry.required ||
|
||||
effectiveForkTarget(entry, targets) !== '' ||
|
||||
copyingKeys.has(forkRefKey(entry))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any reference in a kind is required AND still unmapped AND not selected for copy - drives
|
||||
* the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}'s satisfied rule.
|
||||
*/
|
||||
export function forkRequiredPending(
|
||||
items: ForkMappingEntry[],
|
||||
targets: Record<string, string>,
|
||||
copyingKeys: ReadonlySet<string>
|
||||
): boolean {
|
||||
return items.some(
|
||||
(entry) =>
|
||||
entry.required &&
|
||||
effectiveForkTarget(entry, targets) === '' &&
|
||||
!copyingKeys.has(forkRefKey(entry))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Human label for the kinds still failing the required gate, for "Map all required {label} first"
|
||||
* messaging - shared by the Sync button's disabled tooltip (client gate) and the server gate's
|
||||
* failure toast so both name the obstacle identically. Credentials and secrets are named
|
||||
* explicitly: they are the map-only kinds that fail the required gate WITHOUT also appearing in
|
||||
* the cleared-ref blockers (the collector excludes them), so they need their own wording. Any
|
||||
* other kind falls back to "references".
|
||||
*/
|
||||
export function forkRequiredKindsLabel(kinds: ReadonlySet<string>): string {
|
||||
const credentials = kinds.has('credential')
|
||||
const secrets = kinds.has('env-var')
|
||||
if (credentials && secrets) return 'credentials and secrets'
|
||||
if (credentials) return 'credentials'
|
||||
if (secrets) return 'secrets'
|
||||
return 'references'
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
|
||||
|
||||
interface DependentFieldSelectorProps {
|
||||
selectorKey: SelectorKey
|
||||
/** Full selector context, including the newly-chosen parent value. */
|
||||
context: Record<string, string>
|
||||
/** False until the parent (credential/KB) target is chosen. */
|
||||
enabled: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A controlled, standalone selector for the sync page's pre-sync reconfigure: fetches
|
||||
* options via the shared selector data layer (the same `useSelectorOptions` registry the
|
||||
* canvas selectors use) without the canvas store/blockId coupling. Mirrors
|
||||
* {@link ConnectorSelectorField}.
|
||||
*/
|
||||
export function DependentFieldSelector({
|
||||
selectorKey,
|
||||
context,
|
||||
enabled,
|
||||
value,
|
||||
onChange,
|
||||
title,
|
||||
}: DependentFieldSelectorProps) {
|
||||
const selectorContext = useMemo<SelectorContext>(() => {
|
||||
const ctx: SelectorContext = {}
|
||||
Object.assign(ctx, context)
|
||||
return ctx
|
||||
}, [context])
|
||||
|
||||
const { data: options = [], isLoading } = useSelectorOptions(selectorKey, {
|
||||
context: selectorContext,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => options.map((option) => ({ label: option.label, value: option.id })),
|
||||
[options]
|
||||
)
|
||||
|
||||
if (isLoading && enabled) {
|
||||
return (
|
||||
<div className='flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-medium text-[var(--text-muted)] text-small dark:bg-[var(--surface-4)]'>
|
||||
<Loader className='size-3.5' animate />
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipCombobox
|
||||
className='w-full'
|
||||
options={comboboxOptions}
|
||||
value={value || undefined}
|
||||
onChange={(next) => onChange(next)}
|
||||
searchable
|
||||
searchPlaceholder={`Search ${title.toLowerCase()}...`}
|
||||
placeholder={`Select ${title.toLowerCase()}`}
|
||||
disabled={!enabled}
|
||||
emptyMessage={`No ${title.toLowerCase()} found`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
dependentKey,
|
||||
effectiveCopyDependentValue,
|
||||
effectiveDependentValue,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
|
||||
|
||||
const field = (overrides: Partial<ForkDependentReconfig> = {}): ForkDependentReconfig => ({
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-1',
|
||||
parentContextKey: 'oauthCredential',
|
||||
targetWorkflowId: 'wf-1',
|
||||
targetBlockId: 'blk-1',
|
||||
blockName: 'Gmail',
|
||||
subBlockKey: 'folder',
|
||||
selectorKey: 'gmail.labels',
|
||||
title: 'Label',
|
||||
currentValue: 'INBOX',
|
||||
required: false,
|
||||
consumesContextKeys: [],
|
||||
context: {},
|
||||
sourceValue: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('dependentKey', () => {
|
||||
it('keys by target workflow + block + subblock', () => {
|
||||
expect(
|
||||
dependentKey(field({ targetWorkflowId: 'w', targetBlockId: 'b', subBlockKey: 's' }))
|
||||
).toBe('w:b:s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('effectiveDependentValue', () => {
|
||||
it('returns the in-session re-pick when present', () => {
|
||||
const f = field()
|
||||
expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_42' }, false)).toBe('Label_42')
|
||||
})
|
||||
|
||||
it('returns the stored currentValue when no re-pick and the parent is unchanged', () => {
|
||||
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, false)).toBe('INBOX')
|
||||
})
|
||||
|
||||
it('returns blank when the parent changed (the stored value no longer resolves)', () => {
|
||||
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, true)).toBe('')
|
||||
})
|
||||
|
||||
it('an in-session re-pick wins even when the parent changed', () => {
|
||||
const f = field({ currentValue: 'INBOX' })
|
||||
expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_99' }, true)).toBe('Label_99')
|
||||
})
|
||||
|
||||
it('an explicit empty re-pick is respected (not treated as absent)', () => {
|
||||
const f = field({ currentValue: 'INBOX' })
|
||||
expect(effectiveDependentValue(f, { [dependentKey(f)]: '' }, false)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('effectiveCopyDependentValue', () => {
|
||||
const copyField = (overrides: Partial<ForkDependentReconfig> = {}) =>
|
||||
field({
|
||||
parentKind: 'knowledge-base',
|
||||
parentSourceId: 'kb-src',
|
||||
parentContextKey: 'knowledgeBaseId',
|
||||
subBlockKey: 'documentSelector',
|
||||
selectorKey: 'knowledge.documents',
|
||||
title: 'Document',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('seeds from the raw source reference when nothing is stored (the copy will contain it)', () => {
|
||||
const f = copyField({ currentValue: '', sourceValue: 'doc-src' })
|
||||
expect(effectiveCopyDependentValue(f, {})).toBe('doc-src')
|
||||
})
|
||||
|
||||
it('prefers the stored value over the source reference (a saved re-pick survives reload)', () => {
|
||||
const f = copyField({ currentValue: 'doc-saved', sourceValue: 'doc-src' })
|
||||
expect(effectiveCopyDependentValue(f, {})).toBe('doc-saved')
|
||||
})
|
||||
|
||||
it('an in-session re-pick wins over both', () => {
|
||||
const f = copyField({ currentValue: 'doc-saved', sourceValue: 'doc-src' })
|
||||
expect(effectiveCopyDependentValue(f, { [dependentKey(f)]: 'doc-picked' })).toBe('doc-picked')
|
||||
})
|
||||
|
||||
it('an explicit empty re-pick is respected (a required field then gates as usual)', () => {
|
||||
const f = copyField({ currentValue: '', sourceValue: 'doc-src' })
|
||||
expect(effectiveCopyDependentValue(f, { [dependentKey(f)]: '' })).toBe('')
|
||||
})
|
||||
|
||||
it('is blank when the source never referenced anything and nothing was stored', () => {
|
||||
const f = copyField({ currentValue: '', sourceValue: '' })
|
||||
expect(effectiveCopyDependentValue(f, {})).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
/** Stable key for a per-target dependent re-pick (target workflow + block + subblock). */
|
||||
export function dependentKey(dependent: ForkDependentReconfig): string {
|
||||
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
|
||||
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old
|
||||
* stored value was for the previous parent and won't resolve against the new one. Shared by the
|
||||
* sync gate + payload build and the per-block selector so the rule can't drift between them.
|
||||
*/
|
||||
export function effectiveDependentValue(
|
||||
field: ForkDependentReconfig,
|
||||
reconfig: Record<string, string>,
|
||||
parentChanged: boolean
|
||||
): string {
|
||||
const repicked = reconfig[dependentKey(field)]
|
||||
if (repicked !== undefined) return repicked
|
||||
return parentChanged ? '' : field.currentValue
|
||||
}
|
||||
|
||||
/**
|
||||
* The value sent + displayed for a dependent whose parent is resolved by COPY: the user's
|
||||
* in-session re-pick, else the stored value, else the field's raw SOURCE reference. The copy
|
||||
* brings the source parent's children along (a copied KB carries its referenced documents), so
|
||||
* the source reference is exactly what the copied parent will contain - the selector browses the
|
||||
* SOURCE parent and this seed resolves there. An explicit empty re-pick is respected (it gates a
|
||||
* required field as usual).
|
||||
*/
|
||||
export function effectiveCopyDependentValue(
|
||||
field: ForkDependentReconfig,
|
||||
reconfig: Record<string, string>
|
||||
): string {
|
||||
const repicked = reconfig[dependentKey(field)]
|
||||
if (repicked !== undefined) return repicked
|
||||
return field.currentValue || field.sourceValue
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
'use client'
|
||||
|
||||
import { type Dispatch, Fragment, type SetStateAction, useMemo, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
ChevronDown,
|
||||
ChipCombobox,
|
||||
ChipSwitch,
|
||||
CollapsibleCard,
|
||||
cn,
|
||||
FieldDivider,
|
||||
Label,
|
||||
} from '@sim/emcn'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import type {
|
||||
ForkCopyableUnmapped,
|
||||
ForkDependentReconfig,
|
||||
ForkMappingEntry,
|
||||
ForkResourceUsage,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import {
|
||||
FileKindRow,
|
||||
ResourceKindRow,
|
||||
} from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker'
|
||||
import { forkBlockerResolution } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
|
||||
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
|
||||
import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector'
|
||||
import {
|
||||
dependentKey,
|
||||
effectiveCopyDependentValue,
|
||||
effectiveDependentValue,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
|
||||
import type {
|
||||
ForkKindSummary,
|
||||
ForkMappingGroup,
|
||||
ForkSyncController,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
|
||||
import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
/**
|
||||
* Copyable kinds as expandable rows in the "Copy resources" section, ordered + labeled to match
|
||||
* the fork modal's resource picker exactly. Files nest in a folder ▸ file tree; every other kind
|
||||
* is a flat list.
|
||||
*/
|
||||
const COPYABLE_KIND_SECTIONS: ReadonlyArray<{
|
||||
kind: ForkCopyableUnmapped['kind']
|
||||
label: string
|
||||
}> = [
|
||||
{ kind: 'file', label: 'Files' },
|
||||
{ kind: 'table', label: 'Tables' },
|
||||
{ kind: 'knowledge-base', label: 'Knowledge bases' },
|
||||
{ kind: 'custom-tool', label: 'Custom tools' },
|
||||
{ kind: 'skill', label: 'Skills' },
|
||||
{ kind: 'mcp-server', label: 'MCP servers' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Sentinel option value for the "New copy" entry - the displayed resolution while a copyable
|
||||
* is copy-selected, and the way back to the copy flow after mapping. Handled via onSelect,
|
||||
* never sent.
|
||||
*/
|
||||
const NEW_COPY_VALUE = '__new_copy__'
|
||||
|
||||
/** Fixed target-picker width so every mapping row's control lines up as one column (mirrors General). */
|
||||
const MAPPING_TARGET_TRIGGER_CLASS = 'w-[240px] flex-shrink-0'
|
||||
|
||||
interface DependentBlock {
|
||||
targetBlockId: string
|
||||
blockName: string
|
||||
fields: ForkDependentReconfig[]
|
||||
}
|
||||
|
||||
interface WorkflowDependents {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
blocks: DependentBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket an entry's dependents per workflow, then per block within it - the
|
||||
* workflow → block hierarchy the workflow cards render from.
|
||||
*/
|
||||
function groupDependentsByWorkflow(
|
||||
workflows: ForkResourceUsage['workflows'],
|
||||
dependents: ForkDependentReconfig[]
|
||||
): WorkflowDependents[] {
|
||||
const byWorkflow = new Map<string, ForkDependentReconfig[]>()
|
||||
for (const dependent of dependents) {
|
||||
const list = byWorkflow.get(dependent.targetWorkflowId)
|
||||
if (list) list.push(dependent)
|
||||
else byWorkflow.set(dependent.targetWorkflowId, [dependent])
|
||||
}
|
||||
return workflows.map((workflow) => {
|
||||
const byBlock = new Map<string, DependentBlock>()
|
||||
for (const field of byWorkflow.get(workflow.workflowId) ?? []) {
|
||||
let block = byBlock.get(field.targetBlockId)
|
||||
if (!block) {
|
||||
block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] }
|
||||
byBlock.set(field.targetBlockId, block)
|
||||
}
|
||||
block.fields.push(field)
|
||||
}
|
||||
return {
|
||||
workflowId: workflow.workflowId,
|
||||
workflowName: workflow.workflowName,
|
||||
blocks: Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Chain state for one block: the SelectorContext values its parent fields provide. */
|
||||
function blockChainState(
|
||||
block: DependentBlock,
|
||||
effectiveValue: (field: ForkDependentReconfig) => string
|
||||
) {
|
||||
const providedValues: Record<string, string> = {}
|
||||
const providedContextKeys = new Set<string>()
|
||||
for (const field of block.fields) {
|
||||
if (field.providesContextKey) {
|
||||
providedContextKeys.add(field.providesContextKey)
|
||||
const value = effectiveValue(field)
|
||||
if (value) providedValues[field.providesContextKey] = value
|
||||
}
|
||||
}
|
||||
return { providedValues, providedContextKeys }
|
||||
}
|
||||
|
||||
/** Store a re-pick and invalidate in-block children chained off the changed field. */
|
||||
function applyDependentRepick(
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>,
|
||||
field: ForkDependentReconfig,
|
||||
blockFields: ForkDependentReconfig[],
|
||||
value: string
|
||||
) {
|
||||
setReconfig((prev) => {
|
||||
const nextState = { ...prev, [dependentKey(field)]: value }
|
||||
// A changed parent invalidates its children's stale re-picks.
|
||||
const providedKey = field.providesContextKey
|
||||
if (providedKey) {
|
||||
for (const sibling of blockFields) {
|
||||
if (sibling.consumesContextKeys.includes(providedKey)) {
|
||||
delete nextState[dependentKey(sibling)]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextState
|
||||
})
|
||||
}
|
||||
|
||||
interface DependentSelectorProps {
|
||||
field: ForkDependentReconfig
|
||||
block: DependentBlock
|
||||
target: string
|
||||
parentChanged: boolean
|
||||
/** True when the parent is resolved by COPY: browse the SOURCE parent, seeded from the source. */
|
||||
copying: boolean
|
||||
workspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* One depends-on field's selector. Under a MAPPED parent it browses the TARGET parent
|
||||
* (pre-filled from the stored value, blank after a parent change) and is disabled until the
|
||||
* parent target is set. Under a COPY-resolved parent it browses the SOURCE parent (the copy
|
||||
* will contain exactly those children), pre-filled with the source reference. Either way it
|
||||
* stays disabled until every chained in-block parent has a value, and a re-pick invalidates
|
||||
* chained children.
|
||||
*/
|
||||
function DependentSelector({
|
||||
field,
|
||||
block,
|
||||
target,
|
||||
parentChanged,
|
||||
copying,
|
||||
workspaceId,
|
||||
sourceWorkspaceId,
|
||||
reconfig,
|
||||
setReconfig,
|
||||
}: DependentSelectorProps) {
|
||||
const effectiveValue = (f: ForkDependentReconfig) =>
|
||||
copying
|
||||
? effectiveCopyDependentValue(f, reconfig)
|
||||
: effectiveDependentValue(f, reconfig, parentChanged)
|
||||
const { providedValues, providedContextKeys } = blockChainState(block, effectiveValue)
|
||||
// Disabled until every in-block parent it depends on has a value, so a child never queries
|
||||
// a stale upstream value.
|
||||
const ready = field.consumesContextKeys.every(
|
||||
(key) => !providedContextKeys.has(key) || providedValues[key] !== undefined
|
||||
)
|
||||
// A copy-resolved parent has no target id until the sync runs - scope to the SOURCE parent
|
||||
// instead (its children are what the copy brings), keeping the selector fully editable.
|
||||
const parentValue = copying ? field.parentSourceId : target
|
||||
return (
|
||||
<DependentFieldSelector
|
||||
selectorKey={field.selectorKey as SelectorKey}
|
||||
context={{
|
||||
...field.context,
|
||||
...providedValues,
|
||||
// Owning workspace, for workspace-scoped selectors like table.columns.
|
||||
workspaceId: copying ? sourceWorkspaceId : workspaceId,
|
||||
[field.parentContextKey]: parentValue,
|
||||
}}
|
||||
enabled={parentValue !== '' && ready}
|
||||
value={effectiveValue(field)}
|
||||
onChange={(value) => applyDependentRepick(setReconfig, field, block.fields, value)}
|
||||
title={field.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface DependentWorkflowCardProps {
|
||||
workflow: WorkflowDependents
|
||||
target: string
|
||||
parentChanged: boolean
|
||||
/** True when the parent is resolved by COPY - the selectors browse the SOURCE parent. */
|
||||
copying: boolean
|
||||
workspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* One workflow's dependent fields as a collapsible card (the same `CollapsibleCard` the table
|
||||
* workflow sidebar's input mapping and the enrichment config use): the header names the
|
||||
* workflow; the body groups fields under block → optional tool → plain field label.
|
||||
* Cards holding a required field start expanded - a required field is what gates Sync.
|
||||
*/
|
||||
function DependentWorkflowCard({
|
||||
workflow,
|
||||
target,
|
||||
parentChanged,
|
||||
copying,
|
||||
workspaceId,
|
||||
sourceWorkspaceId,
|
||||
reconfig,
|
||||
setReconfig,
|
||||
}: DependentWorkflowCardProps) {
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => !workflow.blocks.some((block) => block.fields.some((field) => field.required))
|
||||
)
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={workflow.workflowName}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
{workflow.blocks.map((block) => {
|
||||
const topLevel = block.fields.filter((field) => !field.toolName)
|
||||
const byTool = new Map<string, ForkDependentReconfig[]>()
|
||||
for (const field of block.fields) {
|
||||
if (!field.toolName) continue
|
||||
const list = byTool.get(field.toolName)
|
||||
if (list) list.push(field)
|
||||
else byTool.set(field.toolName, [field])
|
||||
}
|
||||
const toolGroups = Array.from(byTool.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return (
|
||||
<div key={block.targetBlockId} className='flex flex-col gap-2'>
|
||||
<Label className='text-small'>{block.blockName}</Label>
|
||||
{topLevel.map((field) => (
|
||||
<div key={dependentKey(field)} className='flex flex-col gap-1'>
|
||||
<Label className='text-[var(--text-muted)] text-caption'>
|
||||
{field.title}
|
||||
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
|
||||
</Label>
|
||||
<DependentSelector
|
||||
field={field}
|
||||
block={block}
|
||||
target={target}
|
||||
parentChanged={parentChanged}
|
||||
copying={copying}
|
||||
workspaceId={workspaceId}
|
||||
sourceWorkspaceId={sourceWorkspaceId}
|
||||
reconfig={reconfig}
|
||||
setReconfig={setReconfig}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{toolGroups.map(([toolName, fields]) => (
|
||||
<div key={toolName} className='flex flex-col gap-1.5 pl-2'>
|
||||
<span className='text-[var(--text-muted)] text-small'>{toolName}</span>
|
||||
{fields.map((field) => (
|
||||
<div key={dependentKey(field)} className='flex flex-col gap-1'>
|
||||
<Label className='text-[var(--text-muted)] text-caption'>
|
||||
{field.title}
|
||||
{field.required ? (
|
||||
<span className='text-[var(--text-error)]'> *</span>
|
||||
) : null}
|
||||
</Label>
|
||||
<DependentSelector
|
||||
field={field}
|
||||
block={block}
|
||||
target={target}
|
||||
parentChanged={parentChanged}
|
||||
copying={copying}
|
||||
workspaceId={workspaceId}
|
||||
sourceWorkspaceId={sourceWorkspaceId}
|
||||
reconfig={reconfig}
|
||||
setReconfig={setReconfig}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
interface MappingEntryProps {
|
||||
controller: ForkSyncController
|
||||
group: ForkMappingGroup
|
||||
entry: ForkMappingEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* One mapping entry: the source ↔ target picker row (with a "Copy instead" entry for copy
|
||||
* candidates and per-source taken-target disabling on push), then one collapsible card per
|
||||
* workflow the resource is used in, holding that workflow's dependent field selectors.
|
||||
* Workflows with nothing to configure are named in a muted note so the usage stays visible.
|
||||
*/
|
||||
function MappingEntry({ controller, group, entry }: MappingEntryProps) {
|
||||
const target = controller.targetFor(entry)
|
||||
const takenOwners = controller.takenOwnersFor(entry, group.items)
|
||||
const parentChanged = controller.parentChangedFor(entry)
|
||||
const entryRefKey = forkRefKey(entry)
|
||||
const copying = controller.copyingKeys.has(entryRefKey)
|
||||
|
||||
const usages = controller.usagesForEntry(entry)
|
||||
const dependents = controller.dependentsForEntry(entry)
|
||||
// Group once per (usages, dependents) change - both keep stable references from the
|
||||
// controller's memoized maps, so this skips recompute across the page's frequent re-renders.
|
||||
const workflows = useMemo(
|
||||
() => groupDependentsByWorkflow(usages, dependents),
|
||||
[usages, dependents]
|
||||
)
|
||||
const configurable = workflows.filter((workflow) => workflow.blocks.length > 0)
|
||||
const usedOnly = workflows.filter((workflow) => workflow.blocks.length === 0)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<Label className='min-w-0 truncate'>{entry.sourceLabel}</Label>
|
||||
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
|
||||
<ChipCombobox
|
||||
className='w-full'
|
||||
align='start'
|
||||
options={[
|
||||
// While copy-resolved, the closed control shows the copy by NAME (the copy
|
||||
// keeps the source's name) via a hidden display-only option; the list itself
|
||||
// stays unambiguous.
|
||||
...(controller.copyableKeys.has(entryRefKey) && copying
|
||||
? [{ label: entry.sourceLabel, value: NEW_COPY_VALUE, hidden: true }]
|
||||
: []),
|
||||
// The way back to the copy flow after mapping - clears the target via onSelect.
|
||||
...(controller.copyableKeys.has(entryRefKey) && target !== ''
|
||||
? [
|
||||
{
|
||||
label: 'New copy',
|
||||
value: NEW_COPY_VALUE,
|
||||
onSelect: () => controller.setTarget(entry, ''),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...entry.candidates.map((candidate) => {
|
||||
const owner = takenOwners.get(candidate.id)
|
||||
return {
|
||||
label: owner ? `${candidate.label} · mapped to ${owner}` : candidate.label,
|
||||
value: candidate.id,
|
||||
disabled: owner !== undefined,
|
||||
}
|
||||
}),
|
||||
]}
|
||||
value={copying ? NEW_COPY_VALUE : target || undefined}
|
||||
onChange={(value) => controller.setTarget(entry, value)}
|
||||
placeholder='Select target'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{entry.candidatesTruncated ? (
|
||||
<p className='text-[var(--text-muted)] text-small'>
|
||||
More options than shown — search by name.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{configurable.map((workflow) => (
|
||||
<DependentWorkflowCard
|
||||
key={workflow.workflowId}
|
||||
workflow={workflow}
|
||||
target={target}
|
||||
parentChanged={parentChanged}
|
||||
copying={copying}
|
||||
workspaceId={controller.targetWorkspaceId}
|
||||
sourceWorkspaceId={controller.sourceWorkspaceId}
|
||||
reconfig={controller.reconfig}
|
||||
setReconfig={controller.setReconfig}
|
||||
/>
|
||||
))}
|
||||
{usedOnly.length > 0 ? (
|
||||
<p className='text-[var(--text-tertiary)] text-caption'>
|
||||
Also used in {usedOnly.map((workflow) => workflow.workflowName).join(', ')} — nothing to
|
||||
configure there.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Badge copy + color for one kind's mapping status (shared badge rules with the old summary). */
|
||||
function kindStatusBadge(summary: ForkKindSummary): {
|
||||
label: string
|
||||
variant: 'green' | 'amber' | 'gray-secondary'
|
||||
} {
|
||||
const { total, mapped, copied, requiredPending, reconfigPending } = summary
|
||||
const resolved = mapped + copied
|
||||
const complete = resolved === total && !reconfigPending
|
||||
const label = complete
|
||||
? mapped === total
|
||||
? 'Fully mapped'
|
||||
: copied === total
|
||||
? 'Copied'
|
||||
: 'Mapped & copied'
|
||||
: reconfigPending && resolved === total
|
||||
? 'Needs setup'
|
||||
: copied > 0
|
||||
? `${resolved}/${total} ready`
|
||||
: `${mapped}/${total} mapped`
|
||||
const variant = complete
|
||||
? 'green'
|
||||
: requiredPending || reconfigPending
|
||||
? 'amber'
|
||||
: 'gray-secondary'
|
||||
return { label, variant }
|
||||
}
|
||||
|
||||
interface MappingKindRowProps {
|
||||
controller: ForkSyncController
|
||||
group: ForkMappingGroup
|
||||
summary: ForkKindSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* One resource kind in the Mappings section: a chevron header row with the kind's status badge
|
||||
* (the summary IS the entry), expanding to that kind's mapping entries. Mirrors the expandable
|
||||
* kind rows of the Copy resources section so the two sections share one interaction rhythm.
|
||||
*/
|
||||
function MappingKindRow({ controller, group, summary }: MappingKindRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const badge = kindStatusBadge(summary)
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className='flex w-full items-center gap-2 text-left text-[var(--text-body)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>{group.label}</span>
|
||||
<Badge variant={badge.variant} size='sm' dot>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className='flex flex-col pt-3 pb-1'>
|
||||
{group.items.map((entry, index) => (
|
||||
<Fragment key={forkRefKey(entry)}>
|
||||
{index > 0 ? <FieldDivider /> : null}
|
||||
<MappingEntry controller={controller} group={group} entry={entry} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CopyKindSectionsProps {
|
||||
controller: ForkSyncController
|
||||
byKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* One expandable row per copyable kind present in `byKind` - shared by the referenced group
|
||||
* and the unreferenced "Not used by any workflow" group so both render exactly like the fork
|
||||
* picker (files as a folder tree, every other kind flat).
|
||||
*/
|
||||
function CopyKindSections({ controller, byKind }: CopyKindSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
{COPYABLE_KIND_SECTIONS.map((section) => {
|
||||
const candidates = byKind.get(section.kind)
|
||||
if (!candidates || candidates.length === 0) return null
|
||||
// The picker rows track item ids; copy selection is keyed `${kind}:${id}`
|
||||
// (matching `forkRefKey`), so derive the per-kind selected-id subset and
|
||||
// re-prefix on toggle.
|
||||
const selectedIds = new Set(
|
||||
candidates
|
||||
.filter((candidate) => controller.copySelected.has(forkRefKey(candidate)))
|
||||
.map((candidate) => candidate.sourceId)
|
||||
)
|
||||
const toggleMany = (ids: string[], checked: boolean) =>
|
||||
controller.toggleCopyKeys(
|
||||
ids.map((id) => `${section.kind}:${id}`),
|
||||
checked
|
||||
)
|
||||
const toggleAll = (selectAll: boolean) =>
|
||||
toggleMany(
|
||||
candidates.map((candidate) => candidate.sourceId),
|
||||
selectAll
|
||||
)
|
||||
return section.kind === 'file' ? (
|
||||
<FileKindRow
|
||||
key={section.kind}
|
||||
label={section.label}
|
||||
files={candidates.map((candidate) => ({
|
||||
id: candidate.sourceId,
|
||||
label: candidate.label,
|
||||
folderId: candidate.parentId,
|
||||
folderName: candidate.parentLabel,
|
||||
}))}
|
||||
selected={selectedIds}
|
||||
onToggleAll={toggleAll}
|
||||
onToggleItem={(id, checked) => toggleMany([id], checked)}
|
||||
onToggleMany={toggleMany}
|
||||
disabled={controller.submitting}
|
||||
/>
|
||||
) : (
|
||||
<ResourceKindRow
|
||||
key={section.kind}
|
||||
label={section.label}
|
||||
items={candidates.map((candidate) => ({
|
||||
id: candidate.sourceId,
|
||||
label: candidate.label,
|
||||
}))}
|
||||
selected={selectedIds}
|
||||
onToggleMany={toggleMany}
|
||||
onToggleItem={(id, checked) => toggleMany([id], checked)}
|
||||
disabled={controller.submitting}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface ForkSyncViewProps {
|
||||
controller: ForkSyncController
|
||||
onDirectionChange: (direction: ForkDirection) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent fork edge's sync experience as page sections: pick a direction, review the
|
||||
* deployed-workflow changes, resolve the per-kind mappings (each kind an expandable row whose
|
||||
* status badge doubles as the summary), choose which unmapped resources to copy, and clear any
|
||||
* blocking references. The page header's Sync action commits it (after the overwrite confirm).
|
||||
*/
|
||||
export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProps) {
|
||||
const detailsError = controller.errorMessage ?? controller.diffErrorMessage
|
||||
const headsUp = controller.mcpReauthCount > 0 || controller.inlineSecretCount > 0
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-7'>
|
||||
<SettingsSection label='Sync direction'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<ChipSwitch
|
||||
value={controller.direction}
|
||||
onChange={onDirectionChange}
|
||||
aria-label='Sync direction'
|
||||
options={[
|
||||
{ value: 'push', label: 'Push' },
|
||||
{ value: 'pull', label: 'Pull' },
|
||||
]}
|
||||
/>
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
{controller.direction === 'push'
|
||||
? `Push this workspace's deployed workflows to "${controller.otherWorkspaceName}", overwriting it.`
|
||||
: `Pull deployed workflows from "${controller.otherWorkspaceName}", overwriting this workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Surface a failed/pending fetch so the page never renders blank below the direction. */}
|
||||
{detailsError ? (
|
||||
<SettingsSection label='Sync details'>
|
||||
<div className='text-[var(--text-error)] text-small'>{detailsError}</div>
|
||||
</SettingsSection>
|
||||
) : !controller.hasDiff ? (
|
||||
<div className='text-[var(--text-muted)] text-small'>Loading sync details…</div>
|
||||
) : null}
|
||||
|
||||
{/* Always shown once the diff loads so the user sees the section even with nothing
|
||||
deployed - an empty change list means the source has no deployed workflows (every
|
||||
deployed workflow appears here, changed or not), so the muted state nudges a deploy. */}
|
||||
{controller.hasDiff ? (
|
||||
<SettingsSection label='Deployed workflows'>
|
||||
{controller.workflowChanges.length > 0 ? (
|
||||
<div className='flex flex-col gap-1'>
|
||||
{controller.workflowChanges.map((change, index) => {
|
||||
const renamed = change.currentName !== change.otherName
|
||||
return (
|
||||
<div
|
||||
key={`${change.action}:${change.currentName}:${index}`}
|
||||
className='flex min-w-0 items-center gap-1.5'
|
||||
>
|
||||
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
|
||||
{change.currentName}
|
||||
</span>
|
||||
{renamed ? (
|
||||
<>
|
||||
<ArrowRight className='size-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm'>
|
||||
{change.otherName}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-[var(--text-muted)] text-small'>
|
||||
{controller.direction === 'push'
|
||||
? `No deployed workflows. Deploy workflows to push changes to ${controller.otherWorkspaceName}.`
|
||||
: `No deployed workflows in ${controller.otherWorkspaceName} to pull.`}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{headsUp ? (
|
||||
<SettingsSection label='Heads up'>
|
||||
{controller.mcpReauthCount > 0 ? (
|
||||
<div className='text-[var(--text-muted)] text-small'>
|
||||
{controller.mcpReauthCount} MCP server(s) use OAuth and must be re-authorized in the
|
||||
target workspace.
|
||||
</div>
|
||||
) : null}
|
||||
{controller.inlineSecretCount > 0 ? (
|
||||
<div className='mt-1 text-[var(--text-muted)] text-small'>
|
||||
{controller.inlineSecretCount} inline secret(s) can't be auto-mapped — set them in the
|
||||
target workspace.
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{controller.hasMapping ? (
|
||||
<SettingsSection label='Mappings'>
|
||||
{controller.groups.length > 0 ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{controller.groups.map((group) => {
|
||||
const summary = controller.kindSummaries.find((item) => item.kind === group.kind)
|
||||
if (!summary) return null
|
||||
return (
|
||||
<MappingKindRow
|
||||
key={group.kind}
|
||||
controller={controller}
|
||||
group={group}
|
||||
summary={summary}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
This workspace's deployed workflows have no mappable references.
|
||||
</SettingsEmptyState>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{controller.hasVisibleCopyables ? (
|
||||
<SettingsSection label='Copy resources'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{controller.referencedByKind.size > 0 ? (
|
||||
<CopyKindSections controller={controller} byKind={controller.referencedByKind} />
|
||||
) : null}
|
||||
{controller.unreferencedByKind.size > 0 ? (
|
||||
<>
|
||||
{controller.referencedByKind.size > 0 ? (
|
||||
<div className='mt-2 text-[var(--text-muted)] text-caption'>
|
||||
Not used by any workflow
|
||||
</div>
|
||||
) : null}
|
||||
<CopyKindSections controller={controller} byKind={controller.unreferencedByKind} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{controller.blockingRefs.length > 0 ? (
|
||||
<SettingsSection label='Blocking sync'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{controller.blockingRefs.map((ref, index) => (
|
||||
<div
|
||||
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
|
||||
className='min-w-0 text-[var(--text-secondary)] text-small'
|
||||
>
|
||||
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> would lose{' '}
|
||||
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
|
||||
{ref.workflowName} — {forkBlockerResolution(ref)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{controller.dependentClears.length > 0 ? (
|
||||
<SettingsSection label='Will be cleared'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{controller.dependentClears.map((ref, index) => (
|
||||
<div
|
||||
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
|
||||
className='min-w-0 text-[var(--text-secondary)] text-small'
|
||||
>
|
||||
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> will lose{' '}
|
||||
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
|
||||
{ref.workflowName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
Re-pick these in the target after the sync.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
'use client'
|
||||
|
||||
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type {
|
||||
ForkClearedRef,
|
||||
ForkCopyableUnmapped,
|
||||
ForkDependentReconfig,
|
||||
ForkMappingEntry,
|
||||
ForkResourceUsage,
|
||||
ForkWorkflowChange,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
selectVisibleClearedRefs,
|
||||
splitForkClearedRefs,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
|
||||
import {
|
||||
effectiveForkTarget,
|
||||
type ForkParentResolution,
|
||||
forkCopyingKeys,
|
||||
forkDefaultCopySelection,
|
||||
forkMappedCopyableKeys,
|
||||
forkParentResolution,
|
||||
forkRefKey,
|
||||
forkRequiredKindsLabel,
|
||||
forkRequiredPending,
|
||||
forkVisibleCopyables,
|
||||
isForkRequiredComplete,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
|
||||
import {
|
||||
dependentKey,
|
||||
effectiveCopyDependentValue,
|
||||
effectiveDependentValue,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
|
||||
import {
|
||||
type ForkDirection,
|
||||
useForkDiff,
|
||||
useForkMapping,
|
||||
usePromoteFork,
|
||||
useUpdateForkMapping,
|
||||
} from '@/ee/workspace-forking/hooks/workspace-fork'
|
||||
|
||||
/**
|
||||
* The mapping kinds that can be a standalone mapping entry. `knowledge-document` is excluded:
|
||||
* the mapping view (`getForkMappingView`) skips documents — they ride their parent KB via the
|
||||
* reconfigure flow — so a `knowledge-document` mapping section is never reachable.
|
||||
*/
|
||||
export type MappableMappingKind = Exclude<ForkMappingEntry['kind'], 'knowledge-document'>
|
||||
|
||||
/** Section label + display order per mapping kind (one mapping group per kind). */
|
||||
const MAPPING_SECTION: Record<MappableMappingKind, { label: string; order: number }> = {
|
||||
credential: { label: 'Credentials', order: 0 },
|
||||
'env-var': { label: 'Secrets', order: 1 },
|
||||
table: { label: 'Tables', order: 2 },
|
||||
'knowledge-base': { label: 'Knowledge bases', order: 3 },
|
||||
file: { label: 'Files', order: 4 },
|
||||
'mcp-server': { label: 'MCP servers', order: 5 },
|
||||
'custom-tool': { label: 'Custom tools', order: 6 },
|
||||
skill: { label: 'Skills', order: 7 },
|
||||
}
|
||||
|
||||
/** Shared empty owners map for the pull direction so the options mapper never re-allocates. */
|
||||
const EMPTY_TARGET_OWNERS: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
/**
|
||||
* Stable empty arrays so an entry with no usages/dependents keeps a constant prop reference,
|
||||
* letting the workflow-card grouping memos skip recompute across the page's frequent re-renders.
|
||||
*/
|
||||
const EMPTY_USAGES: ForkResourceUsage['workflows'] = []
|
||||
const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
|
||||
|
||||
/** Target workflows this sync archives, previewed in the confirm before "and X more". */
|
||||
export const ARCHIVED_PREVIEW_LIMIT = 5
|
||||
|
||||
export interface ForkMappingGroup {
|
||||
kind: MappableMappingKind
|
||||
label: string
|
||||
items: ForkMappingEntry[]
|
||||
}
|
||||
|
||||
/** Per-kind mapping status for the Mappings section's summary badges. */
|
||||
export interface ForkKindSummary {
|
||||
kind: MappableMappingKind
|
||||
total: number
|
||||
mapped: number
|
||||
copied: number
|
||||
requiredPending: boolean
|
||||
reconfigPending: boolean
|
||||
}
|
||||
|
||||
export interface ForkSyncController {
|
||||
direction: ForkDirection
|
||||
otherWorkspaceName: string
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
errorMessage: string | null
|
||||
/** Diff fetch failure, surfaced inline (the mapping may still have loaded). */
|
||||
diffErrorMessage: string | null
|
||||
/** True once the diff payload for ANY direction is present (placeholder included). */
|
||||
hasDiff: boolean
|
||||
/** True once the mapping payload is present (placeholder included), gating the Mappings section. */
|
||||
hasMapping: boolean
|
||||
groups: ForkMappingGroup[]
|
||||
/** Per-kind mapping status, aligned with `groups` (same kinds, same order). */
|
||||
kindSummaries: ForkKindSummary[]
|
||||
/** Effective (in-session override, else persisted/suggested) target for an entry. */
|
||||
targetFor: (entry: ForkMappingEntry) => string
|
||||
/** Set an entry's target ('' clears it) and drop its dependents' stale re-picks. */
|
||||
setTarget: (entry: ForkMappingEntry, value: string) => void
|
||||
/** Targets already claimed by another source in the same kind (push targets are unique; pull never disables). */
|
||||
takenOwnersFor: (
|
||||
entry: ForkMappingEntry,
|
||||
items: ForkMappingEntry[]
|
||||
) => ReadonlyMap<string, string>
|
||||
/** Every workflow an entry's resource is used in (diff-fed; empty until the diff loads). */
|
||||
usagesForEntry: (entry: ForkMappingEntry) => ForkResourceUsage['workflows']
|
||||
/** An entry's dependent fields (its credential/KB/table's selectors), from the diff. */
|
||||
dependentsForEntry: (entry: ForkMappingEntry) => ForkDependentReconfig[]
|
||||
/**
|
||||
* Whether an entry needs an in-place reconfigure: its effective target changed in-session,
|
||||
* or it's an unconfirmed suggestion (accepting it as-is still remaps + clears the dependents).
|
||||
*/
|
||||
parentChangedFor: (entry: ForkMappingEntry) => boolean
|
||||
/** The workspace a MAPPED parent's dependent selectors query against (direction-aware from the diff). */
|
||||
targetWorkspaceId: string
|
||||
/**
|
||||
* The sync's source workspace (push: this one; pull: the other), which a COPY-resolved
|
||||
* parent's dependent selectors browse - the copy will contain the source parent's children,
|
||||
* so the source is the truthful catalog to pick from.
|
||||
*/
|
||||
sourceWorkspaceId: string
|
||||
/** In-session dependent re-picks, keyed by `dependentKey`. */
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
/** Keys the backend offers as copy candidates, for the entry rows' "Copy instead" affordance. */
|
||||
copyableKeys: ReadonlySet<string>
|
||||
/** Copyables actually selected for copy (visible + checked), keyed `${kind}:${sourceId}`. */
|
||||
copyingKeys: ReadonlySet<string>
|
||||
/** The raw copy selection (visible-ness not applied), for per-kind selected-id derivation. */
|
||||
copySelected: ReadonlySet<string>
|
||||
toggleCopyKeys: (keys: string[], checked: boolean) => void
|
||||
/** Visible copy candidates split by referenced-ness, grouped per kind for the section rows. */
|
||||
referencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
|
||||
unreferencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
|
||||
hasVisibleCopyables: boolean
|
||||
/** Would-clear references that BLOCK sync (mirrors the server's zero-cleared-refs gate). */
|
||||
blockingRefs: ForkClearedRef[]
|
||||
/** Informational would-clear dependents (owned by the reconfigure flow; never block). */
|
||||
dependentClears: ForkClearedRef[]
|
||||
/** Deployed-workflow change list (update → create → archive, then by name). */
|
||||
workflowChanges: ForkWorkflowChange[]
|
||||
/** Names of target workflows this sync archives, for the confirm modal. */
|
||||
archivedWorkflowNames: string[]
|
||||
mcpReauthCount: number
|
||||
inlineSecretCount: number
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
save: () => void
|
||||
discard: () => void
|
||||
submitting: boolean
|
||||
syncDisabled: boolean
|
||||
/** Names the failing gate for the disabled Sync chip's tooltip; undefined when enabled. */
|
||||
syncDisabledReason: string | undefined
|
||||
/** Persist the effective mapping + dependents + copy selection, then promote. */
|
||||
sync: () => Promise<void>
|
||||
}
|
||||
|
||||
const entryKey = (entry: ForkMappingEntry) => forkRefKey(entry)
|
||||
|
||||
/**
|
||||
* Whether a mapping entry needs an in-place reconfigure: its effective target was changed
|
||||
* in-session, or it's an unconfirmed suggestion (accepting it as-is still remaps + clears
|
||||
* the dependents). Pure over (entry, in-session targets) so the inline render, the Sync
|
||||
* gate, and the payload build share one predicate instead of drifting copies.
|
||||
*/
|
||||
function shouldReconfigureEntry(entry: ForkMappingEntry, targets: Record<string, string>): boolean {
|
||||
const next = targets[entryKey(entry)] ?? entry.targetId ?? ''
|
||||
if (next === '') return false
|
||||
return entry.suggested || next !== (entry.targetId ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Targets already taken by OTHER sources in the same kind, each mapped to the owning
|
||||
* source's label (for a hint). Used to disable those targets on PUSH: a push row is unique
|
||||
* on the parent (target) side, so a parent target can back only one source - a second source
|
||||
* picking it would be silently dropped on save. Pull is the inverse (many parent sources may
|
||||
* share one fork target, which resolves correctly), so pull passes the empty map and never
|
||||
* disables. Excludes `exclude` so a source never disables its own current selection.
|
||||
*/
|
||||
function takenTargetOwners(
|
||||
items: ForkMappingEntry[],
|
||||
targets: Record<string, string>,
|
||||
exclude: ForkMappingEntry
|
||||
): Map<string, string> {
|
||||
const owners = new Map<string, string>()
|
||||
for (const item of items) {
|
||||
if (entryKey(item) === entryKey(exclude)) continue
|
||||
const target = targets[entryKey(item)] ?? item.targetId ?? ''
|
||||
if (target !== '') owners.set(target, item.sourceLabel)
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
/**
|
||||
* The full sync surface's state for one fork edge, in the chosen direction: the editable
|
||||
* resource mapping (in-session target overrides + dependent re-picks, persisted via Save or
|
||||
* as part of Sync), the copy-resources selection (seeded once the diff settles), the reactive
|
||||
* would-clear blockers, the per-kind status summaries, the Sync gate, and the promote run
|
||||
* itself. A direction switch drops every in-session choice — the mapping set, copy candidates,
|
||||
* and blockers all depend on the direction — and the copy selection re-seeds only from a
|
||||
* settled (non-placeholder) diff so a stale payload can't latch wrong keys.
|
||||
*/
|
||||
export function useForkSync(params: {
|
||||
workspaceId: string
|
||||
otherWorkspaceId?: string
|
||||
otherWorkspaceName: string
|
||||
direction: ForkDirection
|
||||
enabled: boolean
|
||||
}): ForkSyncController {
|
||||
const { workspaceId, otherWorkspaceId, otherWorkspaceName, direction, enabled } = params
|
||||
|
||||
// User's IN-SESSION mapping overrides only - NOT the source of truth. The displayed/persisted
|
||||
// target falls back to each entry's stored `targetId` (see `targetFor`), so a reopened edge
|
||||
// shows its remembered mappings even though React Query's structural sharing keeps `entries`
|
||||
// referentially stable (a target-seeding effect gated on `entries` would never re-run there).
|
||||
const [targets, setTargets] = useState<Record<string, string>>({})
|
||||
// In-session re-picks for dependent fields whose parent the user swapped, keyed by
|
||||
// `dependentKey`. Folded into the full effective set sent on save/sync, which the server
|
||||
// persists as the stored mapping - so the selection survives every future sync without
|
||||
// re-picking.
|
||||
const [reconfig, setReconfig] = useState<Record<string, string>>({})
|
||||
// Referenced-but-unmapped resources the user chose to copy into the target (keyed by
|
||||
// `${kind}:${sourceId}`); default-selected once the diff loads. Selected ones are copied on
|
||||
// sync so their references resolve to the copy instead of being cleared.
|
||||
const [copySelected, setCopySelected] = useState<Set<string>>(new Set())
|
||||
const [copyDefaulted, setCopyDefaulted] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Drop every in-session choice when the direction (or edge) changes - the mapping set,
|
||||
// copy candidates, and blockers all depend on it.
|
||||
useEffect(() => {
|
||||
setTargets({})
|
||||
setReconfig({})
|
||||
setCopySelected(new Set())
|
||||
setCopyDefaulted(false)
|
||||
}, [direction, otherWorkspaceId])
|
||||
|
||||
const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled })
|
||||
const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled })
|
||||
const updateMapping = useUpdateForkMapping()
|
||||
const promote = usePromoteFork()
|
||||
|
||||
const entries = useMemo<ForkMappingEntry[]>(() => mapping.data?.entries ?? [], [mapping.data])
|
||||
const dependentReconfigs = useMemo(
|
||||
() => diff.data?.dependentReconfigs ?? [],
|
||||
[diff.data?.dependentReconfigs]
|
||||
)
|
||||
const resourceUsages = useMemo(() => diff.data?.resourceUsages ?? [], [diff.data?.resourceUsages])
|
||||
const copyableUnmapped = useMemo(
|
||||
() => diff.data?.copyableUnmapped ?? [],
|
||||
[diff.data?.copyableUnmapped]
|
||||
)
|
||||
const clearedRefs = useMemo(() => diff.data?.clearedRefs ?? [], [diff.data?.clearedRefs])
|
||||
|
||||
// Keys the backend offers as copy candidates, so the entry rows show a "Copy instead"
|
||||
// affordance only for those - clearing a name-match suggestion returns the ref to the copy
|
||||
// list (it re-enters `visibleCopyables` once its effective target is '').
|
||||
const copyableKeys = useMemo(
|
||||
() => new Set(copyableUnmapped.map((candidate) => forkRefKey(candidate))),
|
||||
[copyableUnmapped]
|
||||
)
|
||||
|
||||
// Copy-vs-map reconciliation: a copyable resource the user has given an effective (in-session
|
||||
// or persisted) mapping target must NOT also appear in the copy list - the user picked map, not
|
||||
// copy. `forkRefKey` shares the `${kind}:${sourceId}` keyspace across entries and candidates,
|
||||
// so a mapped entry's key directly excludes its copy candidate. The server enforces the same
|
||||
// precedence: a mapped resource resolves != null, so it never reaches the plan's
|
||||
// `copyableUnmapped`, and a copy request for it is dropped by `buildPromoteCopySelection`.
|
||||
const mappedCopyableKeys = useMemo(
|
||||
() => forkMappedCopyableKeys(entries, targets),
|
||||
[entries, targets]
|
||||
)
|
||||
|
||||
const visibleCopyables = useMemo(
|
||||
() => forkVisibleCopyables(copyableUnmapped, mappedCopyableKeys),
|
||||
[copyableUnmapped, mappedCopyableKeys]
|
||||
)
|
||||
|
||||
// Copyables actually selected for copy (visible + checked), keyed for an O(1) lookup so a
|
||||
// copyable mapping entry can show a "will be copied" note.
|
||||
const copyingKeys = useMemo(
|
||||
() => forkCopyingKeys(visibleCopyables, copySelected),
|
||||
[visibleCopyables, copySelected]
|
||||
)
|
||||
|
||||
// Group the visible copy candidates by kind so each renders as its own expandable section
|
||||
// (chevron + tri-state select-all + count), matching the fork picker. Referenced and
|
||||
// unreferenced candidates group separately: unreferenced ones (used by no synced workflow)
|
||||
// render under a muted "Not used by any workflow" grouping and default to unselected.
|
||||
const { referencedByKind, unreferencedByKind } = useMemo(() => {
|
||||
const referenced = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
|
||||
const unreferenced = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
|
||||
for (const candidate of visibleCopyables) {
|
||||
const groups = candidate.referenced ? referenced : unreferenced
|
||||
const list = groups.get(candidate.kind)
|
||||
if (list) list.push(candidate)
|
||||
else groups.set(candidate.kind, [candidate])
|
||||
}
|
||||
return { referencedByKind: referenced, unreferencedByKind: unreferenced }
|
||||
}, [visibleCopyables])
|
||||
|
||||
// Default every REFERENCED copyable resource to "copy" once the diff loads, so the common case
|
||||
// (bring the referenced resources along) needs no clicks; the user can deselect to clear
|
||||
// instead. Unreferenced candidates start unselected (see `forkDefaultCopySelection`) - copying
|
||||
// them is opt-in since nothing references them. Seed ONLY from a settled diff for the current
|
||||
// direction: on a direction switch the reset clears `copyDefaulted`, but `useForkDiff` keeps
|
||||
// the previous direction's payload (placeholderData) until the new fetch resolves - seeding
|
||||
// from it would latch against stale keys and leave the real copyables unchecked, clearing
|
||||
// their references on Sync.
|
||||
useEffect(() => {
|
||||
if (!enabled || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return
|
||||
setCopyDefaulted(true)
|
||||
setCopySelected(forkDefaultCopySelection(copyableUnmapped))
|
||||
}, [enabled, diff.isPlaceholderData, copyableUnmapped, copyDefaulted])
|
||||
|
||||
// Group dependents by their parent (kind:sourceId) once, so each mapping entry gets a
|
||||
// STABLE `dependents` array reference - a fresh `.filter` per render would defeat the
|
||||
// workflow-card grouping memos.
|
||||
const dependentsByParent = useMemo(() => {
|
||||
const map = new Map<string, ForkDependentReconfig[]>()
|
||||
for (const dependent of dependentReconfigs) {
|
||||
const key = `${dependent.parentKind}:${dependent.parentSourceId}`
|
||||
const list = map.get(key)
|
||||
if (list) list.push(dependent)
|
||||
else map.set(key, [dependent])
|
||||
}
|
||||
return map
|
||||
}, [dependentReconfigs])
|
||||
|
||||
// Effective target for an entry: the user's in-session override if present, else the
|
||||
// persisted mapping from the server. Read directly from `entries` so a reopened edge
|
||||
// reflects stored mappings without a seeding effect.
|
||||
const targetFor = (entry: ForkMappingEntry) => effectiveForkTarget(entry, targets)
|
||||
|
||||
const usagesForEntry = (entry: ForkMappingEntry): ForkResourceUsage['workflows'] =>
|
||||
resourceUsages.find(
|
||||
(usage) => usage.parentKind === entry.kind && usage.parentSourceId === entry.sourceId
|
||||
)?.workflows ?? EMPTY_USAGES
|
||||
|
||||
const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] =>
|
||||
dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS
|
||||
|
||||
const parentChangedFor = (entry: ForkMappingEntry): boolean =>
|
||||
shouldReconfigureEntry(entry, targets)
|
||||
|
||||
// Set an entry's in-session mapping target. A `value` of '' explicitly clears it, overriding
|
||||
// any name-match suggestion (effectiveForkTarget's `??` treats '' as present, so the suggestion
|
||||
// no longer wins) - so the resource re-enters `visibleCopyables` and is copy-selectable again.
|
||||
// Changing the parent invalidates its dependents' in-session re-picks (chosen against the old
|
||||
// account), so drop them.
|
||||
const setTarget = (entry: ForkMappingEntry, value: string) => {
|
||||
setTargets((prev) => ({ ...prev, [entryKey(entry)]: value }))
|
||||
setReconfig((prev) => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
for (const dependent of dependentsForEntry(entry)) {
|
||||
const key = dependentKey(dependent)
|
||||
if (key in next) {
|
||||
delete next[key]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
const takenOwnersFor = (
|
||||
entry: ForkMappingEntry,
|
||||
items: ForkMappingEntry[]
|
||||
): ReadonlyMap<string, string> =>
|
||||
direction === 'push' ? takenTargetOwners(items, targets, entry) : EMPTY_TARGET_OWNERS
|
||||
|
||||
const toggleCopyKeys = (keys: string[], checked: boolean) =>
|
||||
setCopySelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const key of keys) {
|
||||
if (checked) next.add(key)
|
||||
else next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// Group mappings by resource type - one accordion row per kind, required types first.
|
||||
const groups = useMemo<ForkMappingGroup[]>(() => {
|
||||
const byKind = new Map<MappableMappingKind, ForkMappingEntry[]>()
|
||||
for (const entry of entries) {
|
||||
// The mapping view never emits a document entry (it rides its KB), so the group is
|
||||
// unreachable - skip defensively so the narrowed `MAPPING_SECTION` lookup stays sound.
|
||||
if (entry.kind === 'knowledge-document') continue
|
||||
const list = byKind.get(entry.kind)
|
||||
if (list) list.push(entry)
|
||||
else byKind.set(entry.kind, [entry])
|
||||
}
|
||||
return Array.from(byKind, ([kind, items]) => ({
|
||||
kind,
|
||||
label: MAPPING_SECTION[kind].label,
|
||||
items: items.slice().sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)),
|
||||
})).sort((a, b) => MAPPING_SECTION[a.kind].order - MAPPING_SECTION[b.kind].order)
|
||||
}, [entries])
|
||||
|
||||
// The mapping entry each dependent hangs off, indexed by `kind:sourceId` (matching `entryKey`)
|
||||
// so the per-field lookups below are O(1) instead of rescanning `entries` for every dependent -
|
||||
// and several times per field across the Sync gate, the value helper, and the payload build.
|
||||
const entriesByParent = useMemo(() => {
|
||||
const map = new Map<string, ForkMappingEntry>()
|
||||
for (const entry of entries) map.set(entryKey(entry), entry)
|
||||
return map
|
||||
}, [entries])
|
||||
|
||||
const entryForDependent = (field: ForkDependentReconfig) =>
|
||||
entriesByParent.get(`${field.parentKind}:${field.parentSourceId}`)
|
||||
|
||||
const resolutionFor = (entry: ForkMappingEntry): ForkParentResolution =>
|
||||
forkParentResolution(entry, targets, copyingKeys)
|
||||
|
||||
// The value sent + displayed for a dependent (delegates to the shared per-resolution rule):
|
||||
// the user's re-pick, else - under a MAPPED parent - the stored value (blank when the parent
|
||||
// target changed in-session), or - under a COPY-resolved parent - the stored value falling
|
||||
// back to the raw source reference (which the copied parent will contain). Callers that
|
||||
// already resolved the parent pass both in to skip repeat lookups.
|
||||
const dependentValueFor = (
|
||||
field: ForkDependentReconfig,
|
||||
parent = entryForDependent(field),
|
||||
resolution: ForkParentResolution = parent ? resolutionFor(parent) : 'unresolved'
|
||||
): string => {
|
||||
if (resolution === 'copied') return effectiveCopyDependentValue(field, reconfig)
|
||||
return effectiveDependentValue(
|
||||
field,
|
||||
reconfig,
|
||||
parent ? shouldReconfigureEntry(parent, targets) : false
|
||||
)
|
||||
}
|
||||
|
||||
// A required reference is satisfied when it has a mapping target OR the user selected it for
|
||||
// copy (the server accepts a copy as resolving a required ref). See `isForkRequiredComplete`.
|
||||
const requiredComplete = isForkRequiredComplete(entries, targets, copyingKeys)
|
||||
|
||||
// Every required dependent whose parent is RESOLVED must have a value before sync. Under a
|
||||
// mapped parent the user re-picks against the target; under a copy-resolved parent the field
|
||||
// pre-fills with the source reference (the copy carries it), so it's satisfied out of the box
|
||||
// and gates only when explicitly emptied. A dependent whose parent is unresolved can't be
|
||||
// picked yet (its selector is disabled) and is gated by `requiredComplete` on the parent
|
||||
// instead, so it's skipped here.
|
||||
const reconfigComplete = dependentReconfigs.every((field) => {
|
||||
if (!field.required) return true
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent) return true
|
||||
const resolution = resolutionFor(parent)
|
||||
if (resolution === 'unresolved') return true
|
||||
return dependentValueFor(field, parent, resolution) !== ''
|
||||
})
|
||||
|
||||
// Kinds with a required DEPENDENT that still has no value (its parent is resolved): these
|
||||
// block Sync via `reconfigComplete`, so the summary badge for that kind must not read
|
||||
// "Fully mapped".
|
||||
const reconfigPendingByKind = new Set<MappableMappingKind>()
|
||||
for (const field of dependentReconfigs) {
|
||||
if (!field.required) continue
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent) continue
|
||||
const resolution = resolutionFor(parent)
|
||||
if (resolution === 'unresolved') continue
|
||||
if (dependentValueFor(field, parent, resolution) === '') {
|
||||
reconfigPendingByKind.add(parent.kind as MappableMappingKind)
|
||||
}
|
||||
}
|
||||
|
||||
// The references this sync would blank, reactively narrowed to the current selection. A
|
||||
// resource is "resolved" once it has a mapping target OR is selected for copy - the same
|
||||
// predicate drives a `reference` (its own resource) and a `dependent` (its PARENT resource),
|
||||
// so mapping or copying a parent KB makes its child document drop off. Then split:
|
||||
// `reference`/`workflow` entries are BLOCKERS (Sync stays disabled while any remain -
|
||||
// mirroring the server's zero-cleared-refs gate); `dependent` entries stay informational
|
||||
// (the reconfigure flow owns them).
|
||||
const { blockers: blockingRefs, informational: dependentClears } = useMemo(() => {
|
||||
const isResolved = (kind: string, sourceId: string) => {
|
||||
const key = `${kind}:${sourceId}`
|
||||
const entry = entriesByParent.get(key)
|
||||
const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false
|
||||
return mapped || copyingKeys.has(key)
|
||||
}
|
||||
return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved))
|
||||
}, [clearedRefs, entriesByParent, targets, copyingKeys])
|
||||
|
||||
// Per-kind status for the Mappings summary: "Fully mapped" or "n/total mapped", flagged when
|
||||
// a REQUIRED target is still missing (which blocks Sync). Reads the effective
|
||||
// (override-or-persisted) target so it reflects both remembered mappings and in-session edits.
|
||||
const kindSummaries: ForkKindSummary[] = groups.map((group) => {
|
||||
const total = group.items.length
|
||||
const mapped = group.items.filter((entry) => targetFor(entry) !== '').length
|
||||
// Copy-selected items are resolved too (their refs are kept), so they count toward
|
||||
// completion and render as "copied" rather than unconfigured. mapped/copied are disjoint:
|
||||
// a mapped copyable is excluded from the copy candidates, so copyingKeys never overlaps a
|
||||
// mapped entry.
|
||||
const copied = group.items.filter((entry) => copyingKeys.has(entryKey(entry))).length
|
||||
// Mirror the Sync gate: a required ref selected for copy is satisfied, so it is not
|
||||
// "pending".
|
||||
const requiredPending = forkRequiredPending(group.items, targets, copyingKeys)
|
||||
const reconfigPending = reconfigPendingByKind.has(group.kind)
|
||||
return { kind: group.kind, total, mapped, copied, requiredPending, reconfigPending }
|
||||
})
|
||||
|
||||
// Kinds whose required gate is still failing, so the Sync tooltip can name the actual
|
||||
// obstacle. An unmapped credential/secret is NEVER a cleared-ref blocker (the collector
|
||||
// excludes required kinds), so the required gate must not borrow the blocker message -
|
||||
// it would point at a "Blocking sync" section that isn't rendered.
|
||||
const pendingRequiredKinds = new Set<string>(
|
||||
kindSummaries.filter((summary) => summary.requiredPending).map((summary) => summary.kind)
|
||||
)
|
||||
|
||||
// Sync details still settling for the current direction: loading, a failed/empty mapping
|
||||
// (`!mapping.data` must not read as "nothing required"), or the PREVIOUS direction's
|
||||
// placeholder after a switch (syncing on it would send stale mappings/copies and clear
|
||||
// references). Until `diff.data` arrives `dependentReconfigs` is empty, so `reconfigComplete`
|
||||
// is vacuously true.
|
||||
const dataPending =
|
||||
mapping.isLoading ||
|
||||
!mapping.data ||
|
||||
mapping.isPlaceholderData ||
|
||||
!diff.data ||
|
||||
diff.isPlaceholderData
|
||||
// A failed fetch also gates Sync: a failed REFETCH keeps the last successful payload in
|
||||
// `data` (so `dataPending` stays false), and every gate below would be judging that stale
|
||||
// snapshot while the page shows the load error.
|
||||
const dataError = mapping.isError || diff.isError
|
||||
// Zero-blockers invariant (mirrors the server gate): Sync stays disabled while ANY reference
|
||||
// would clear in a synced target workflow. `requiredComplete` covers the mapping entries
|
||||
// (credentials/secrets and unresolved resource refs); `blockingRefs` additionally covers
|
||||
// workflow-to-workflow references, which have no mapping entry to resolve.
|
||||
const syncBlocked = blockingRefs.length > 0
|
||||
const syncDisabled =
|
||||
submitting ||
|
||||
!otherWorkspaceId ||
|
||||
!requiredComplete ||
|
||||
!reconfigComplete ||
|
||||
syncBlocked ||
|
||||
dataPending ||
|
||||
dataError
|
||||
|
||||
// A load failure outranks the gates - they're computed from the stale (or absent) payload,
|
||||
// so naming one would mislead. Then priority mirrors the resolution flow: clear the
|
||||
// blockers, map the required resources, reconfigure their dependents - each failing gate
|
||||
// names ITS obstacle (an unmapped credential/secret is a required-mapping failure, not a
|
||||
// cleared-ref blocker; see `pendingRequiredKinds`).
|
||||
const syncDisabledReason = dataError
|
||||
? "Couldn't load sync details — reload the page to retry"
|
||||
: syncBlocked
|
||||
? 'Resolve every blocking reference first — map it, copy it, or fix it in the source'
|
||||
: !requiredComplete
|
||||
? `Map all required ${forkRequiredKindsLabel(pendingRequiredKinds)} first`
|
||||
: !reconfigComplete
|
||||
? 'Reconfigure all required fields first'
|
||||
: dataPending
|
||||
? 'Loading sync details…'
|
||||
: undefined
|
||||
|
||||
const workflowChanges = useMemo<ForkWorkflowChange[]>(() => {
|
||||
const order: Record<ForkWorkflowChange['action'], number> = { update: 0, create: 1, archive: 2 }
|
||||
return [...(diff.data?.workflows ?? [])].sort(
|
||||
(a, b) => order[a.action] - order[b.action] || a.currentName.localeCompare(b.currentName)
|
||||
)
|
||||
}, [diff.data?.workflows])
|
||||
|
||||
// Target workflows this sync archives (their source was deleted), named in the confirm modal
|
||||
// so the overwrite warning is concrete.
|
||||
const archivedWorkflowNames = useMemo(
|
||||
() =>
|
||||
workflowChanges
|
||||
.filter((change) => change.action === 'archive')
|
||||
.map((change) => change.currentName),
|
||||
[workflowChanges]
|
||||
)
|
||||
|
||||
// Send the full stored mapping for every dependent whose parent is RESOLVED - mapped (its
|
||||
// effective value: re-pick, stored, or blank-after-change) or copy-selected (re-pick, stored,
|
||||
// or the source reference; promote translates a source document id to its copied counterpart
|
||||
// at write time). The server persists this verbatim as the stored mapping; fields whose
|
||||
// parent is unresolved are omitted (they can't be configured). This is the whole "what's in
|
||||
// the mapping goes in" contract, shared by Save and Sync so the two persist identically.
|
||||
const buildDependentValues = () =>
|
||||
dependentReconfigs.flatMap((field) => {
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent) return []
|
||||
const resolution = resolutionFor(parent)
|
||||
if (resolution === 'unresolved') return []
|
||||
return [
|
||||
{
|
||||
workflowId: field.targetWorkflowId,
|
||||
blockId: field.targetBlockId,
|
||||
subBlockKey: field.subBlockKey,
|
||||
value: dependentValueFor(field, parent, resolution),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const buildMappingEntries = () =>
|
||||
entries.map((entry) => ({
|
||||
resourceType: entry.resourceType,
|
||||
sourceId: entry.sourceId,
|
||||
targetId: targetFor(entry) || null,
|
||||
}))
|
||||
|
||||
// Dirty only on a real change from the stored/suggested target - so a freshly loaded
|
||||
// mapping (even with name-match suggestions shown) isn't dirty until the user edits.
|
||||
const targetsDirty = useMemo(
|
||||
() =>
|
||||
entries.some((entry) => {
|
||||
const key = entryKey(entry)
|
||||
return key in targets && targets[key] !== (entry.targetId ?? '')
|
||||
}),
|
||||
[entries, targets]
|
||||
)
|
||||
|
||||
// A dependent re-pick that differs from its stored value also dirties the editor. A re-pick
|
||||
// under a changed parent is covered by `targetsDirty` (the parent override is the change).
|
||||
const reconfigDirty = useMemo(
|
||||
() =>
|
||||
dependentReconfigs.some((field) => {
|
||||
const repicked = reconfig[dependentKey(field)]
|
||||
return repicked !== undefined && repicked !== field.currentValue
|
||||
}),
|
||||
[dependentReconfigs, reconfig]
|
||||
)
|
||||
|
||||
const dirty = targetsDirty || reconfigDirty
|
||||
|
||||
const save = () => {
|
||||
if (!otherWorkspaceId || !dirty || updateMapping.isPending) return
|
||||
updateMapping.mutate(
|
||||
{
|
||||
workspaceId,
|
||||
body: {
|
||||
otherWorkspaceId,
|
||||
direction,
|
||||
// Persist the full effective set (WYSIWYG). Only include dependentValues once the
|
||||
// diff has loaded; omitted before that so an early save can't wipe the store from
|
||||
// an unknown set.
|
||||
entries: buildMappingEntries(),
|
||||
...(diff.data ? { dependentValues: buildDependentValues() } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTargets({})
|
||||
setReconfig({})
|
||||
toast.success('Mapping saved')
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error, 'Failed to save mapping')),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const discard = () => {
|
||||
setTargets({})
|
||||
setReconfig({})
|
||||
}
|
||||
|
||||
const sync = async () => {
|
||||
if (!otherWorkspaceId) return
|
||||
setSubmitting(true)
|
||||
// Capture every payload from the state at confirm time, before any await - the page's
|
||||
// controls stay mounted during the run (unlike the old modal, which blocked its UI), so a
|
||||
// mid-flight edit must not leak into the promote body.
|
||||
const mappingEntries = buildMappingEntries()
|
||||
const dependentValues = diff.data ? buildDependentValues() : null
|
||||
// Copy the referenced-but-unmapped resources the user kept selected, excluding any the
|
||||
// user mapped in-session (reconciliation: maps win). The backend validates each id
|
||||
// against the plan's copy candidates too, so a mapped/stale id is dropped server-side
|
||||
// regardless.
|
||||
const selectedCopyables = visibleCopyables.filter((candidate) =>
|
||||
copySelected.has(forkRefKey(candidate))
|
||||
)
|
||||
try {
|
||||
await updateMapping.mutateAsync({
|
||||
workspaceId,
|
||||
body: { otherWorkspaceId, direction, entries: mappingEntries },
|
||||
})
|
||||
const copyResources = {
|
||||
knowledgeBases: selectedCopyables
|
||||
.filter((c) => c.kind === 'knowledge-base')
|
||||
.map((c) => c.sourceId),
|
||||
tables: selectedCopyables.filter((c) => c.kind === 'table').map((c) => c.sourceId),
|
||||
customTools: selectedCopyables
|
||||
.filter((c) => c.kind === 'custom-tool')
|
||||
.map((c) => c.sourceId),
|
||||
skills: selectedCopyables.filter((c) => c.kind === 'skill').map((c) => c.sourceId),
|
||||
// Files are identified by storage key (the copyable candidate's sourceId is the key).
|
||||
files: selectedCopyables.filter((c) => c.kind === 'file').map((c) => c.sourceId),
|
||||
mcpServers: selectedCopyables.filter((c) => c.kind === 'mcp-server').map((c) => c.sourceId),
|
||||
}
|
||||
|
||||
const result = await promote.mutateAsync({
|
||||
workspaceId,
|
||||
body: {
|
||||
otherWorkspaceId,
|
||||
direction,
|
||||
// Once the diff has loaded, ALWAYS send the full effective set - including `[]`,
|
||||
// which means "every dependent went away" and must reconcile/clear the live replace
|
||||
// targets' stored rows. Collapsing `[]` into omission would make the backend
|
||||
// PRESERVE stale rows. Only omit before the diff loads (set unknown), so the
|
||||
// existing store is left untouched.
|
||||
...(dependentValues !== null ? { dependentValues } : {}),
|
||||
...(selectedCopyables.length > 0 ? { copyResources } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.promoteRunId) {
|
||||
if (result.blockers.length > 0) {
|
||||
// The server's authoritative gate re-found would-clear references (something changed
|
||||
// between the preview and Sync). The mutation's settled invalidation refetches the
|
||||
// diff, so the refreshed blocker list is already on its way in.
|
||||
const count = result.blockers.length
|
||||
toast.error(
|
||||
`Sync blocked: ${count} reference${count === 1 ? '' : 's'} would break in the target. Review the updated list and try again.`
|
||||
)
|
||||
return
|
||||
}
|
||||
if (result.unmappedRequired.length > 0) {
|
||||
// Name the actual blocking kinds rather than always blaming credentials: the server
|
||||
// blocks on required REFERENCES (credentials and/or secrets); required dependents are
|
||||
// gated client-side before this runs (see the Sync chip's disabled tooltip).
|
||||
const kinds = new Set(result.unmappedRequired.map((reference) => reference.kind))
|
||||
toast.error(`Map all required ${forkRequiredKindsLabel(kinds)} first`)
|
||||
return
|
||||
}
|
||||
toast.error('Sync did not complete')
|
||||
return
|
||||
}
|
||||
|
||||
const target = otherWorkspaceName || 'the workspace'
|
||||
const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`
|
||||
// A sync only commits once every reference is mapped/copied and every required dependent
|
||||
// has a value (the zero-cleared-refs invariant + `reconfigComplete`), so the old
|
||||
// "re-check X block - something may have been cleared" warnings only fired on
|
||||
// preview-vs-commit races and read as false alarms. Those rare cases still land in the
|
||||
// Activity entry (needsConfiguration/clearedOptional are recorded there) and a
|
||||
// needs-config workflow visibly stays undeployed. Deploy FAILURES remain a real,
|
||||
// actionable outcome, so they keep a warning.
|
||||
if (result.deployFailed > 0) {
|
||||
const n = result.deployFailed
|
||||
toast.warning(
|
||||
`${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.`
|
||||
)
|
||||
} else {
|
||||
toast.success(label)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Sync failed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
direction,
|
||||
otherWorkspaceName,
|
||||
isLoading: enabled && mapping.isLoading,
|
||||
isError: mapping.isError,
|
||||
errorMessage: mapping.isError ? getErrorMessage(mapping.error, 'Failed to load mapping') : null,
|
||||
diffErrorMessage: diff.isError
|
||||
? getErrorMessage(diff.error, "Couldn't load sync details. Reload the page to retry.")
|
||||
: null,
|
||||
hasDiff: Boolean(diff.data),
|
||||
hasMapping: Boolean(mapping.data),
|
||||
groups,
|
||||
kindSummaries,
|
||||
targetFor,
|
||||
setTarget,
|
||||
takenOwnersFor,
|
||||
usagesForEntry,
|
||||
dependentsForEntry,
|
||||
parentChangedFor,
|
||||
targetWorkspaceId: diff.data?.targetWorkspaceId ?? '',
|
||||
sourceWorkspaceId: diff.data?.sourceWorkspaceId ?? '',
|
||||
reconfig,
|
||||
setReconfig,
|
||||
copyableKeys,
|
||||
copyingKeys,
|
||||
copySelected,
|
||||
toggleCopyKeys,
|
||||
referencedByKind,
|
||||
unreferencedByKind,
|
||||
hasVisibleCopyables: visibleCopyables.length > 0,
|
||||
blockingRefs,
|
||||
dependentClears,
|
||||
workflowChanges,
|
||||
archivedWorkflowNames,
|
||||
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,
|
||||
inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0,
|
||||
dirty,
|
||||
saving: updateMapping.isPending,
|
||||
save,
|
||||
discard,
|
||||
submitting,
|
||||
syncDisabled,
|
||||
syncDisabledReason,
|
||||
sync,
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ChipCopyInput,
|
||||
ChipInput,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { GetForkResourcesResponse } from '@/lib/api/contracts/workspace-fork'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import {
|
||||
FileKindRow,
|
||||
ResourceKindRow,
|
||||
} from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker'
|
||||
import { useForkResources, useForkWorkspace } from '@/ee/workspace-forking/hooks/workspace-fork'
|
||||
|
||||
interface ForkWorkspaceModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sourceWorkspaceId: string
|
||||
sourceWorkspaceName: string
|
||||
/** Whether the user is under their workspace cap; creating a fork is gated on this. */
|
||||
canFork: boolean
|
||||
/** Sends the user to upgrade (billing) when they try to fork at the cap. */
|
||||
onUpgrade: () => void
|
||||
}
|
||||
|
||||
type ResourceKey = Exclude<keyof GetForkResourcesResponse, 'deployedWorkflowCount'>
|
||||
type ResourceSelection = Record<ResourceKey, Set<string>>
|
||||
|
||||
const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [
|
||||
{ key: 'files', label: 'Files' },
|
||||
{ key: 'tables', label: 'Tables' },
|
||||
{ key: 'knowledgeBases', label: 'Knowledge bases' },
|
||||
{ key: 'customTools', label: 'Custom tools' },
|
||||
{ key: 'skills', label: 'Skills' },
|
||||
{ key: 'mcpServers', label: 'MCP servers' },
|
||||
{ key: 'workflowMcpServers', label: 'Workflow MCP servers' },
|
||||
]
|
||||
|
||||
const emptySelection = (): ResourceSelection => ({
|
||||
files: new Set(),
|
||||
tables: new Set(),
|
||||
knowledgeBases: new Set(),
|
||||
customTools: new Set(),
|
||||
skills: new Set(),
|
||||
mcpServers: new Set(),
|
||||
workflowMcpServers: new Set(),
|
||||
})
|
||||
|
||||
const fullSelection = (data: GetForkResourcesResponse): ResourceSelection => {
|
||||
const selection = emptySelection()
|
||||
for (const kind of RESOURCE_KINDS) {
|
||||
selection[kind.key] = new Set((data[kind.key] ?? []).map((item) => item.id))
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
/**
|
||||
* Names and creates a fork of the current workspace, letting the user pick which
|
||||
* resources to copy (whole kinds or a specific subset). Unselected resources leave
|
||||
* the corresponding workflow subblocks empty. On success the modal closes - the
|
||||
* Forks settings page's Activity log tracks the copy job, and the toast offers a
|
||||
* one-click jump into the new fork.
|
||||
*/
|
||||
export function ForkWorkspaceModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
sourceWorkspaceId,
|
||||
sourceWorkspaceName,
|
||||
canFork,
|
||||
onUpgrade,
|
||||
}: ForkWorkspaceModalProps) {
|
||||
const router = useRouter()
|
||||
const forkWorkspace = useForkWorkspace()
|
||||
const resources = useForkResources(sourceWorkspaceId, open)
|
||||
const [name, setName] = useState('')
|
||||
const [selected, setSelected] = useState<ResourceSelection>(emptySelection)
|
||||
const [defaulted, setDefaulted] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(`${sourceWorkspaceName} (fork)`)
|
||||
setSelected(emptySelection())
|
||||
setDefaulted(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [open, sourceWorkspaceName])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !resources.data || defaulted) return
|
||||
setDefaulted(true)
|
||||
setSelected(fullSelection(resources.data))
|
||||
}, [open, resources.data, defaulted])
|
||||
|
||||
const isForking = forkWorkspace.isPending
|
||||
|
||||
const availableKinds = useMemo(
|
||||
() => RESOURCE_KINDS.filter((kind) => (resources.data?.[kind.key].length ?? 0) > 0),
|
||||
[resources.data]
|
||||
)
|
||||
|
||||
const hasDeselection = useMemo(
|
||||
() =>
|
||||
defaulted &&
|
||||
availableKinds.some(
|
||||
(kind) => selected[kind.key].size < (resources.data?.[kind.key]?.length ?? 0)
|
||||
),
|
||||
[defaulted, availableKinds, selected, resources.data]
|
||||
)
|
||||
|
||||
// A fork always produces a usable workspace: deployed workflows are copied, and
|
||||
// when the source has none, create-fork seeds a blank starter workflow (plus any
|
||||
// selected resources). So forking is never blocked - we just set expectations when
|
||||
// there are no deployed workflows to carry over.
|
||||
const noDeployedWorkflows =
|
||||
Boolean(resources.data) && (resources.data?.deployedWorkflowCount ?? 0) === 0
|
||||
|
||||
const handleSubmit = () => {
|
||||
// At a workspace cap, creating a fork is the only gated action - send the user to
|
||||
// upgrade rather than blocking the whole modal.
|
||||
if (!canFork) {
|
||||
onUpgrade()
|
||||
return
|
||||
}
|
||||
const trimmed = name.trim()
|
||||
// Block until the resources query resolves: building `copy` from an unloaded `resources.data`
|
||||
// would send an empty selection and silently clear every reference in the fork. The Fork
|
||||
// action is disabled in this state too; this is the defense-in-depth guard.
|
||||
if (!trimmed || isForking || !resources.data) return
|
||||
setError(null)
|
||||
const copy = Object.fromEntries(
|
||||
RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])])
|
||||
)
|
||||
forkWorkspace.mutate(
|
||||
{ workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
// The copy job's progress lands in the page's Activity log; the toast action
|
||||
// preserves the old modal's one-click "Open fork".
|
||||
toast.success(`Forked into "${result.workspace.name}"`, {
|
||||
action: {
|
||||
label: 'Open fork',
|
||||
onClick: () => router.push(`/workspace/${result.workspace.id}/w`),
|
||||
},
|
||||
})
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => setError(err.message || 'Failed to fork workspace'),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Fork workspace'>
|
||||
<ChipModalHeader onClose={() => onOpenChange(false)}>Fork workspace</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<div className='flex flex-col gap-7 px-2'>
|
||||
<SettingsSection label='Forking from'>
|
||||
<ChipCopyInput value={sourceWorkspaceName} aria-label='Forking from' />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
label='Name'
|
||||
headerAccessory={
|
||||
<span className='text-[var(--text-error)]' title='Required'>
|
||||
*
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ChipInput
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder='Workspace name'
|
||||
maxLength={100}
|
||||
autoComplete='off'
|
||||
disabled={isForking}
|
||||
aria-label='Workspace name'
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{availableKinds.length > 0 ? (
|
||||
<SettingsSection label='Copy resources'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{availableKinds.map((kind) =>
|
||||
kind.key === 'files' ? (
|
||||
<FileKindRow
|
||||
key={kind.key}
|
||||
label={kind.label}
|
||||
files={resources.data?.files ?? []}
|
||||
selected={selected.files}
|
||||
onToggleAll={(selectAll) =>
|
||||
setSelected((prev) => ({
|
||||
...prev,
|
||||
files: selectAll
|
||||
? new Set((resources.data?.files ?? []).map((item) => item.id))
|
||||
: new Set<string>(),
|
||||
}))
|
||||
}
|
||||
onToggleItem={(id, checked) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev.files)
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
return { ...prev, files: next }
|
||||
})
|
||||
}
|
||||
onToggleMany={(ids, checked) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev.files)
|
||||
for (const id of ids) {
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
}
|
||||
return { ...prev, files: next }
|
||||
})
|
||||
}
|
||||
disabled={isForking}
|
||||
/>
|
||||
) : (
|
||||
<ResourceKindRow
|
||||
key={kind.key}
|
||||
label={kind.label}
|
||||
items={resources.data?.[kind.key] ?? []}
|
||||
selected={selected[kind.key]}
|
||||
onToggleMany={(ids, checked) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev[kind.key])
|
||||
for (const id of ids) {
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
}
|
||||
return { ...prev, [kind.key]: next }
|
||||
})
|
||||
}
|
||||
onToggleItem={(id, checked) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev[kind.key])
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
return { ...prev, [kind.key]: next }
|
||||
})
|
||||
}
|
||||
disabled={isForking}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{hasDeselection ? (
|
||||
<div className='flex items-start gap-1.5 text-[var(--text-secondary)] text-caption'>
|
||||
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
|
||||
<span>
|
||||
Some resources are not selected — references to them in your workflows will be
|
||||
cleared in the fork.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{noDeployedWorkflows ? (
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
No deployed workflows to copy — your fork will start with a blank workflow.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ChipModalError>{error ?? undefined}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
cancelDisabled={isForking}
|
||||
primaryAction={{
|
||||
label: isForking ? 'Forking...' : 'Fork',
|
||||
onClick: handleSubmit,
|
||||
// At the cap the button stays clickable (no name needed) so it can route to
|
||||
// upgrade. Otherwise it needs a name AND the resources query loaded - forking
|
||||
// before `resources.data` arrives would clear every reference (P1-C).
|
||||
disabled: isForking || (canFork && (!name.trim() || !resources.data)),
|
||||
disabledTooltip:
|
||||
canFork && name.trim() && !resources.data ? 'Loading workspace resources…' : undefined,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChipConfirmModal, toast } from '@sim/emcn'
|
||||
import { ArrowLeft } from '@sim/emcn/icons'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { AlertTriangle, Plus } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork'
|
||||
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
|
||||
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
forkIdParam,
|
||||
forkIdUrlKeys,
|
||||
forkSyncDirectionParam,
|
||||
forkSyncDirectionUrlKeys,
|
||||
forkViewParam,
|
||||
forkViewUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
|
||||
import {
|
||||
type RowAction,
|
||||
RowActionsMenu,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel'
|
||||
import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view'
|
||||
import {
|
||||
ARCHIVED_PREVIEW_LIMIT,
|
||||
useForkSync,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
|
||||
import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal'
|
||||
import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available'
|
||||
import {
|
||||
useForkLineage,
|
||||
useRollbackFork,
|
||||
useUnlinkFork,
|
||||
} from '@/ee/workspace-forking/hooks/workspace-fork'
|
||||
import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
|
||||
/** Explains a disabled lineage action whose target workspace the viewer cannot open. */
|
||||
const NO_ACCESS_TOOLTIP = "You don't have access to this workspace"
|
||||
|
||||
/** Lineage partner names by id (the parent + this workspace's forks), for the Activity view. */
|
||||
function lineagePartnerNames(
|
||||
parent: ForkLineageNodeApi | null,
|
||||
forks: ForkLineageChildApi[]
|
||||
): ReadonlyMap<string, string> {
|
||||
const names = new Map<string, string>()
|
||||
if (parent) names.set(parent.id, parent.name)
|
||||
for (const fork of forks) names.set(fork.id, fork.name)
|
||||
return names
|
||||
}
|
||||
|
||||
interface ForkListRowProps {
|
||||
name: string
|
||||
/** Entries for the row's `...` menu (Edit mappings / Open workspace / Disconnect). */
|
||||
actions: RowAction[]
|
||||
}
|
||||
|
||||
function ForkListRow({ name, actions }: ForkListRowProps) {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<FloatingOverflowText
|
||||
label={name}
|
||||
className='block min-w-0 truncate text-[var(--text-body)] text-sm'
|
||||
/>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
<RowActionsMenu label='Fork actions' actions={actions} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ForkSyncDetailViewProps {
|
||||
title: string
|
||||
workspaceId: string
|
||||
/** The other side of the edge being synced (this workspace's parent). */
|
||||
otherWorkspaceId: string
|
||||
otherWorkspaceName: string
|
||||
onBack: () => void
|
||||
/** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */
|
||||
actions: SettingsAction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent edge's sync page (reached from the parent row): direction, deployed-workflow
|
||||
* changes, per-kind mappings (each an expandable row whose status badge is the summary),
|
||||
* copy resources, and blocking references, all as page sections.
|
||||
* The header's Sync chip is gated until zero blockers + required mappings + reconfigure are
|
||||
* complete, and always confirms the overwrite first — that confirm is the flow's one modal.
|
||||
* While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded;
|
||||
* Sync itself persists the effective mapping as part of the run.
|
||||
*/
|
||||
function ForkSyncDetailView({
|
||||
title,
|
||||
workspaceId,
|
||||
otherWorkspaceId,
|
||||
otherWorkspaceName,
|
||||
onBack,
|
||||
actions,
|
||||
}: ForkSyncDetailViewProps) {
|
||||
// Sync direction is shareable view state: a copied link opens the same side of the sync.
|
||||
const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, {
|
||||
...forkSyncDirectionParam.parser,
|
||||
...forkSyncDirectionUrlKeys,
|
||||
})
|
||||
|
||||
const controller = useForkSync({
|
||||
workspaceId,
|
||||
otherWorkspaceId,
|
||||
otherWorkspaceName,
|
||||
direction,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed
|
||||
// the shared settings dirty store so a sidebar section switch confirms too.
|
||||
const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty })
|
||||
|
||||
const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
|
||||
|
||||
// Sync is the edge's primary action, so it's the rightmost/black chip; the caller's
|
||||
// Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster
|
||||
// for Discard/Save until they're saved or discarded.
|
||||
const panelActions: SettingsAction[] = controller.dirty
|
||||
? saveDiscardActions({
|
||||
dirty: controller.dirty,
|
||||
saving: controller.saving,
|
||||
onSave: controller.save,
|
||||
onDiscard: controller.discard,
|
||||
})
|
||||
: [
|
||||
...actions,
|
||||
{
|
||||
text: controller.submitting ? 'Working...' : 'Sync',
|
||||
variant: 'primary' as const,
|
||||
onSelect: () => setConfirmSyncOpen(true),
|
||||
disabled: controller.syncDisabled,
|
||||
tooltip: controller.syncDisabled
|
||||
? controller.syncDisabledReason
|
||||
: `Push to or pull from ${otherWorkspaceName}`,
|
||||
},
|
||||
]
|
||||
|
||||
const targetWorkspaceName = direction === 'push' ? otherWorkspaceName : 'this workspace'
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
back={{
|
||||
text: 'Workspace Forks',
|
||||
icon: ArrowLeft,
|
||||
onSelect: () =>
|
||||
guard.guardBack(() => {
|
||||
void setDirection(null)
|
||||
onBack()
|
||||
}),
|
||||
}}
|
||||
title={title}
|
||||
actions={panelActions}
|
||||
>
|
||||
<ForkSyncView
|
||||
controller={controller}
|
||||
onDirectionChange={(next) => void setDirection(next)}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
|
||||
<UnsavedChangesModal
|
||||
open={guard.showUnsavedModal}
|
||||
onOpenChange={guard.setShowUnsavedModal}
|
||||
onDiscard={guard.confirmDiscard}
|
||||
/>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={confirmSyncOpen}
|
||||
onOpenChange={setConfirmSyncOpen}
|
||||
srTitle='Sync workspace'
|
||||
title='Overwrite target workspace'
|
||||
text={[
|
||||
'The target may have been modified since the last sync. Syncing will ',
|
||||
{ text: 'overwrite any changes', bold: true },
|
||||
' there. Continue?',
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Sync',
|
||||
onClick: () => {
|
||||
setConfirmSyncOpen(false)
|
||||
void controller.sync()
|
||||
},
|
||||
pending: controller.submitting,
|
||||
pendingLabel: 'Syncing...',
|
||||
}}
|
||||
>
|
||||
{controller.archivedWorkflowNames.length > 0 ? (
|
||||
<div className='flex flex-col gap-1 px-2'>
|
||||
<p className='break-words text-[var(--text-primary)] text-sm'>
|
||||
Will be archived in <span className='font-medium'>{targetWorkspaceName}</span>{' '}
|
||||
(deleted in the source):
|
||||
</p>
|
||||
{controller.archivedWorkflowNames
|
||||
.slice(0, ARCHIVED_PREVIEW_LIMIT)
|
||||
.map((name, index) => (
|
||||
<div
|
||||
key={`${name}:${index}`}
|
||||
className='min-w-0 truncate text-[var(--text-muted)] text-small'
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
{controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? (
|
||||
<div className='text-[var(--text-muted)] text-small'>
|
||||
and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</ChipConfirmModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface ForkActivityDetailViewProps {
|
||||
workspaceId: string
|
||||
/** Lineage partner names by id, for phrasing rows recorded on the other side of an edge. */
|
||||
workspaceNames: ReadonlyMap<string, string>
|
||||
onBack: () => void
|
||||
/** Header actions (e.g. the destructive Rollback chip while the last sync is undoable). */
|
||||
actions?: SettingsAction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-scoped activity: every fork, sync, and rollback involving this workspace
|
||||
* (both sides of each edge), reached from the page header's "See activity" action.
|
||||
*/
|
||||
function ForkActivityDetailView({
|
||||
workspaceId,
|
||||
workspaceNames,
|
||||
onBack,
|
||||
actions,
|
||||
}: ForkActivityDetailViewProps) {
|
||||
return (
|
||||
<SettingsPanel
|
||||
back={{ text: 'Workspace Forks', icon: ArrowLeft, onSelect: onBack }}
|
||||
title='Activity'
|
||||
actions={actions}
|
||||
>
|
||||
<ForkActivityPanel workspaceId={workspaceId} workspaceNames={workspaceNames} />
|
||||
</SettingsPanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks settings page. The workspace's single parent (if it's a fork) sits in its own
|
||||
* "Parent" section, above the "Forks" list of child forks. The parent row's `...` menu
|
||||
* has Edit mappings (the child owns its edge's re-picks), Open workspace, and
|
||||
* Disconnect; fork rows offer Open workspace and Disconnect only. Activity is
|
||||
* workspace-scoped and lives behind the header's "See activity" action (including
|
||||
* Rollback when the last sync into this workspace is undoable). Sync lives on the
|
||||
* parent's sync detail page.
|
||||
* Forking and sync rewrite workflow state and deployments en masse, so the page is
|
||||
* workspace-admin only and gated on the workspace's fork entitlement - every fork route
|
||||
* re-checks both; the server remains the boundary.
|
||||
*/
|
||||
export function Forks() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { canAdmin, isLoading: permissionsLoading } = useUserPermissionsContext()
|
||||
const { available: forkingAvailable, isLoading: availabilityLoading } =
|
||||
useForkingAvailability(workspaceId)
|
||||
const canUseForking = forkingAvailable && canAdmin
|
||||
|
||||
const { data: workspaces } = useWorkspacesQuery()
|
||||
const { data: creationPolicy } = useWorkspaceCreationPolicy()
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const lineage = useForkLineage(workspaceId, canUseForking)
|
||||
const rollback = useRollbackFork()
|
||||
const unlink = useUnlinkFork()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isForkModalOpen, setIsForkModalOpen] = useState(false)
|
||||
const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false)
|
||||
const [confirmUnlink, setConfirmUnlink] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const [selectedForkId, setSelectedForkId] = useQueryState(forkIdParam.key, {
|
||||
...forkIdParam.parser,
|
||||
...forkIdUrlKeys,
|
||||
})
|
||||
const [forkView, setForkView] = useQueryState(forkViewParam.key, {
|
||||
...forkViewParam.parser,
|
||||
...forkViewUrlKeys,
|
||||
})
|
||||
|
||||
const workspaceName = workspaces?.find((workspace) => workspace.id === workspaceId)?.name
|
||||
const canFork = creationPolicy?.canCreate ?? true
|
||||
const parent = lineage.data?.parent ?? null
|
||||
const forks = lineage.data?.children ?? []
|
||||
const undoableRun = lineage.data?.undoableRun ?? null
|
||||
const gateLoading = availabilityLoading || permissionsLoading
|
||||
|
||||
// Rollback undoes the last sync INTO this workspace, restoring each affected
|
||||
// workflow to its prior deployed version.
|
||||
const runRollback = async () => {
|
||||
if (!undoableRun) return
|
||||
try {
|
||||
await rollback.mutateAsync({
|
||||
workspaceId,
|
||||
body: { otherWorkspaceId: undoableRun.otherWorkspaceId },
|
||||
})
|
||||
toast.success(`Undid sync from "${undoableRun.otherName}"`)
|
||||
setConfirmRollbackOpen(false)
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Undo failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const openForkWorkspace = (forkId: string) => {
|
||||
router.push(`/workspace/${forkId}/w`)
|
||||
}
|
||||
|
||||
const openForkMappings = (forkId: string) => {
|
||||
void setSelectedForkId(forkId)
|
||||
}
|
||||
|
||||
/** Permanently dissolve the edge with the confirmed workspace; both workspaces remain. */
|
||||
const runUnlink = async () => {
|
||||
if (!confirmUnlink) return
|
||||
try {
|
||||
await unlink.mutateAsync({
|
||||
workspaceId,
|
||||
body: { otherWorkspaceId: confirmUnlink.id },
|
||||
})
|
||||
toast.success(`Disconnected "${confirmUnlink.name}"`)
|
||||
setConfirmUnlink(null)
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Disconnect failed'))
|
||||
}
|
||||
}
|
||||
|
||||
if (gateLoading) {
|
||||
return <SettingsPanel />
|
||||
}
|
||||
|
||||
if (!canUseForking) {
|
||||
return (
|
||||
<SettingsPanel>
|
||||
<SettingsEmptyState>
|
||||
{canAdmin
|
||||
? 'Forking is not available for this workspace.'
|
||||
: 'Only workspace admins can manage forks.'}
|
||||
</SettingsEmptyState>
|
||||
</SettingsPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const searchLower = searchTerm.trim().toLowerCase()
|
||||
const parentVisible =
|
||||
parent !== null && (!searchLower || parent.name.toLowerCase().includes(searchLower))
|
||||
const filteredForks = forks.filter((fork) => fork.name.toLowerCase().includes(searchLower))
|
||||
const hasRows = parent !== null || forks.length > 0
|
||||
|
||||
// The sync detail exists only for the PARENT edge: sync (and the mapping re-picks it
|
||||
// persists) belongs to the child workspace configuring how it maps its parent's
|
||||
// resources, so a parent browsing its forks gets no detail for them (a stale fork-id
|
||||
// deep link falls back to the list). Fork rows offer Open workspace / Disconnect only.
|
||||
const showParentDetail = Boolean(selectedForkId && parent && parent.id === selectedForkId)
|
||||
|
||||
// Open workspace sits left of the detail view's primary Sync chip, which the sync
|
||||
// page owns (it carries the gating). Rollback lives on the Activity view only.
|
||||
const parentHeaderActions: SettingsAction[] = parent
|
||||
? [
|
||||
{
|
||||
text: 'Open workspace',
|
||||
onSelect: () => openForkWorkspace(parent.id),
|
||||
disabled: !parent.viewerAccessible,
|
||||
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
{showParentDetail && parent ? (
|
||||
<ForkSyncDetailView
|
||||
key={parent.id}
|
||||
title={parent.name}
|
||||
workspaceId={workspaceId}
|
||||
otherWorkspaceId={parent.id}
|
||||
otherWorkspaceName={parent.name}
|
||||
onBack={() => setSelectedForkId(null)}
|
||||
actions={parentHeaderActions}
|
||||
/>
|
||||
) : forkView === 'activity' ? (
|
||||
<ForkActivityDetailView
|
||||
workspaceId={workspaceId}
|
||||
workspaceNames={lineagePartnerNames(parent, forks)}
|
||||
onBack={() => setForkView(null)}
|
||||
actions={
|
||||
undoableRun
|
||||
? [
|
||||
{
|
||||
text: 'Rollback',
|
||||
variant: 'destructive',
|
||||
onSelect: () => setConfirmRollbackOpen(true),
|
||||
disabled: rollback.isPending,
|
||||
tooltip: `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search forks...',
|
||||
}}
|
||||
actions={[
|
||||
{ text: 'See activity', onSelect: () => void setForkView('activity') },
|
||||
{
|
||||
text: 'Create fork',
|
||||
icon: Plus,
|
||||
variant: 'primary',
|
||||
onSelect: () => setIsForkModalOpen(true),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{lineage.isError ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-2'>
|
||||
<p className='text-[var(--text-error)] text-sm leading-tight'>
|
||||
{getErrorMessage(lineage.error, 'Failed to load forks')}
|
||||
</p>
|
||||
</div>
|
||||
) : lineage.isLoading ? null : !hasRows ? (
|
||||
<SettingsEmptyState>Click "Create fork" above to get started</SettingsEmptyState>
|
||||
) : (
|
||||
<div className='flex flex-col gap-7'>
|
||||
{parentVisible && parent !== null && (
|
||||
<SettingsSection label='Parent'>
|
||||
<ForkListRow
|
||||
name={parent.name}
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit mappings',
|
||||
onSelect: () => openForkMappings(parent.id),
|
||||
disabled: !parent.viewerAccessible,
|
||||
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
|
||||
},
|
||||
{
|
||||
label: 'Open workspace',
|
||||
onSelect: () => openForkWorkspace(parent.id),
|
||||
disabled: !parent.viewerAccessible,
|
||||
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
|
||||
},
|
||||
// Disconnect stays enabled regardless of access: severing the edge is a
|
||||
// current-workspace operation (admin on the acting side only), and must
|
||||
// remain reachable exactly when the other side is inaccessible.
|
||||
{
|
||||
label: 'Disconnect',
|
||||
destructive: true,
|
||||
onSelect: () => setConfirmUnlink({ id: parent.id, name: parent.name }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
<SettingsSection label='Forks'>
|
||||
{filteredForks.length > 0 ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{filteredForks.map((fork) => (
|
||||
<ForkListRow
|
||||
key={fork.id}
|
||||
name={fork.name}
|
||||
actions={[
|
||||
{
|
||||
label: 'Open workspace',
|
||||
onSelect: () => openForkWorkspace(fork.id),
|
||||
disabled: !fork.viewerAccessible,
|
||||
tooltip: fork.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
|
||||
},
|
||||
{
|
||||
label: 'Disconnect',
|
||||
destructive: true,
|
||||
onSelect: () => setConfirmUnlink({ id: fork.id, name: fork.name }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
{searchTerm.trim()
|
||||
? `No forks found matching "${searchTerm}"`
|
||||
: 'No forks yet — click "Create fork" above to get started'}
|
||||
</SettingsEmptyState>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)}
|
||||
</SettingsPanel>
|
||||
)}
|
||||
|
||||
<ForkWorkspaceModal
|
||||
open={isForkModalOpen}
|
||||
onOpenChange={setIsForkModalOpen}
|
||||
sourceWorkspaceId={workspaceId}
|
||||
sourceWorkspaceName={workspaceName || 'Workspace'}
|
||||
canFork={canFork}
|
||||
onUpgrade={() => {
|
||||
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={confirmUnlink !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmUnlink(null)
|
||||
}}
|
||||
srTitle='Disconnect fork'
|
||||
title='Disconnect fork'
|
||||
text={[
|
||||
'This permanently removes the fork relationship with ',
|
||||
{ text: confirmUnlink?.name ?? '', bold: true },
|
||||
". Both workspaces stay exactly as they are, but they will no longer appear in each other's fork lists, and syncing between them stops.",
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Disconnect',
|
||||
onClick: () => void runUnlink(),
|
||||
pending: unlink.isPending,
|
||||
pendingLabel: 'Disconnecting...',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-start gap-1.5 px-2 text-[var(--text-secondary)] text-caption'>
|
||||
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
|
||||
<span>
|
||||
This cannot be undone — the saved mappings and sync history for this pair are deleted,
|
||||
and forking again creates a brand-new workspace.
|
||||
</span>
|
||||
</div>
|
||||
</ChipConfirmModal>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={confirmRollbackOpen}
|
||||
onOpenChange={setConfirmRollbackOpen}
|
||||
srTitle='Undo last sync'
|
||||
title='Undo last sync'
|
||||
text={[
|
||||
'This restores each affected workflow to its ',
|
||||
{ text: 'prior deployed version', bold: true },
|
||||
' and removes workflows the sync created. Continue?',
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Rollback',
|
||||
onClick: () => void runRollback(),
|
||||
pending: rollback.isPending,
|
||||
pendingLabel: 'Rolling back...',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-start gap-1.5 px-2 text-[var(--text-secondary)] text-caption'>
|
||||
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
|
||||
<span>
|
||||
Resources copied into this workspace during syncs may remain afterward — rollback
|
||||
restores workflows to their prior versions but does not remove copied resources.
|
||||
</span>
|
||||
</div>
|
||||
</ChipConfirmModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type BackgroundWorkItem,
|
||||
type GetWorkspaceBackgroundWorkResponse,
|
||||
getWorkspaceBackgroundWorkContract,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
export const backgroundWorkKeys = {
|
||||
all: ['backgroundWork'] as const,
|
||||
// 'infinite' segments the key from the pre-pagination plain-query era: the data shape
|
||||
// under the old key was an array, and an infinite query reading such a cache entry
|
||||
// renders as empty. A shape change must always re-key.
|
||||
lists: () => [...backgroundWorkKeys.all, 'list', 'infinite'] as const,
|
||||
list: (workspaceId?: string) => [...backgroundWorkKeys.lists(), workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
export const BACKGROUND_WORK_STALE_TIME = 5_000
|
||||
|
||||
/** Page size for the fork Activity feed, matching the audit log's. */
|
||||
const BACKGROUND_WORK_PAGE_SIZE = '50'
|
||||
|
||||
async function fetchWorkspaceBackgroundWork(
|
||||
workspaceId: string,
|
||||
cursor?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<GetWorkspaceBackgroundWorkResponse> {
|
||||
return requestJson(getWorkspaceBackgroundWorkContract, {
|
||||
params: { id: workspaceId },
|
||||
query: { cursor, limit: BACKGROUND_WORK_PAGE_SIZE },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
const isActive = (item: BackgroundWorkItem) =>
|
||||
item.status === 'pending' || item.status === 'processing'
|
||||
|
||||
/**
|
||||
* Durable "background work in progress" status for a workspace (fork content copy +
|
||||
* any deployment side-effects), keyset-paginated like the enterprise audit log
|
||||
* (`getNextPageParam` from the page's `nextCursor`). Poll-first per the best-practice
|
||||
* for long jobs: the status survives a reload (it's a DB row), and we only keep
|
||||
* polling while something is still running, then stop - the poll refetches every
|
||||
* loaded page sequentially with fresh cursors, so pagination stays consistent.
|
||||
* Refetch on focus catches changes after the tab was away.
|
||||
*/
|
||||
export function useWorkspaceBackgroundWork(workspaceId?: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: backgroundWorkKeys.list(workspaceId),
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
fetchWorkspaceBackgroundWork(workspaceId as string, pageParam, signal),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: BACKGROUND_WORK_STALE_TIME,
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data?.pages ?? []).some((page) => page.items.some(isActive)) ? 5_000 : false,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getForkAvailabilityContract } from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
export const forkAvailabilityKeys = {
|
||||
all: ['fork-availability'] as const,
|
||||
details: () => [...forkAvailabilityKeys.all, 'detail'] as const,
|
||||
detail: (workspaceId?: string) => [...forkAvailabilityKeys.details(), workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
/** Availability flips only on plan changes or flag rollouts - cache generously. */
|
||||
const FORK_AVAILABILITY_STALE_TIME = 5 * 60 * 1000
|
||||
|
||||
interface ForkingAvailability {
|
||||
available: boolean
|
||||
/** The lookup is still in flight - callers that gate a whole page wait on this. */
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-evaluated fork availability for the workspace: the verdict of the exact gate
|
||||
* every fork route enforces (env/plan + the `workspace-forking` AppConfig rollout
|
||||
* flag), served by the availability route. Used to hide the Forks settings tab and
|
||||
* the fork context-menu entries; the server gate remains the security boundary.
|
||||
*/
|
||||
export function useForkingAvailability(workspaceId?: string): ForkingAvailability {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: forkAvailabilityKeys.detail(workspaceId),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkAvailabilityContract, { params: { id: workspaceId as string }, signal }),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: FORK_AVAILABILITY_STALE_TIME,
|
||||
})
|
||||
return { available: data?.available ?? false, isLoading }
|
||||
}
|
||||
|
||||
/** Boolean shorthand for surfaces that only show/hide fork entry points. */
|
||||
export function useForkingAvailable(workspaceId?: string): boolean {
|
||||
return useForkingAvailability(workspaceId).available
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ForkWorkspaceBody,
|
||||
forkWorkspaceContract,
|
||||
getForkDiffContract,
|
||||
getForkLineageContract,
|
||||
getForkMappingContract,
|
||||
getForkResourcesContract,
|
||||
type PromoteForkBody,
|
||||
promoteForkContract,
|
||||
type RollbackForkBody,
|
||||
rollbackForkContract,
|
||||
type UnlinkForkBody,
|
||||
type UpdateForkMappingBody,
|
||||
unlinkForkContract,
|
||||
updateForkMappingContract,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import type { WorkspacesResponse } from '@/lib/api/contracts/workspaces'
|
||||
import { backgroundWorkKeys } from '@/ee/workspace-forking/hooks/background-work'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
import { workspaceKeys } from '@/hooks/queries/workspace'
|
||||
|
||||
export type ForkDirection = 'push' | 'pull'
|
||||
|
||||
export const forkKeys = {
|
||||
all: ['workspace-fork'] as const,
|
||||
lineages: () => [...forkKeys.all, 'lineage'] as const,
|
||||
lineage: (workspaceId?: string) => [...forkKeys.lineages(), workspaceId ?? ''] as const,
|
||||
mappings: () => [...forkKeys.all, 'mapping'] as const,
|
||||
mapping: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) =>
|
||||
[...forkKeys.mappings(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const,
|
||||
diffs: () => [...forkKeys.all, 'diff'] as const,
|
||||
diff: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) =>
|
||||
[...forkKeys.diffs(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const,
|
||||
resourcesAll: () => [...forkKeys.all, 'resources'] as const,
|
||||
resources: (workspaceId?: string) => [...forkKeys.resourcesAll(), workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
export const WORKSPACE_FORK_RESOURCES_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_FORK_LINEAGE_STALE_TIME = 30 * 1000
|
||||
export const WORKSPACE_FORK_MAPPING_STALE_TIME = 15 * 1000
|
||||
export const WORKSPACE_FORK_DIFF_STALE_TIME = 10 * 1000
|
||||
|
||||
export function useForkResources(workspaceId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: forkKeys.resources(workspaceId),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkResourcesContract, { params: { id: workspaceId as string }, signal }),
|
||||
enabled: Boolean(workspaceId) && enabled,
|
||||
staleTime: WORKSPACE_FORK_RESOURCES_STALE_TIME,
|
||||
})
|
||||
}
|
||||
|
||||
export function useForkLineage(workspaceId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: forkKeys.lineage(workspaceId),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkLineageContract, { params: { id: workspaceId as string }, signal }),
|
||||
enabled: Boolean(workspaceId) && enabled,
|
||||
staleTime: WORKSPACE_FORK_LINEAGE_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useForkWorkspace() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: ForkWorkspaceBody }) =>
|
||||
requestJson(forkWorkspaceContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSuccess: (data) => {
|
||||
// Merge the new fork into the active list cache before invalidation so the
|
||||
// immediate navigation into it can't race a stale list and trip the
|
||||
// not-in-workspaces redirect (mirrors useCreateWorkspace).
|
||||
const newWorkspace = data.workspace
|
||||
queryClient.setQueryData<WorkspacesResponse>(workspaceKeys.list('active'), (previous) => {
|
||||
if (!previous) {
|
||||
return { workspaces: [newWorkspace], lastActiveWorkspaceId: null, creationPolicy: null }
|
||||
}
|
||||
if (previous.workspaces.some((w) => w.id === newWorkspace.id)) {
|
||||
return previous
|
||||
}
|
||||
return { ...previous, workspaces: [newWorkspace, ...previous.workspaces] }
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() })
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
|
||||
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useForkMapping(args: {
|
||||
workspaceId?: string
|
||||
otherWorkspaceId?: string
|
||||
direction: ForkDirection
|
||||
enabled?: boolean
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: forkKeys.mapping(args.workspaceId, args.otherWorkspaceId, args.direction),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkMappingContract, {
|
||||
params: { id: args.workspaceId as string },
|
||||
query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true),
|
||||
staleTime: WORKSPACE_FORK_MAPPING_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateForkMapping() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: UpdateForkMappingBody }) =>
|
||||
requestJson(updateForkMappingContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useForkDiff(args: {
|
||||
workspaceId?: string
|
||||
otherWorkspaceId?: string
|
||||
direction: ForkDirection
|
||||
enabled?: boolean
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: forkKeys.diff(args.workspaceId, args.otherWorkspaceId, args.direction),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkDiffContract, {
|
||||
params: { id: args.workspaceId as string },
|
||||
query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true),
|
||||
staleTime: WORKSPACE_FORK_DIFF_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePromoteFork() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: PromoteForkBody }) =>
|
||||
requestJson(promoteForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSettled: () => {
|
||||
// A sync changes lineage (undoable run), mappings, and the diff - not the
|
||||
// workspace's copyable resource inventory, so leave `resources` cached.
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
|
||||
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
|
||||
// A sync rewrites the target workflows' drafts AND redeploys them. The promote
|
||||
// result doesn't expose the affected ids, so invalidate all deployment caches:
|
||||
// otherwise a target workflow whose deployed state was already cached compares its
|
||||
// fresh draft against the stale (pre-sync) deployed snapshot and falsely shows
|
||||
// "Update" instead of "Live".
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUnlinkFork() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: UnlinkForkBody }) =>
|
||||
requestJson(unlinkForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSettled: () => {
|
||||
// Unlink dissolves the edge: lineage loses the row, and the edge's mappings/diff
|
||||
// no longer exist. Workflows and deployments are untouched.
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
|
||||
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRollbackFork() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: RollbackForkBody }) =>
|
||||
requestJson(rollbackForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSettled: () => {
|
||||
// Rollback changes lineage, mappings, and the diff - not the copyable resource
|
||||
// inventory, so leave `resources` cached (mirrors usePromoteFork).
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
|
||||
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
|
||||
// Rollback restores the target workflows' drafts + reactivates a prior deployment,
|
||||
// so the cached deployed snapshots are stale - refresh them so change detection
|
||||
// doesn't falsely show "Update" (mirrors usePromoteFork).
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { listSurfacedBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
|
||||
|
||||
const executor = dbChainMock.db as unknown as DbOrTx
|
||||
|
||||
/** Shape produced by the drizzle-orm mock's `sql` tagged template. */
|
||||
interface MockSqlFragment {
|
||||
strings: readonly string[]
|
||||
values: unknown[]
|
||||
}
|
||||
|
||||
interface MockCondition {
|
||||
type: string
|
||||
conditions?: MockCondition[]
|
||||
left?: unknown
|
||||
right?: unknown
|
||||
column?: unknown
|
||||
values?: unknown[]
|
||||
}
|
||||
|
||||
/** Resolves the first `.where(...)` (the children lookup) to the given fork ids. */
|
||||
function mockChildrenLookup(childIds: string[]) {
|
||||
dbChainMockFns.where.mockImplementationOnce(
|
||||
() => Promise.resolve(childIds.map((id) => ({ id }))) as never
|
||||
)
|
||||
}
|
||||
|
||||
/** Builds an opaque cursor the way the store encodes one (base64 JSON). */
|
||||
function encodeCursor(data: { updatedAt: string; id: string }): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): { updatedAt: string; id: string } {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||
}
|
||||
|
||||
describe('listSurfacedBackgroundWork', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('returns the surfaced rows ordered by recency with the id tiebreaker', async () => {
|
||||
mockChildrenLookup([])
|
||||
const rows = [
|
||||
{ id: 'job-1', updatedAt: new Date('2026-07-01T10:00:00.000Z') },
|
||||
{ id: 'job-2', updatedAt: new Date('2026-07-01T09:00:00.000Z') },
|
||||
]
|
||||
dbChainMockFns.limit.mockResolvedValueOnce(rows as never)
|
||||
|
||||
const result = await listSurfacedBackgroundWork(executor, 'ws-1')
|
||||
|
||||
expect(result.rows).toEqual(rows)
|
||||
expect(dbChainMockFns.orderBy).toHaveBeenCalledWith(
|
||||
{ type: 'desc', column: 'updatedAt' },
|
||||
{ type: 'desc', column: 'id' }
|
||||
)
|
||||
// Over-fetches one row past the default page size to detect another page.
|
||||
expect(dbChainMockFns.limit).toHaveBeenCalledWith(51)
|
||||
})
|
||||
|
||||
it('returns a null cursor when the page is not full', async () => {
|
||||
mockChildrenLookup([])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ id: 'job-1', updatedAt: new Date('2026-07-01T10:00:00.000Z') },
|
||||
] as never)
|
||||
|
||||
const result = await listSurfacedBackgroundWork(executor, 'ws-1', { limit: 2 })
|
||||
|
||||
expect(result.rows).toHaveLength(1)
|
||||
expect(result.nextCursor).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a null cursor for an empty page', async () => {
|
||||
mockChildrenLookup([])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([] as never)
|
||||
|
||||
const result = await listSurfacedBackgroundWork(executor, 'ws-1')
|
||||
|
||||
expect(result).toEqual({ rows: [], nextCursor: null })
|
||||
})
|
||||
|
||||
it('trims the over-fetched row and encodes the next cursor from the last returned row', async () => {
|
||||
mockChildrenLookup([])
|
||||
const rows = [
|
||||
{ id: 'job-1', updatedAt: new Date('2026-07-01T10:00:00.000Z') },
|
||||
{ id: 'job-2', updatedAt: new Date('2026-07-01T09:00:00.000Z') },
|
||||
{ id: 'job-3', updatedAt: new Date('2026-07-01T08:00:00.000Z') },
|
||||
]
|
||||
dbChainMockFns.limit.mockResolvedValueOnce(rows as never)
|
||||
|
||||
const result = await listSurfacedBackgroundWork(executor, 'ws-1', { limit: 2 })
|
||||
|
||||
expect(dbChainMockFns.limit).toHaveBeenCalledWith(3)
|
||||
expect(result.rows).toEqual(rows.slice(0, 2))
|
||||
expect(result.nextCursor).not.toBeNull()
|
||||
expect(decodeCursor(result.nextCursor as string)).toEqual({
|
||||
updatedAt: '2026-07-01T09:00:00.000Z',
|
||||
id: 'job-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('applies the cursor as a keyset condition with the id tiebreaker', async () => {
|
||||
mockChildrenLookup([])
|
||||
const cursor = encodeCursor({ updatedAt: '2026-07-01T09:00:00.000Z', id: 'job-2' })
|
||||
|
||||
await listSurfacedBackgroundWork(executor, 'ws-1', { cursor })
|
||||
|
||||
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
|
||||
expect(rowsWhere.type).toBe('and')
|
||||
expect(rowsWhere.conditions).toHaveLength(3)
|
||||
|
||||
const cursorDate = new Date('2026-07-01T09:00:00.000Z')
|
||||
const keyset = (rowsWhere.conditions as MockCondition[])[2]
|
||||
expect(keyset).toEqual({
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{ type: 'lt', left: 'updatedAt', right: cursorDate },
|
||||
{
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{ type: 'eq', left: 'updatedAt', right: cursorDate },
|
||||
{ type: 'lt', left: 'id', right: 'job-2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('pages through rows with identical updatedAt via the id tiebreaker', async () => {
|
||||
// Two rows share one timestamp; page 1 ends on the higher id. The next
|
||||
// cursor must carry that id so the second page matches the remaining row
|
||||
// through the eq(updatedAt) + lt(id) arm instead of skipping it.
|
||||
const sharedAt = new Date('2026-07-01T09:00:00.000Z')
|
||||
mockChildrenLookup([])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ id: 'job-b', updatedAt: sharedAt },
|
||||
{ id: 'job-a', updatedAt: sharedAt },
|
||||
] as never)
|
||||
|
||||
const firstPage = await listSurfacedBackgroundWork(executor, 'ws-1', { limit: 1 })
|
||||
expect(firstPage.rows).toEqual([{ id: 'job-b', updatedAt: sharedAt }])
|
||||
expect(decodeCursor(firstPage.nextCursor as string)).toEqual({
|
||||
updatedAt: sharedAt.toISOString(),
|
||||
id: 'job-b',
|
||||
})
|
||||
|
||||
mockChildrenLookup([])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'job-a', updatedAt: sharedAt }] as never)
|
||||
|
||||
const secondPage = await listSurfacedBackgroundWork(executor, 'ws-1', {
|
||||
cursor: firstPage.nextCursor as string,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
const rowsWhere = dbChainMockFns.where.mock.calls[3][0] as MockCondition
|
||||
const keyset = (rowsWhere.conditions as MockCondition[])[2]
|
||||
expect(keyset.conditions?.[1]).toEqual({
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{ type: 'eq', left: 'updatedAt', right: sharedAt },
|
||||
{ type: 'lt', left: 'id', right: 'job-b' },
|
||||
],
|
||||
})
|
||||
expect(secondPage.rows).toEqual([{ id: 'job-a', updatedAt: sharedAt }])
|
||||
expect(secondPage.nextCursor).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an undecodable cursor and serves the first page', async () => {
|
||||
mockChildrenLookup([])
|
||||
|
||||
await listSurfacedBackgroundWork(executor, 'ws-1', { cursor: 'not-base64-json' })
|
||||
|
||||
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
|
||||
expect(rowsWhere.conditions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clamps the requested limit to the server-side cap', async () => {
|
||||
mockChildrenLookup([])
|
||||
|
||||
await listSurfacedBackgroundWork(executor, 'ws-1', { limit: 5000 })
|
||||
|
||||
expect(dbChainMockFns.limit).toHaveBeenCalledWith(101)
|
||||
})
|
||||
|
||||
it('looks up live forks of the workspace for the child-keyed clause', async () => {
|
||||
mockChildrenLookup([])
|
||||
await listSurfacedBackgroundWork(executor, 'ws-1')
|
||||
|
||||
const childrenWhere = dbChainMockFns.where.mock.calls[0][0] as MockCondition
|
||||
expect(childrenWhere).toEqual({
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{ type: 'eq', left: 'forkedFromWorkspaceId', right: 'ws-1' },
|
||||
{ type: 'isNull', column: 'archivedAt' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('matches rows keyed to the workspace, to it as fork child, and to it as edge partner', async () => {
|
||||
mockChildrenLookup([])
|
||||
await listSurfacedBackgroundWork(executor, 'ws-1')
|
||||
|
||||
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
|
||||
expect(rowsWhere.type).toBe('and')
|
||||
expect(rowsWhere.conditions).toHaveLength(2)
|
||||
const [involves, statuses] = rowsWhere.conditions as [MockCondition, MockCondition]
|
||||
|
||||
expect(involves.type).toBe('or')
|
||||
const orConditions = involves.conditions as unknown as [
|
||||
MockCondition,
|
||||
MockSqlFragment,
|
||||
MockSqlFragment,
|
||||
]
|
||||
expect(orConditions).toHaveLength(3)
|
||||
|
||||
expect(orConditions[0]).toEqual({ type: 'eq', left: 'workspaceId', right: 'ws-1' })
|
||||
|
||||
const childIdClause = orConditions[1]
|
||||
expect(childIdClause.strings.join('?')).toContain("->> 'childWorkspaceId' =")
|
||||
expect(childIdClause.values).toEqual(['metadata', 'ws-1'])
|
||||
|
||||
const otherIdClause = orConditions[2]
|
||||
expect(otherIdClause.strings.join('?')).toContain("->> 'otherWorkspaceId' =")
|
||||
expect(otherIdClause.values).toEqual(['metadata', 'ws-1'])
|
||||
|
||||
expect(statuses).toEqual({
|
||||
type: 'inArray',
|
||||
column: 'status',
|
||||
values: ['pending', 'processing', 'completed', 'completed_with_warnings', 'failed'],
|
||||
})
|
||||
})
|
||||
|
||||
it('also matches sync/rollback rows keyed to the forks of the workspace (pre-otherWorkspaceId history)', async () => {
|
||||
mockChildrenLookup(['fork-1', 'fork-2'])
|
||||
await listSurfacedBackgroundWork(executor, 'ws-1')
|
||||
|
||||
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
|
||||
const involves = (rowsWhere.conditions as MockCondition[])[0]
|
||||
expect(involves.conditions).toHaveLength(4)
|
||||
|
||||
const childKeyedClause = (involves.conditions as MockCondition[])[3]
|
||||
expect(childKeyedClause).toEqual({
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{ type: 'inArray', column: 'workspaceId', values: ['fork-1', 'fork-2'] },
|
||||
{ type: 'inArray', column: 'kind', values: ['fork_sync', 'fork_rollback'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the child-keyed clause when the workspace has no forks', async () => {
|
||||
mockChildrenLookup([])
|
||||
await listSurfacedBackgroundWork(executor, 'ws-1')
|
||||
|
||||
const rowsWhere = dbChainMockFns.where.mock.calls[1][0] as MockCondition
|
||||
const involves = (rowsWhere.conditions as MockCondition[])[0]
|
||||
expect(involves.conditions).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
import { backgroundWorkStatus, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, desc, eq, inArray, isNull, lt, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
const logger = createLogger('ForkBackgroundWork')
|
||||
|
||||
export type BackgroundWorkKind =
|
||||
| 'deployment_side_effects'
|
||||
| 'fork_content_copy'
|
||||
| 'fork_sync'
|
||||
| 'fork_rollback'
|
||||
export type BackgroundWorkStatusValue =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'completed_with_warnings'
|
||||
| 'failed'
|
||||
|
||||
/** Statuses surfaced as recent jobs - active, completed, or a recent issue. */
|
||||
const SURFACED_STATUSES: BackgroundWorkStatusValue[] = [
|
||||
'pending',
|
||||
'processing',
|
||||
'completed',
|
||||
'completed_with_warnings',
|
||||
'failed',
|
||||
]
|
||||
|
||||
/** Default page size for the workspace's Activity tab (mirrors the audit log's). */
|
||||
const BACKGROUND_WORK_PAGE_SIZE = 50
|
||||
|
||||
/** Server-side cap on the requested page size (mirrors the audit log's). */
|
||||
const BACKGROUND_WORK_PAGE_SIZE_MAX = 100
|
||||
|
||||
/**
|
||||
* An active (pending/processing) row older than this is treated as abandoned: the
|
||||
* worker crashed or restarted before {@link finishBackgroundWork}, and a hard crash
|
||||
* (Trigger CRASHED / process death) fires no in-task hook. The outbox-processor cron
|
||||
* sweeps these via {@link reapStaleBackgroundWork}, marking them `failed` so the UI
|
||||
* surfaces the failure instead of spinning forever.
|
||||
*/
|
||||
const STALE_ACTIVE_MS = 30 * 60 * 1000
|
||||
|
||||
/** Terminal rows older than this are pruned by the cron so the audit trail stays bounded. */
|
||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
export interface BackgroundWorkRow {
|
||||
id: string
|
||||
workspaceId: string
|
||||
workflowId: string | null
|
||||
kind: BackgroundWorkKind
|
||||
status: BackgroundWorkStatusValue
|
||||
message: string | null
|
||||
error: string | null
|
||||
metadata: unknown
|
||||
startedAt: Date
|
||||
completedAt: Date | null
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin tracking a unit of background work, returning its id. Any prior row for the
|
||||
* same scope (workspace + workflow + kind) is removed first so exactly one status per
|
||||
* scope is live - a fresh run supersedes a stale completed/failed one.
|
||||
*/
|
||||
export async function startBackgroundWork(
|
||||
executor: DbOrTx,
|
||||
params: {
|
||||
workspaceId: string
|
||||
workflowId?: string | null
|
||||
kind: BackgroundWorkKind
|
||||
message?: string
|
||||
metadata?: unknown
|
||||
/**
|
||||
* Replace any prior row for the same scope (workspace + workflow + kind) so exactly
|
||||
* one status per scope is live (default). Pass `false` to keep an append-only audit
|
||||
* trail - e.g. fork jobs, where each fork is a distinct historical entry.
|
||||
*/
|
||||
supersede?: boolean
|
||||
}
|
||||
): Promise<string> {
|
||||
const { workspaceId, workflowId = null, kind, message, metadata, supersede = true } = params
|
||||
if (supersede) {
|
||||
await executor
|
||||
.delete(backgroundWorkStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(backgroundWorkStatus.workspaceId, workspaceId),
|
||||
eq(backgroundWorkStatus.kind, kind),
|
||||
workflowId == null
|
||||
? isNull(backgroundWorkStatus.workflowId)
|
||||
: eq(backgroundWorkStatus.workflowId, workflowId)
|
||||
)
|
||||
)
|
||||
}
|
||||
const id = generateId()
|
||||
const now = new Date()
|
||||
await executor.insert(backgroundWorkStatus).values({
|
||||
id,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
kind,
|
||||
status: 'processing',
|
||||
message: message ?? null,
|
||||
metadata: metadata ?? null,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a synchronous operation directly as a terminal audit entry (no `processing`
|
||||
* phase). Append-only - used by sync/rollback, which complete in-request, so they show
|
||||
* up in the same workspace audit log as fork jobs.
|
||||
*/
|
||||
export async function recordBackgroundWork(
|
||||
executor: DbOrTx,
|
||||
params: {
|
||||
workspaceId: string
|
||||
kind: BackgroundWorkKind
|
||||
status: Extract<BackgroundWorkStatusValue, 'completed' | 'completed_with_warnings' | 'failed'>
|
||||
message?: string
|
||||
error?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date()
|
||||
await executor.insert(backgroundWorkStatus).values({
|
||||
id: generateId(),
|
||||
workspaceId: params.workspaceId,
|
||||
workflowId: null,
|
||||
kind: params.kind,
|
||||
status: params.status,
|
||||
message: params.message ?? null,
|
||||
error: params.error ?? null,
|
||||
metadata: params.metadata ?? null,
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
/** Mark a tracked unit of work terminal (completed / completed_with_warnings / failed). */
|
||||
export async function finishBackgroundWork(
|
||||
executor: DbOrTx,
|
||||
id: string,
|
||||
params: {
|
||||
status: Extract<BackgroundWorkStatusValue, 'completed' | 'completed_with_warnings' | 'failed'>
|
||||
message?: string
|
||||
error?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date()
|
||||
// Merge metadata so terminal counts (copied/failed) augment what start recorded
|
||||
// (child name, per-kind plan) instead of replacing it.
|
||||
let metadata: unknown
|
||||
if (params.metadata !== undefined) {
|
||||
const [existing] = await executor
|
||||
.select({ metadata: backgroundWorkStatus.metadata })
|
||||
.from(backgroundWorkStatus)
|
||||
.where(eq(backgroundWorkStatus.id, id))
|
||||
.limit(1)
|
||||
metadata = { ...toMetadataRecord(existing?.metadata), ...toMetadataRecord(params.metadata) }
|
||||
}
|
||||
await executor
|
||||
.update(backgroundWorkStatus)
|
||||
.set({
|
||||
status: params.status,
|
||||
message: params.message ?? null,
|
||||
error: params.error ?? null,
|
||||
...(params.metadata !== undefined ? { metadata } : {}),
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(backgroundWorkStatus.id, id))
|
||||
}
|
||||
|
||||
/** Coerce an unknown jsonb metadata value to a plain record for safe merging. */
|
||||
function toMetadataRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
/** Keyset position of the last row of a page: `updatedAt` plus the `id` tiebreaker. */
|
||||
interface BackgroundWorkCursorData {
|
||||
updatedAt: string
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Encodes the keyset position as an opaque base64 cursor (mirrors the audit log's). */
|
||||
function encodeCursor(data: BackgroundWorkCursorData): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): BackgroundWorkCursorData | null {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyset condition for rows strictly after the cursor position in
|
||||
* `updatedAt DESC, id DESC` order: older `updatedAt`, or the same `updatedAt`
|
||||
* (which is not unique) with a smaller `id`. Null for an invalid cursor, which
|
||||
* degrades to the first page rather than erroring.
|
||||
*/
|
||||
function buildCursorCondition(cursor: string): SQL<unknown> | null {
|
||||
const cursorData = decodeCursor(cursor)
|
||||
if (!cursorData?.updatedAt || !cursorData.id) return null
|
||||
|
||||
const cursorDate = new Date(cursorData.updatedAt)
|
||||
if (Number.isNaN(cursorDate.getTime())) return null
|
||||
|
||||
return or(
|
||||
lt(backgroundWorkStatus.updatedAt, cursorDate),
|
||||
and(eq(backgroundWorkStatus.updatedAt, cursorDate), lt(backgroundWorkStatus.id, cursorData.id))
|
||||
)!
|
||||
}
|
||||
|
||||
export interface BackgroundWorkPage {
|
||||
rows: BackgroundWorkRow[]
|
||||
/** Cursor for the next page; null when this page is the last. */
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent background-work jobs involving a workspace - the durable audit record the
|
||||
* Activity view renders, most-recent first and keyset-paginated (`updatedAt DESC,
|
||||
* id DESC`, cursor + limit mirroring the audit log's `queryAuditLogs`). Fork jobs
|
||||
* are append-only (one row per fork), so this is the workspace's fork history;
|
||||
* older rows are pruned by the cron.
|
||||
*
|
||||
* Every fork event is recorded ONCE, keyed to the workspace it was initiated from
|
||||
* (fork-create → the parent; sync/rollback/sync-copy → the workspace whose page ran
|
||||
* it), so "involving" matches both sides of each edge without double-writing rows:
|
||||
*
|
||||
* - rows keyed to this workspace (its own forks, syncs it ran, its rollbacks);
|
||||
* - rows whose `metadata.childWorkspaceId` is this workspace (its own creation,
|
||||
* recorded on the parent);
|
||||
* - rows whose `metadata.otherWorkspaceId` is this workspace (the other side of a
|
||||
* sync/rollback/sync-copy edge);
|
||||
* - sync/rollback rows keyed to one of this workspace's forks - a fork's only sync
|
||||
* edge is its parent, so these are guaranteed edge events (covers rows written
|
||||
* before `metadata.otherWorkspaceId` existed).
|
||||
*/
|
||||
export async function listSurfacedBackgroundWork(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string,
|
||||
options?: { cursor?: string; limit?: number }
|
||||
): Promise<BackgroundWorkPage> {
|
||||
const limit = Math.min(
|
||||
Math.max(options?.limit ?? BACKGROUND_WORK_PAGE_SIZE, 1),
|
||||
BACKGROUND_WORK_PAGE_SIZE_MAX
|
||||
)
|
||||
|
||||
const childRows = await executor
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.forkedFromWorkspaceId, workspaceId), isNull(workspace.archivedAt)))
|
||||
const childWorkspaceIds = childRows.map((row) => row.id)
|
||||
|
||||
const involvesWorkspace = or(
|
||||
eq(backgroundWorkStatus.workspaceId, workspaceId),
|
||||
sql`${backgroundWorkStatus.metadata} ->> 'childWorkspaceId' = ${workspaceId}`,
|
||||
sql`${backgroundWorkStatus.metadata} ->> 'otherWorkspaceId' = ${workspaceId}`,
|
||||
...(childWorkspaceIds.length > 0
|
||||
? [
|
||||
and(
|
||||
inArray(backgroundWorkStatus.workspaceId, childWorkspaceIds),
|
||||
inArray(backgroundWorkStatus.kind, ['fork_sync', 'fork_rollback'])
|
||||
),
|
||||
]
|
||||
: [])
|
||||
)
|
||||
|
||||
const conditions = [involvesWorkspace, inArray(backgroundWorkStatus.status, SURFACED_STATUSES)]
|
||||
if (options?.cursor) {
|
||||
const cursorCondition = buildCursorCondition(options.cursor)
|
||||
if (cursorCondition) conditions.push(cursorCondition)
|
||||
}
|
||||
|
||||
// Over-fetch by one row to learn whether another page exists (audit-log pattern).
|
||||
const rows = (await executor
|
||||
.select()
|
||||
.from(backgroundWorkStatus)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(backgroundWorkStatus.updatedAt), desc(backgroundWorkStatus.id))
|
||||
.limit(limit + 1)) as BackgroundWorkRow[]
|
||||
|
||||
const hasMore = rows.length > limit
|
||||
const page = rows.slice(0, limit)
|
||||
const last = page[page.length - 1]
|
||||
const nextCursor =
|
||||
hasMore && last ? encodeCursor({ updatedAt: last.updatedAt.toISOString(), id: last.id }) : null
|
||||
return { rows: page, nextCursor }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail background-work rows stuck in an active state past {@link STALE_ACTIVE_MS}: the
|
||||
* worker crashed or restarted before writing a terminal status, and a hard crash has
|
||||
* no in-task hook to recover from. Marks them `failed` so the UI shows the failure
|
||||
* rather than an indefinitely-spinning banner. Touches ONLY `background_work_status`.
|
||||
* Returns the count reaped; invoked from the outbox-processor cron.
|
||||
*/
|
||||
export async function reapStaleBackgroundWork(executor: DbOrTx): Promise<number> {
|
||||
const now = new Date()
|
||||
const cutoff = new Date(now.getTime() - STALE_ACTIVE_MS)
|
||||
const reaped = await executor
|
||||
.update(backgroundWorkStatus)
|
||||
.set({
|
||||
status: 'failed',
|
||||
error: 'Background work did not finish in time (the worker may have restarted).',
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(backgroundWorkStatus.status, ['pending', 'processing']),
|
||||
lte(backgroundWorkStatus.startedAt, cutoff)
|
||||
)
|
||||
)
|
||||
.returning({ id: backgroundWorkStatus.id })
|
||||
|
||||
// Retention: the append-only fork audit trail would otherwise grow forever, so drop
|
||||
// terminal rows past the retention window. The Activity tab paginates separately.
|
||||
await executor
|
||||
.delete(backgroundWorkStatus)
|
||||
.where(
|
||||
and(
|
||||
inArray(backgroundWorkStatus.status, ['completed', 'completed_with_warnings', 'failed']),
|
||||
lte(backgroundWorkStatus.updatedAt, new Date(now.getTime() - RETENTION_MS))
|
||||
)
|
||||
)
|
||||
|
||||
if (reaped.length > 0) {
|
||||
logger.warn('Reaped stale background-work rows', {
|
||||
count: reaped.length,
|
||||
thresholdMs: STALE_ACTIVE_MS,
|
||||
})
|
||||
}
|
||||
return reaped.length
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const dbMock = vi.hoisted(() => {
|
||||
const reads = new Map<unknown, unknown[][]>()
|
||||
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = []
|
||||
const deletes: Array<{ table: unknown }> = []
|
||||
|
||||
const nextPage = (table: unknown): unknown[] => {
|
||||
const pages = reads.get(table)
|
||||
return pages && pages.length > 0 ? (pages.shift() as unknown[]) : []
|
||||
}
|
||||
|
||||
// A drizzle-style read builder bound to one table: `.where`/`.orderBy`/`.limit` chain back to
|
||||
// the same builder, and awaiting it (at `.where()` or `.limit()`) shifts that table's next page.
|
||||
const makeReadBuilder = (table: unknown) => {
|
||||
const builder = {
|
||||
where: () => builder,
|
||||
orderBy: () => builder,
|
||||
limit: () => builder,
|
||||
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (error: unknown) => unknown) =>
|
||||
Promise.resolve(nextPage(table)).then(onFulfilled, onRejected),
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
const db = {
|
||||
select: () => ({ from: (table: unknown) => makeReadBuilder(table) }),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
updates.push({ table, values })
|
||||
return Promise.resolve([])
|
||||
},
|
||||
}),
|
||||
}),
|
||||
delete: (table: unknown) => ({
|
||||
where: () => {
|
||||
deletes.push({ table })
|
||||
return Promise.resolve([])
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
db,
|
||||
updates,
|
||||
deletes,
|
||||
queueRead: (table: unknown, ...pages: unknown[][]) => reads.set(table, pages),
|
||||
reset: () => {
|
||||
reads.clear()
|
||||
updates.length = 0
|
||||
deletes.length = 0
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const { mockInvalidateDeployedStateCache } = vi.hoisted(() => ({
|
||||
mockInvalidateDeployedStateCache: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: dbMock.db,
|
||||
dbReplica: dbMock.db,
|
||||
runOutsideTransactionContext: <T>(fn: () => T): T => fn(),
|
||||
instrumentPoolClient: <T>(client: T): T => client,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
invalidateDeployedStateCache: mockInvalidateDeployedStateCache,
|
||||
CREDENTIAL_SUBBLOCK_IDS: new Set(['credential', 'manualCredential', 'triggerCredentials']),
|
||||
}))
|
||||
|
||||
// The reference indexer resolves a tool's params via the tool registry; stub it so loading the
|
||||
// remap module never pulls the full registry (this file only exercises top-level selectors).
|
||||
vi.mock('@/tools/params', () => ({
|
||||
getToolIdForOperation: () => undefined,
|
||||
getToolParametersConfig: () => null,
|
||||
getSubBlocksForToolInput: (
|
||||
_toolId: string,
|
||||
_type: string,
|
||||
_values: unknown,
|
||||
_modes: unknown,
|
||||
provided?: { subBlocks?: SubBlockConfig[] }
|
||||
) => ({ subBlocks: provided?.subBlocks ?? [] }),
|
||||
formatParameterLabel: (label: string) => label,
|
||||
}))
|
||||
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import {
|
||||
clearFailedForkResourceReferences,
|
||||
clearFailedReferencesInDeploymentVersions,
|
||||
clearFailedReferencesInWorkflows,
|
||||
rewriteDeploymentVersionState,
|
||||
} from '@/ee/workspace-forking/lib/copy/cleanup-failed'
|
||||
import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
|
||||
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
|
||||
({ name: 'Knowledge', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
|
||||
|
||||
/** A KB block whose `documentId` (document-selector) hangs off `knowledgeBaseId` (kb-selector). */
|
||||
const kbBlockConfig = () =>
|
||||
blockWith([
|
||||
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
||||
{ id: 'documentId', title: 'Doc', type: 'document-selector', dependsOn: ['knowledgeBaseId'] },
|
||||
])
|
||||
|
||||
/** An agent block whose `tools` tool-input holds a KB tool with a nested `knowledgeBaseId` param. */
|
||||
const agentToolConfig = (type: string): BlockConfig => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'kbtool')
|
||||
return blockWith([{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }])
|
||||
return undefined as unknown as BlockConfig
|
||||
}
|
||||
|
||||
/** A deployment-version state whose agent block references `kbId` inside a tool-input tool param. */
|
||||
const agentVersionState = (kbId: string) => ({
|
||||
blocks: {
|
||||
'agent-1': {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
subBlocks: {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [{ type: 'kbtool', toolId: 'kbtool_search', params: { knowledgeBaseId: kbId } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
})
|
||||
|
||||
type AgentStateBlocks = {
|
||||
blocks: {
|
||||
'agent-1': { subBlocks: { tools: { value: Array<{ params: { knowledgeBaseId: string } }> } } }
|
||||
}
|
||||
}
|
||||
const nestedKbValue = (state: unknown) =>
|
||||
(state as AgentStateBlocks).blocks['agent-1'].subBlocks.tools.value[0].params.knowledgeBaseId
|
||||
|
||||
/** A serialized deployment-version state whose single block points its KB selector at `kbId`. */
|
||||
const versionState = (kbId: string) => ({
|
||||
blocks: {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'knowledge',
|
||||
name: 'KB Block',
|
||||
subBlocks: {
|
||||
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: kbId },
|
||||
documentId: { id: 'documentId', type: 'document-selector', value: 'doc-keep' },
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
})
|
||||
|
||||
const draftBlockRow = (kbId: string) => ({
|
||||
id: 'b-1',
|
||||
workflowId: 'wf-1',
|
||||
type: 'knowledge',
|
||||
subBlocks: {
|
||||
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: kbId },
|
||||
documentId: { id: 'documentId', type: 'document-selector', value: 'doc-keep' },
|
||||
},
|
||||
})
|
||||
|
||||
/** A draft block whose `file-upload` subblock points at a copied workspace-file storage key. */
|
||||
const fileBlockRow = (fileKey: string) => ({
|
||||
id: 'b-file',
|
||||
workflowId: 'wf-1',
|
||||
type: 'agent',
|
||||
subBlocks: {
|
||||
file: { id: 'file', type: 'file-upload', value: { key: fileKey, name: 'a.png' } },
|
||||
},
|
||||
})
|
||||
|
||||
const failedKbResolver: ForkCopyResolver = (kind, id) =>
|
||||
kind === 'knowledge-base' && id === 'failed-kb' ? null : id
|
||||
|
||||
const failedByKind = () =>
|
||||
new Map<ForkRemapKind, Set<string>>([['knowledge-base', new Set(['failed-kb'])]])
|
||||
|
||||
type StateBlocks = { blocks: Record<string, { subBlocks: Record<string, { value: unknown }> }> }
|
||||
const kbValue = (state: unknown) =>
|
||||
(state as StateBlocks).blocks['block-1'].subBlocks.knowledgeBaseId.value
|
||||
const docValue = (state: unknown) =>
|
||||
(state as StateBlocks).blocks['block-1'].subBlocks.documentId.value
|
||||
|
||||
describe('cleanup-failed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
dbMock.reset()
|
||||
vi.mocked(getBlock).mockReturnValue(kbBlockConfig())
|
||||
})
|
||||
|
||||
describe('rewriteDeploymentVersionState', () => {
|
||||
it('clears a block ref that resolves to a failed id and its dependents', () => {
|
||||
const result = rewriteDeploymentVersionState(versionState('failed-kb'), failedKbResolver)
|
||||
expect(result.changed).toBe(true)
|
||||
expect(kbValue(result.state)).toBe('')
|
||||
// documentId hangs off knowledgeBaseId, so the cleared parent clears it too.
|
||||
expect(docValue(result.state)).toBe('')
|
||||
})
|
||||
|
||||
it('leaves a state that references no failed id untouched (same reference, not changed)', () => {
|
||||
const input = versionState('other-kb')
|
||||
const result = rewriteDeploymentVersionState(input, failedKbResolver)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.state).toBe(input)
|
||||
})
|
||||
|
||||
it('is tolerant of a malformed state shape', () => {
|
||||
const input = { not: 'a workflow state' }
|
||||
const result = rewriteDeploymentVersionState(input, failedKbResolver)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.state).toBe(input)
|
||||
})
|
||||
|
||||
// The deployed-version sweep must clear EVERY subblock variety the draft sweep does -
|
||||
// including a failed id nested in an agent block's `tool-input` tool params, not only
|
||||
// top-level selectors - via the shared remapForkSubBlocks/clearFailedSubBlockReferences.
|
||||
it('clears a failed id nested in an agent tool-input param inside a deployed version', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => agentToolConfig(type))
|
||||
const result = rewriteDeploymentVersionState(agentVersionState('failed-kb'), failedKbResolver)
|
||||
expect(result.changed).toBe(true)
|
||||
expect(nestedKbValue(result.state)).toBe('')
|
||||
})
|
||||
|
||||
it('leaves an agent tool-input param that references no failed id untouched', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => agentToolConfig(type))
|
||||
const input = agentVersionState('other-kb')
|
||||
const result = rewriteDeploymentVersionState(input, failedKbResolver)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.state).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearFailedReferencesInWorkflows', () => {
|
||||
it('sweeps the draft blocks and returns the affected workflow ids', async () => {
|
||||
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
|
||||
dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')])
|
||||
|
||||
const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test')
|
||||
|
||||
expect([...affected]).toEqual(['wf-1'])
|
||||
expect(dbMock.updates).toHaveLength(1)
|
||||
expect(dbMock.updates[0].table).toBe(workflowBlocks)
|
||||
const cleared = dbMock.updates[0].values.subBlocks as Record<string, { value: unknown }>
|
||||
expect(cleared.knowledgeBaseId.value).toBe('')
|
||||
expect(cleared.documentId.value).toBe('')
|
||||
})
|
||||
|
||||
it('returns an empty set and writes nothing when no block references a failed id', async () => {
|
||||
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
|
||||
dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')])
|
||||
|
||||
const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test')
|
||||
|
||||
expect(affected.size).toBe(0)
|
||||
expect(dbMock.updates).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearFailedReferencesInDeploymentVersions', () => {
|
||||
it('rewrites a version referencing a failed id and invalidates its deployed-state cache', async () => {
|
||||
dbMock.queueRead(workflowDeploymentVersion, [
|
||||
{ id: 'dv-1', version: 5, state: versionState('failed-kb') },
|
||||
])
|
||||
|
||||
await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test')
|
||||
|
||||
expect(dbMock.updates).toHaveLength(1)
|
||||
expect(dbMock.updates[0].table).toBe(workflowDeploymentVersion)
|
||||
expect(kbValue(dbMock.updates[0].values.state)).toBe('')
|
||||
expect(docValue(dbMock.updates[0].values.state)).toBe('')
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1')
|
||||
})
|
||||
|
||||
it('leaves a version that does not reference a failed id unwritten and uncached', async () => {
|
||||
dbMock.queueRead(workflowDeploymentVersion, [
|
||||
{ id: 'dv-old', version: 3, state: versionState('other-kb') },
|
||||
])
|
||||
|
||||
await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test')
|
||||
|
||||
expect(dbMock.updates).toHaveLength(0)
|
||||
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes only the changed version when a workflow mixes referencing and non-referencing versions', async () => {
|
||||
dbMock.queueRead(workflowDeploymentVersion, [
|
||||
{ id: 'dv-active', version: 5, state: versionState('failed-kb') },
|
||||
{ id: 'dv-old', version: 4, state: versionState('other-kb') },
|
||||
])
|
||||
|
||||
await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test')
|
||||
|
||||
expect(dbMock.updates).toHaveLength(1)
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active')
|
||||
})
|
||||
|
||||
it('does nothing when no workflows were affected', async () => {
|
||||
await clearFailedReferencesInDeploymentVersions(new Set(), failedByKind(), 'test')
|
||||
expect(dbMock.updates).toHaveLength(0)
|
||||
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearFailedForkResourceReferences', () => {
|
||||
it('threads the draft sweep into the deployed sweep, then drops the placeholder', async () => {
|
||||
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
|
||||
dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')])
|
||||
dbMock.queueRead(workflowDeploymentVersion, [
|
||||
{ id: 'dv-active', version: 5, state: versionState('failed-kb') },
|
||||
{ id: 'dv-old', version: 4, state: versionState('other-kb') },
|
||||
])
|
||||
|
||||
const cleaned = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
|
||||
// One draft block update + one deployed version update (only the referencing version).
|
||||
const updatedTables = dbMock.updates.map((u) => u.table)
|
||||
expect(updatedTables).toEqual([workflowBlocks, workflowDeploymentVersion])
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active')
|
||||
// The orphaned KB placeholder is dropped after both sweeps.
|
||||
expect(dbMock.deletes).toHaveLength(1)
|
||||
expect(dbMock.deletes[0].table).toBe(knowledgeBase)
|
||||
})
|
||||
|
||||
it('still drops the placeholder when no workflow referenced the failed resource', async () => {
|
||||
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
|
||||
dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')])
|
||||
|
||||
const cleaned = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
|
||||
// No draft block referenced the failed id AND no deployed targets were threaded, so the
|
||||
// deployed sweep is skipped entirely.
|
||||
expect(dbMock.updates).toHaveLength(0)
|
||||
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
|
||||
expect(dbMock.deletes).toHaveLength(1)
|
||||
expect(dbMock.deletes[0].table).toBe(knowledgeBase)
|
||||
})
|
||||
|
||||
it('sweeps a deployed target version even when no draft referenced the failed id', async () => {
|
||||
// Draft is clean (other-kb), but a deployed target version still points at the dropped
|
||||
// placeholder - the deployed-target scope (not draft divergence) catches it.
|
||||
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
|
||||
dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')])
|
||||
dbMock.queueRead(workflowDeploymentVersion, [
|
||||
{ id: 'dv-1', version: 5, state: versionState('failed-kb') },
|
||||
])
|
||||
|
||||
const cleaned = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
|
||||
deployedTargetWorkflowIds: ['wf-deployed'],
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
|
||||
expect(dbMock.updates.map((u) => u.table)).toContain(workflowDeploymentVersion)
|
||||
expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1')
|
||||
// Clearing succeeded, so the placeholder is dropped.
|
||||
expect(dbMock.deletes[0].table).toBe(knowledgeBase)
|
||||
})
|
||||
|
||||
it('clears a file-upload reference to a failed copied blob and drops no row', async () => {
|
||||
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
|
||||
dbMock.queueRead(workflowBlocks, [fileBlockRow('workspace/child/failed.png')])
|
||||
|
||||
const cleaned = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [{ kind: 'file', childKey: 'workspace/child/failed.png' }],
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
|
||||
expect(dbMock.updates).toHaveLength(1)
|
||||
expect(dbMock.updates[0].table).toBe(workflowBlocks)
|
||||
const cleared = dbMock.updates[0].values.subBlocks as Record<string, { value: unknown }>
|
||||
expect(cleared.file.value).toBe('')
|
||||
// A failed file has no placeholder row to drop (the metadata row stays re-uploadable).
|
||||
expect(dbMock.deletes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports cleared:0 + clearingFailed and skips the placeholder drop when a clear phase throws', async () => {
|
||||
// A clear-phase failure must not drop the placeholder: that would turn an empty placeholder
|
||||
// into a dangling reference to a deleted row. Make the draft block UPDATE throw.
|
||||
dbMock.queueRead(workflow, [{ id: 'wf-1' }])
|
||||
dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')])
|
||||
const originalUpdate = dbMock.db.update
|
||||
dbMock.db.update = () => {
|
||||
throw new Error('update failed')
|
||||
}
|
||||
try {
|
||||
const cleaned = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: 'child-ws',
|
||||
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
|
||||
requestId: 'test',
|
||||
})
|
||||
// The count must NOT overstate: nothing was cleared and the flag marks cleanup incomplete.
|
||||
expect(cleaned).toEqual({ cleared: 0, clearingFailed: true })
|
||||
} finally {
|
||||
dbMock.db.update = originalUpdate
|
||||
}
|
||||
// The drop is skipped, so the placeholder row survives (no delete issued).
|
||||
expect(dbMock.deletes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,364 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
document,
|
||||
knowledgeBase,
|
||||
userTableDefinitions,
|
||||
workflow,
|
||||
workflowBlocks,
|
||||
workflowDeploymentVersion,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, asc, eq, gt, inArray } from 'drizzle-orm'
|
||||
import { isRecord, type SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { invalidateDeployedStateCache } from '@/lib/workflows/persistence/utils'
|
||||
import type { ForkFailedResource } from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
|
||||
import {
|
||||
clearDependentsOnRemap,
|
||||
type ForkRemapKind,
|
||||
remapForkSubBlocks,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCleanupFailed')
|
||||
|
||||
/** Child workflow ids loaded per page so the block sweep never materializes the whole workspace. */
|
||||
const WORKFLOW_PAGE = 200
|
||||
|
||||
/** Deployment versions loaded per page so a workflow with many versions never loads all at once. */
|
||||
const DEPLOYMENT_VERSION_PAGE = 100
|
||||
|
||||
/** Identity-or-clear resolver: a failed id resolves to null (cleared), any other id to itself. */
|
||||
function buildFailedResolver(failedByKind: Map<ForkRemapKind, Set<string>>): ForkCopyResolver {
|
||||
return (kind, id) => (failedByKind.get(kind)?.has(id) ? null : id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the identity-or-clear resolver to one block's subBlocks: a value (top-level selector or
|
||||
* nested tool param) that resolves to a failed id is cleared, and its `dependsOn` children with it;
|
||||
* everything else is left untouched. Returns the rewritten record plus whether anything changed.
|
||||
* Shared by the draft block sweep and the deployment-version state sweep so both clear identically.
|
||||
*/
|
||||
function clearFailedSubBlockReferences(
|
||||
subBlocks: SubBlockRecord,
|
||||
blockType: string,
|
||||
resolve: ForkCopyResolver
|
||||
): { subBlocks: SubBlockRecord; changed: boolean } {
|
||||
const result = remapForkSubBlocks(subBlocks, resolve, 'create')
|
||||
// remappedKeys is non-empty only when a failed id was actually cleared, so a block that
|
||||
// referenced nothing failed is reported unchanged without a write.
|
||||
if (result.remappedKeys.size === 0) return { subBlocks, changed: false }
|
||||
return {
|
||||
subBlocks: clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys),
|
||||
changed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up after a resource whose post-commit content fill failed: clear every subblock
|
||||
* reference in the child workspace's workflows that points at the failed resource (so no
|
||||
* subblock keeps a dead id), then drop the orphaned placeholder rows (a KB cascade-drops its
|
||||
* documents + embeddings; a table cascade-drops its rows). In-content references inside copied
|
||||
* skill/markdown bodies are intentionally left as graceful broken links rather than mutated.
|
||||
*
|
||||
* The deployed-version sweep covers the draft-affected workflows UNION this sync's deployed
|
||||
* target workflows (`deployedTargetWorkflowIds`): a deployed version can reference the dropped
|
||||
* placeholder even when the draft no longer does (the user edited the empty-looking block in the
|
||||
* fill window), so scoping to draft divergence alone would miss it.
|
||||
*
|
||||
* Best-effort and isolated: a failure cleaning one resource is logged and the rest continue, so
|
||||
* a cleanup error never aborts the others. The placeholder drop is SKIPPED when a reference-clear
|
||||
* phase threw - dropping it then would turn an empty placeholder into a dangling reference to a
|
||||
* deleted row; leaving it keeps the reference resolvable (to empty content) until a later retry.
|
||||
*
|
||||
* Returns `{ cleared, clearingFailed }` for the fork activity metadata. `clearingFailed` is true
|
||||
* when a reference-clear phase threw - placeholders were then NOT dropped - and `cleared` is 0 in
|
||||
* that case, so the report never claims references it did not actually clear. On success `cleared`
|
||||
* is the count of failed resources whose references were cleared.
|
||||
*
|
||||
* Storage accounting: this cleanup never decrements storage usage because it never removes
|
||||
* anything that was counted. Copied file blobs are the only counted copies (incremented in
|
||||
* `executeForkFileBlobCopies` only after the blob lands), and a failed file's blob never
|
||||
* landed - its metadata row is intentionally left re-uploadable, and nothing was charged. The
|
||||
* dropped table/KB/document placeholders are DB rows the upload path never counts, and any KB
|
||||
* blobs copied before their KB failed are left in storage (rows only are dropped here) but
|
||||
* uncounted - mirroring the KB upload path, which never counts KB blobs.
|
||||
*/
|
||||
export async function clearFailedForkResourceReferences(params: {
|
||||
childWorkspaceId: string
|
||||
failures: ForkFailedResource[]
|
||||
/** Target workflows this sync deployed; their deployed versions are swept regardless of draft. */
|
||||
deployedTargetWorkflowIds?: string[]
|
||||
requestId?: string
|
||||
}): Promise<{ cleared: number; clearingFailed: boolean }> {
|
||||
const { childWorkspaceId, failures, requestId = 'unknown' } = params
|
||||
if (failures.length === 0) return { cleared: 0, clearingFailed: false }
|
||||
|
||||
const failedByKind = new Map<ForkRemapKind, Set<string>>()
|
||||
const markFailed = (kind: ForkRemapKind, id: string) => {
|
||||
const set = failedByKind.get(kind)
|
||||
if (set) set.add(id)
|
||||
else failedByKind.set(kind, new Set([id]))
|
||||
}
|
||||
const tableIds: string[] = []
|
||||
const kbIds: string[] = []
|
||||
// Standalone documents copied into an already-existing target KB (the doc-into-mapped-KB sync
|
||||
// path) - dropped individually, since their KB is not ours to remove.
|
||||
const docIds: string[] = []
|
||||
for (const failure of failures) {
|
||||
if (failure.kind === 'table') {
|
||||
markFailed('table', failure.childId)
|
||||
tableIds.push(failure.childId)
|
||||
} else if (failure.kind === 'knowledge-document') {
|
||||
markFailed('knowledge-document', failure.childId)
|
||||
docIds.push(failure.childId)
|
||||
} else if (failure.kind === 'file') {
|
||||
// A failed file blob: clear `file-upload` references to its copied storage key. No row to
|
||||
// drop - the metadata row is left in place so the user can re-upload the missing blob.
|
||||
markFailed('file', failure.childKey)
|
||||
} else {
|
||||
markFailed('knowledge-base', failure.childId)
|
||||
for (const docId of failure.documentChildIds) markFailed('knowledge-document', docId)
|
||||
kbIds.push(failure.childId)
|
||||
}
|
||||
}
|
||||
|
||||
// Whether BOTH reference-clear phases completed without throwing. The placeholder drop below is
|
||||
// gated on this: if clearing threw, a workflow (draft or deployed version) may still reference
|
||||
// the failed id, so dropping its placeholder would create a dangling reference to a deleted row.
|
||||
let clearingSucceeded = true
|
||||
|
||||
let affectedWorkflowIds: Set<string> = new Set()
|
||||
try {
|
||||
affectedWorkflowIds = await clearFailedReferencesInWorkflows(
|
||||
childWorkspaceId,
|
||||
failedByKind,
|
||||
requestId
|
||||
)
|
||||
} catch (error) {
|
||||
clearingSucceeded = false
|
||||
logger.error(`[${requestId}] Failed to clear references for failed fork resources`, {
|
||||
childWorkspaceId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
// The same dead id also lives in DEPLOYED version states (the active one re-cut from the
|
||||
// placeholder-referencing draft at this sync's redeploy, plus any newer version from a rare
|
||||
// race), not only the draft just swept above. Sweep those too so "Load deployment" can't
|
||||
// re-poison the cleaned draft with a dropped id, and the execute path never runs against content
|
||||
// that no longer exists. Scope is the draft-affected workflows UNION this sync's deployed target
|
||||
// workflows: a deployed version may reference the placeholder even when the draft no longer does
|
||||
// (edited in the fill window), so draft divergence alone is not a reliable scope. Isolated: a
|
||||
// failure here never aborts a sibling resource's cleanup.
|
||||
const deployedSweepIds = new Set(affectedWorkflowIds)
|
||||
for (const id of params.deployedTargetWorkflowIds ?? []) deployedSweepIds.add(id)
|
||||
try {
|
||||
await clearFailedReferencesInDeploymentVersions(deployedSweepIds, failedByKind, requestId)
|
||||
} catch (error) {
|
||||
clearingSucceeded = false
|
||||
logger.error(`[${requestId}] Failed to clear references in fork deployment versions`, {
|
||||
childWorkspaceId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
// Drop the orphaned placeholders. The KB delete cascades its documents + embeddings; the
|
||||
// table delete cascades its rows; a standalone document delete cascades its embeddings. Done
|
||||
// after the refs are cleared so a drop failure can't strand a workflow still pointing at the
|
||||
// (now content-less) resource - and ONLY when clearing succeeded, so a clear failure never
|
||||
// turns an empty placeholder into a dangling reference to a deleted row.
|
||||
if (!clearingSucceeded) {
|
||||
logger.warn(
|
||||
`[${requestId}] Skipping fork resource placeholder drop after a reference-clear failure`,
|
||||
{
|
||||
childWorkspaceId,
|
||||
tables: tableIds.length,
|
||||
knowledgeBases: kbIds.length,
|
||||
documents: docIds.length,
|
||||
}
|
||||
)
|
||||
return { cleared: 0, clearingFailed: true }
|
||||
}
|
||||
try {
|
||||
if (tableIds.length > 0) {
|
||||
await db.delete(userTableDefinitions).where(inArray(userTableDefinitions.id, tableIds))
|
||||
}
|
||||
if (kbIds.length > 0) {
|
||||
await db.delete(knowledgeBase).where(inArray(knowledgeBase.id, kbIds))
|
||||
}
|
||||
if (docIds.length > 0) {
|
||||
await db.delete(document).where(inArray(document.id, docIds))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to drop orphaned fork resource placeholders`, {
|
||||
childWorkspaceId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
return { cleared: failures.length, clearingFailed: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep the child workspace's workflow blocks and clear any subblock value (top-level selector
|
||||
* or nested tool param) that resolves to a failed child resource id. Reuses the create-mode
|
||||
* remap with an identity-or-clear resolver: a non-failed id resolves to itself (left unchanged),
|
||||
* a failed id resolves to null and is cleared, and its `dependsOn` children are cleared too.
|
||||
* Returns the ids of the workflows whose blocks actually had a reference cleared, so the deployed
|
||||
* version sweep can scope itself to exactly those workflows.
|
||||
*/
|
||||
export async function clearFailedReferencesInWorkflows(
|
||||
childWorkspaceId: string,
|
||||
failedByKind: Map<ForkRemapKind, Set<string>>,
|
||||
requestId: string
|
||||
): Promise<Set<string>> {
|
||||
const resolve = buildFailedResolver(failedByKind)
|
||||
const affectedWorkflowIds = new Set<string>()
|
||||
|
||||
let afterWorkflowId: string | null = null
|
||||
for (;;) {
|
||||
const workflowRows = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(
|
||||
afterWorkflowId === null
|
||||
? eq(workflow.workspaceId, childWorkspaceId)
|
||||
: and(eq(workflow.workspaceId, childWorkspaceId), gt(workflow.id, afterWorkflowId))
|
||||
)
|
||||
.orderBy(asc(workflow.id))
|
||||
.limit(WORKFLOW_PAGE)
|
||||
if (workflowRows.length === 0) break
|
||||
|
||||
const workflowIds = workflowRows.map((row) => row.id)
|
||||
const blocks = await db
|
||||
.select({
|
||||
id: workflowBlocks.id,
|
||||
workflowId: workflowBlocks.workflowId,
|
||||
type: workflowBlocks.type,
|
||||
subBlocks: workflowBlocks.subBlocks,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(inArray(workflowBlocks.workflowId, workflowIds))
|
||||
|
||||
for (const block of blocks) {
|
||||
const current = (block.subBlocks ?? {}) as SubBlockRecord
|
||||
const { subBlocks: cleared, changed } = clearFailedSubBlockReferences(
|
||||
current,
|
||||
block.type,
|
||||
resolve
|
||||
)
|
||||
if (!changed) continue
|
||||
await db
|
||||
.update(workflowBlocks)
|
||||
.set({ subBlocks: cleared })
|
||||
.where(eq(workflowBlocks.id, block.id))
|
||||
affectedWorkflowIds.add(block.workflowId)
|
||||
}
|
||||
|
||||
if (workflowRows.length < WORKFLOW_PAGE) break
|
||||
afterWorkflowId = workflowIds[workflowIds.length - 1]
|
||||
}
|
||||
|
||||
return affectedWorkflowIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a deployment version's serialized state in memory, clearing every block subblock that
|
||||
* resolves to a failed id (and its `dependsOn` children). Returns `changed: false` with the
|
||||
* original state when no block referenced a failed id - so a version cut before this sync, which
|
||||
* cannot contain the new placeholder id, is a no-op and never written back. Tolerant of a
|
||||
* malformed/legacy state shape (anything that is not `{ blocks: {...} }` is left untouched).
|
||||
*/
|
||||
export function rewriteDeploymentVersionState(
|
||||
state: unknown,
|
||||
resolve: ForkCopyResolver
|
||||
): { state: unknown; changed: boolean } {
|
||||
if (!isRecord(state) || !isRecord(state.blocks)) return { state, changed: false }
|
||||
|
||||
let nextBlocks: Record<string, unknown> | null = null
|
||||
for (const [blockId, block] of Object.entries(state.blocks)) {
|
||||
if (!isRecord(block)) continue
|
||||
const blockType = typeof block.type === 'string' ? block.type : undefined
|
||||
if (!blockType || !isRecord(block.subBlocks)) continue
|
||||
const { subBlocks: cleared, changed } = clearFailedSubBlockReferences(
|
||||
block.subBlocks as SubBlockRecord,
|
||||
blockType,
|
||||
resolve
|
||||
)
|
||||
if (!changed) continue
|
||||
nextBlocks ??= { ...state.blocks }
|
||||
nextBlocks[blockId] = { ...block, subBlocks: cleared }
|
||||
}
|
||||
|
||||
if (!nextBlocks) return { state, changed: false }
|
||||
return { state: { ...state, blocks: nextBlocks }, changed: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the DEPLOYED version states of the workflows whose draft blocks were just swept. The dead
|
||||
* placeholder id lives in any version re-cut from the (placeholder-referencing) draft at this sync's
|
||||
* redeploy - in practice the active one - so it must be cleared there too, not only in the draft.
|
||||
* Every version of each affected workflow is examined, but only versions whose state actually
|
||||
* changes are written: an older version predates the sync and cannot contain the new id, so it is a
|
||||
* no-op. After a version is rewritten its cached deployed state is evicted so execute/serve rebuilds
|
||||
* from the cleaned snapshot. Bounded work (no long transaction): per-version short UPDATEs, versions
|
||||
* keyset-paginated, and a per-workflow failure is logged without aborting the other workflows.
|
||||
*/
|
||||
export async function clearFailedReferencesInDeploymentVersions(
|
||||
workflowIds: ReadonlySet<string>,
|
||||
failedByKind: Map<ForkRemapKind, Set<string>>,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
if (workflowIds.size === 0) return
|
||||
const resolve = buildFailedResolver(failedByKind)
|
||||
|
||||
for (const workflowId of workflowIds) {
|
||||
try {
|
||||
let afterVersion: number | null = null
|
||||
for (;;) {
|
||||
const versions = await db
|
||||
.select({
|
||||
id: workflowDeploymentVersion.id,
|
||||
version: workflowDeploymentVersion.version,
|
||||
state: workflowDeploymentVersion.state,
|
||||
})
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
afterVersion === null
|
||||
? eq(workflowDeploymentVersion.workflowId, workflowId)
|
||||
: and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
gt(workflowDeploymentVersion.version, afterVersion)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(workflowDeploymentVersion.version))
|
||||
.limit(DEPLOYMENT_VERSION_PAGE)
|
||||
if (versions.length === 0) break
|
||||
|
||||
for (const version of versions) {
|
||||
const { state: nextState, changed } = rewriteDeploymentVersionState(
|
||||
version.state,
|
||||
resolve
|
||||
)
|
||||
if (!changed) continue
|
||||
await db
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ state: nextState })
|
||||
.where(eq(workflowDeploymentVersion.id, version.id))
|
||||
// Evict the post-migration deployed state cached by this immutable version id so the
|
||||
// execute/serve path rebuilds from the cleaned snapshot.
|
||||
invalidateDeployedStateCache(version.id)
|
||||
}
|
||||
|
||||
if (versions.length < DEPLOYMENT_VERSION_PAGE) break
|
||||
afterVersion = versions[versions.length - 1].version
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to clear references in deployment versions`, {
|
||||
workflowId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasForkContentToCopy,
|
||||
serializeContentRefMaps,
|
||||
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
|
||||
import type { BlobCopyTask } from '@/ee/workspace-forking/lib/copy/copy-files'
|
||||
import type { ForkContentPlan } from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
|
||||
describe('serializeContentRefMaps', () => {
|
||||
it('converts each map to a record and drops empty maps', () => {
|
||||
const result = serializeContentRefMaps({
|
||||
workspaceId: { from: 'src', to: 'dst' },
|
||||
fileKeys: new Map([['k1', 'k2']]),
|
||||
fileIds: new Map(),
|
||||
workflows: new Map([['wf-src', 'wf-dst']]),
|
||||
knowledgeBases: new Map([['kb-src', 'kb-dst']]),
|
||||
skills: new Map([['sk-src', 'sk-dst']]),
|
||||
})
|
||||
|
||||
expect(result.workspaceId).toEqual({ from: 'src', to: 'dst' })
|
||||
expect(result.fileKeys).toEqual({ k1: 'k2' })
|
||||
// An empty map is omitted rather than serialized to `{}`.
|
||||
expect(result.fileIds).toBeUndefined()
|
||||
expect(result.workflows).toEqual({ 'wf-src': 'wf-dst' })
|
||||
expect(result.knowledgeBases).toEqual({ 'kb-src': 'kb-dst' })
|
||||
expect(result.skills).toEqual({ 'sk-src': 'sk-dst' })
|
||||
// Maps not supplied stay undefined.
|
||||
expect(result.tables).toBeUndefined()
|
||||
expect(result.folders).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes an undefined workspaceId through unchanged', () => {
|
||||
const result = serializeContentRefMaps({})
|
||||
expect(result.workspaceId).toBeUndefined()
|
||||
expect(result.fileKeys).toBeUndefined()
|
||||
expect(result.skills).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasForkContentToCopy', () => {
|
||||
const emptyPlan = (): ForkContentPlan => ({
|
||||
sourceWorkspaceId: 'src',
|
||||
childWorkspaceId: 'child',
|
||||
userId: 'u',
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
skills: [],
|
||||
documents: [],
|
||||
})
|
||||
// The helper only inspects array lengths, so a single placeholder entry per kind is enough.
|
||||
const oneSkill = [{}] as unknown as ForkContentPlan['skills']
|
||||
const oneDoc = [{}] as unknown as ForkContentPlan['documents']
|
||||
const oneTable = [{}] as unknown as ForkContentPlan['tables']
|
||||
const oneKb = [{}] as unknown as ForkContentPlan['knowledgeBases']
|
||||
const oneBlob = [{}] as unknown as BlobCopyTask[]
|
||||
const noBlobs: BlobCopyTask[] = []
|
||||
|
||||
it('is true when skills are non-empty (the create-fork skill-only fix)', () => {
|
||||
expect(hasForkContentToCopy({ ...emptyPlan(), skills: oneSkill }, noBlobs)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when documents are non-empty', () => {
|
||||
expect(hasForkContentToCopy({ ...emptyPlan(), documents: oneDoc }, noBlobs)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when tables are non-empty', () => {
|
||||
expect(hasForkContentToCopy({ ...emptyPlan(), tables: oneTable }, noBlobs)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when knowledgeBases are non-empty', () => {
|
||||
expect(hasForkContentToCopy({ ...emptyPlan(), knowledgeBases: oneKb }, noBlobs)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when there are blob tasks', () => {
|
||||
expect(hasForkContentToCopy(emptyPlan(), oneBlob)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for an all-empty plan with no blob tasks', () => {
|
||||
expect(hasForkContentToCopy(emptyPlan(), noBlobs)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import { db } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
|
||||
import { runDetached } from '@/lib/core/utils/background'
|
||||
import { finishBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
|
||||
import { clearFailedForkResourceReferences } from '@/ee/workspace-forking/lib/copy/cleanup-failed'
|
||||
import type { BlobCopyTask } from '@/ee/workspace-forking/lib/copy/copy-files'
|
||||
import { executeForkFileBlobCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
|
||||
import type {
|
||||
ForkContentPlan,
|
||||
ForkFailedResource,
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import { copyForkResourceContent } from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import type { ForkContentRefMaps } from '@/ee/workspace-forking/lib/remap/remap-content-refs'
|
||||
|
||||
const logger = createLogger('WorkspaceForkContentCopy')
|
||||
|
||||
/**
|
||||
* Whether a fork/sync has any heavy content to copy after the commit: table rows, KB documents,
|
||||
* copied skill bodies, standalone documents, or file blobs. The single gate for scheduling the
|
||||
* background content fill - shared by fork-create and promote so the two can't diverge. A
|
||||
* skill-only (or documents-only) copy must still schedule it, otherwise the in-content `sim:` /
|
||||
* serve-link rewrite in {@link runForkContentCopy} never runs and copied bodies keep source links.
|
||||
*/
|
||||
export function hasForkContentToCopy(
|
||||
contentPlan: ForkContentPlan,
|
||||
blobTasks: BlobCopyTask[]
|
||||
): boolean {
|
||||
return (
|
||||
contentPlan.tables.length > 0 ||
|
||||
contentPlan.knowledgeBases.length > 0 ||
|
||||
contentPlan.skills.length > 0 ||
|
||||
contentPlan.documents.length > 0 ||
|
||||
blobTasks.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-serializable form of {@link ForkContentRefMaps} (Maps become Records) so the in-content
|
||||
* reference maps survive the Trigger.dev payload boundary. Rehydrated to Maps in the runner.
|
||||
*/
|
||||
export interface SerializableForkContentRefMaps {
|
||||
workspaceId?: { from: string; to: string }
|
||||
fileKeys?: Record<string, string>
|
||||
fileIds?: Record<string, string>
|
||||
workflows?: Record<string, string>
|
||||
knowledgeBases?: Record<string, string>
|
||||
tables?: Record<string, string>
|
||||
skills?: Record<string, string>
|
||||
folders?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable payload for the post-fork heavy-content copy. Runs either as a
|
||||
* Trigger.dev task (`background/fork-content-copy`) or inline via `runDetached`
|
||||
* when Trigger.dev is disabled - both call {@link runForkContentCopy}.
|
||||
*/
|
||||
export interface ForkContentCopyPayload {
|
||||
contentPlan: ForkContentPlan
|
||||
blobTasks: BlobCopyTask[]
|
||||
/** In-content reference maps for rewriting copied markdown blobs (serialized form). */
|
||||
contentRefMaps?: SerializableForkContentRefMaps
|
||||
/**
|
||||
* `background_work_status` row to finish when the copy ends, so the source workspace's
|
||||
* Manage Forks -> Activity entry resolves (completed / warning / error). Started right
|
||||
* after the fork commits so it's visible immediately.
|
||||
*/
|
||||
statusId?: string
|
||||
/**
|
||||
* Target workflow ids this sync deployed (promote's deploy loop). When a copied resource's
|
||||
* fill fails, its dropped placeholder must be cleared from these workflows' DEPLOYED version
|
||||
* states too - a deployed version can reference the placeholder even when the draft no longer
|
||||
* does (edited in the fill window), so the cleanup unions these with the draft-affected set
|
||||
* rather than relying on draft divergence. Empty/omitted for fork-create (child is undeployed).
|
||||
*/
|
||||
deployedTargetWorkflowIds?: string[]
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
const toRefMap = (record?: Record<string, string>): Map<string, string> | undefined =>
|
||||
record ? new Map(Object.entries(record)) : undefined
|
||||
|
||||
const fromRefMap = (map?: ReadonlyMap<string, string>): Record<string, string> | undefined =>
|
||||
map && map.size > 0 ? Object.fromEntries(map) : undefined
|
||||
|
||||
/**
|
||||
* Convert the Map-based {@link ForkContentRefMaps} to its JSON-serializable form for the
|
||||
* Trigger.dev payload. Empty maps are dropped (omitted). Single source of truth for the
|
||||
* Map->Record direction, paired with {@link deserializeContentRefMaps}.
|
||||
*/
|
||||
export function serializeContentRefMaps(maps: ForkContentRefMaps): SerializableForkContentRefMaps {
|
||||
return {
|
||||
workspaceId: maps.workspaceId,
|
||||
fileKeys: fromRefMap(maps.fileKeys),
|
||||
fileIds: fromRefMap(maps.fileIds),
|
||||
workflows: fromRefMap(maps.workflows),
|
||||
knowledgeBases: fromRefMap(maps.knowledgeBases),
|
||||
tables: fromRefMap(maps.tables),
|
||||
skills: fromRefMap(maps.skills),
|
||||
folders: fromRefMap(maps.folders),
|
||||
}
|
||||
}
|
||||
|
||||
/** Rehydrate the serialized content-ref maps to the Map-based {@link ForkContentRefMaps}. */
|
||||
function deserializeContentRefMaps(
|
||||
serialized?: SerializableForkContentRefMaps
|
||||
): ForkContentRefMaps | undefined {
|
||||
if (!serialized) return undefined
|
||||
return {
|
||||
workspaceId: serialized.workspaceId,
|
||||
fileKeys: toRefMap(serialized.fileKeys),
|
||||
fileIds: toRefMap(serialized.fileIds),
|
||||
workflows: toRefMap(serialized.workflows),
|
||||
knowledgeBases: toRefMap(serialized.knowledgeBases),
|
||||
tables: toRefMap(serialized.tables),
|
||||
skills: toRefMap(serialized.skills),
|
||||
folders: toRefMap(serialized.folders),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the heavy fork content after the fork transaction has committed: table
|
||||
* rows, KB documents + embeddings (keyset-paginated), and file blobs. Best-effort
|
||||
* and idempotency-unsafe (per-row inserts use fresh ids), so it must run at most
|
||||
* once - never blindly retried. Per-resource failures are counted (not thrown), so
|
||||
* the run finishes `completed_with_warnings` rather than failing the whole copy.
|
||||
*/
|
||||
export async function runForkContentCopy(payload: ForkContentCopyPayload): Promise<void> {
|
||||
const { contentPlan, blobTasks, statusId, requestId } = payload
|
||||
try {
|
||||
const contentRefMaps = deserializeContentRefMaps(payload.contentRefMaps)
|
||||
const resourceCounts = await copyForkResourceContent({ contentPlan, contentRefMaps, requestId })
|
||||
const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId, contentRefMaps)
|
||||
// A resource whose content fill failed leaves a dangling reference: a table/KB/doc placeholder
|
||||
// its workflows still point at, or a `file-upload` whose copied blob is missing. Clear those
|
||||
// references (draft + deployed versions) and drop the table/KB/doc placeholder so nothing
|
||||
// dangles; a failed file leaves its metadata row (re-uploadable) but has its refs cleared.
|
||||
const fileFailures: ForkFailedResource[] = fileCounts.failedTargetKeys.map((childKey) => ({
|
||||
kind: 'file',
|
||||
childKey,
|
||||
}))
|
||||
const { cleared, clearingFailed } = await clearFailedForkResourceReferences({
|
||||
childWorkspaceId: contentPlan.childWorkspaceId,
|
||||
failures: [...resourceCounts.failures, ...fileFailures],
|
||||
deployedTargetWorkflowIds: payload.deployedTargetWorkflowIds,
|
||||
requestId,
|
||||
})
|
||||
const copied = resourceCounts.copied + fileCounts.copied
|
||||
const failed = resourceCounts.failed + fileCounts.failed
|
||||
if (statusId) {
|
||||
await finishBackgroundWork(db, statusId, {
|
||||
status: failed > 0 ? 'completed_with_warnings' : 'completed',
|
||||
message:
|
||||
failed > 0
|
||||
? `Copied ${copied} item${copied === 1 ? '' : 's'}; ${failed} could not be copied`
|
||||
: `Copied ${copied} item${copied === 1 ? '' : 's'}`,
|
||||
metadata: {
|
||||
copied,
|
||||
failed,
|
||||
clearedReferences: cleared,
|
||||
...(clearingFailed ? { clearingFailed: true } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (statusId) {
|
||||
await finishBackgroundWork(db, statusId, {
|
||||
status: 'failed',
|
||||
error: getErrorMessage(error, 'Background resource copy failed'),
|
||||
}).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the post-commit heavy-content copy off the request path. Uses the Trigger.dev task
|
||||
* when enabled (so it survives an app deploy), else `runDetached` inline best-effort. Shared by
|
||||
* both fork and sync - only the `detachedLabel` (and so the inline job's name) differs. Never
|
||||
* throws: a scheduling failure is logged and, when a status row exists, marks it failed, so a
|
||||
* committed fork/sync is never turned into a 500 by a background-scheduling hiccup.
|
||||
*/
|
||||
export async function scheduleForkContentCopy(
|
||||
payload: ForkContentCopyPayload,
|
||||
options: { detachedLabel: string; requestId?: string }
|
||||
): Promise<void> {
|
||||
const { detachedLabel, requestId = 'unknown' } = options
|
||||
try {
|
||||
if (isTriggerDevEnabled) {
|
||||
const [{ forkContentCopyTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
|
||||
import('@/background/fork-content-copy'),
|
||||
import('@trigger.dev/sdk'),
|
||||
import('@/lib/core/async-jobs/region'),
|
||||
])
|
||||
await tasks.trigger<typeof forkContentCopyTask>('fork-content-copy', payload, {
|
||||
region: await resolveTriggerRegion(),
|
||||
})
|
||||
} else {
|
||||
runDetached(detachedLabel, () => runForkContentCopy(payload))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to schedule fork content copy`, {
|
||||
detachedLabel,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
if (payload.statusId) {
|
||||
await finishBackgroundWork(db, payload.statusId, {
|
||||
status: 'failed',
|
||||
error: getErrorMessage(error, 'Could not start the background copy'),
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { copyForkChatDeployments } from '@/ee/workspace-forking/lib/copy/copy-chats'
|
||||
|
||||
/**
|
||||
* Sequenced select mock: each `.select().from().where()` resolves the next queued result.
|
||||
* The chat copy issues (1) source chats, (2) target chat rows, then per identifier attempt
|
||||
* (3+) the taken-identifier check; inserts are captured.
|
||||
*/
|
||||
function makeTx(selectResults: unknown[][]) {
|
||||
const inserted: Array<Record<string, unknown>> = []
|
||||
let call = 0
|
||||
const tx = {
|
||||
select: () => ({
|
||||
from: () => ({ where: () => Promise.resolve(selectResults[call++] ?? []) }),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (values: Array<Record<string, unknown>>) => {
|
||||
inserted.push(...values)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}
|
||||
return { tx: tx as unknown as DbOrTx, inserted }
|
||||
}
|
||||
|
||||
const sourceChat = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'chat-src',
|
||||
workflowId: 'wf-src',
|
||||
userId: 'src-user',
|
||||
identifier: 'original-chat',
|
||||
title: 'Support Chat',
|
||||
description: 'desc',
|
||||
isActive: true,
|
||||
customizations: { welcomeMessage: 'hi' },
|
||||
authType: 'password',
|
||||
password: 'hashed-secret',
|
||||
allowedEmails: ['a@b.com'],
|
||||
outputConfigs: [{ blockId: 'block-src', path: 'content' }],
|
||||
archivedAt: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const pair = {
|
||||
sourceWorkflowId: 'wf-src',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
workflowName: 'Support Flow',
|
||||
}
|
||||
|
||||
describe('copyForkChatDeployments', () => {
|
||||
it('copies a live source chat with a generated identifier and remapped output block ids', async () => {
|
||||
const { tx, inserted } = makeTx([
|
||||
[sourceChat()],
|
||||
[], // target has no chat rows
|
||||
[], // no identifier collisions
|
||||
])
|
||||
const result = await copyForkChatDeployments({
|
||||
tx,
|
||||
pairs: [pair],
|
||||
targetWorkspaceName: 'My Team WS',
|
||||
userId: 'user-1',
|
||||
now: new Date('2026-07-07'),
|
||||
resolveBlockId: (targetWorkflowId, sourceBlockId) =>
|
||||
`${targetWorkflowId}:${sourceBlockId}:mapped`,
|
||||
})
|
||||
|
||||
expect(result.created).toBe(1)
|
||||
expect(inserted).toHaveLength(1)
|
||||
const copy = inserted[0]
|
||||
expect(copy.id).not.toBe('chat-src')
|
||||
expect(copy.workflowId).toBe('wf-tgt')
|
||||
expect(copy.userId).toBe('user-1')
|
||||
// `{workspace}-{workflow}-{randomnum}` in the identifier charset.
|
||||
expect(copy.identifier).toMatch(/^my-team-ws-support-flow-\d{6}$/)
|
||||
// Config copies verbatim - auth (hashed password) included.
|
||||
expect(copy.title).toBe('Support Chat')
|
||||
expect(copy.authType).toBe('password')
|
||||
expect(copy.password).toBe('hashed-secret')
|
||||
expect(copy.allowedEmails).toEqual(['a@b.com'])
|
||||
// Output configs bind to the target's block ids via the sync's block resolver.
|
||||
expect(copy.outputConfigs).toEqual([{ blockId: 'wf-tgt:block-src:mapped', path: 'content' }])
|
||||
expect(copy.archivedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a target that already has ANY chat row untouched (live or archived - never resurrects)', async () => {
|
||||
const { tx, inserted } = makeTx([
|
||||
[sourceChat()],
|
||||
[{ workflowId: 'wf-tgt' }], // target already has a chat row
|
||||
])
|
||||
const result = await copyForkChatDeployments({
|
||||
tx,
|
||||
pairs: [pair],
|
||||
targetWorkspaceName: 'WS',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
resolveBlockId: (_wf, blockId) => blockId,
|
||||
})
|
||||
expect(result.created).toBe(0)
|
||||
expect(inserted).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never emits duplicate identifiers within a batch (same workspace + workflow slug)', async () => {
|
||||
const twoChats = makeTx([
|
||||
[sourceChat(), sourceChat({ id: 'chat-src-2', identifier: 'other' })],
|
||||
[], // target has no chat rows
|
||||
[], // no live-identifier collisions
|
||||
])
|
||||
const result = await copyForkChatDeployments({
|
||||
tx: twoChats.tx,
|
||||
pairs: [pair],
|
||||
targetWorkspaceName: 'WS',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
resolveBlockId: (_wf, blockId) => blockId,
|
||||
})
|
||||
expect(result.created).toBe(2)
|
||||
const identifiers = twoChats.inserted.map((row) => row.identifier)
|
||||
expect(new Set(identifiers).size).toBe(2)
|
||||
})
|
||||
|
||||
it('no-ops with no pairs or no live source chats', async () => {
|
||||
const empty = makeTx([[]])
|
||||
expect(
|
||||
(
|
||||
await copyForkChatDeployments({
|
||||
tx: empty.tx,
|
||||
pairs: [pair],
|
||||
targetWorkspaceName: 'WS',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
resolveBlockId: (_wf, blockId) => blockId,
|
||||
})
|
||||
).created
|
||||
).toBe(0)
|
||||
expect(
|
||||
(
|
||||
await copyForkChatDeployments({
|
||||
tx: empty.tx,
|
||||
pairs: [],
|
||||
targetWorkspaceName: 'WS',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
resolveBlockId: (_wf, blockId) => blockId,
|
||||
})
|
||||
).created
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId, generateShortId } from '@sim/utils/id'
|
||||
import { randomInt } from '@sim/utils/random'
|
||||
import { and, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCopyChats')
|
||||
|
||||
/** Attempts at a random chat identifier before falling back to a long random suffix. */
|
||||
const IDENTIFIER_ATTEMPTS = 5
|
||||
|
||||
export interface ForkChatCopyPair {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
/** The target workflow's display name, for the generated chat identifier. */
|
||||
workflowName: string
|
||||
}
|
||||
|
||||
/** Lowercase a display name into the chat identifier charset (`[a-z0-9-]`), bounded. */
|
||||
function slugifyForIdentifier(value: string): string {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 24)
|
||||
.replace(/-+$/g, '')
|
||||
return slug || 'chat'
|
||||
}
|
||||
|
||||
/**
|
||||
* `{workspace}-{workflow}-{randomnum}` in the identifier charset. Six digits: concurrent forks
|
||||
* of one parent share the workspace/workflow slugs and can't see each other's uncommitted rows,
|
||||
* so the number is the only collision guard across simultaneous transactions.
|
||||
*/
|
||||
function buildIdentifierCandidate(workspaceSlug: string, workflowName: string): string {
|
||||
const random = randomInt(100000, 1000000)
|
||||
return `${workspaceSlug}-${slugifyForIdentifier(workflowName)}-${random}`
|
||||
}
|
||||
|
||||
/** Remap each output config's `blockId` onto the target workflow's block ids. */
|
||||
function remapChatOutputConfigs(
|
||||
value: unknown,
|
||||
targetWorkflowId: string,
|
||||
resolveBlockId: (targetWorkflowId: string, sourceBlockId: string) => string
|
||||
): unknown {
|
||||
if (!Array.isArray(value)) return value
|
||||
return value.map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.blockId !== 'string') return entry
|
||||
return { ...entry, blockId: resolveBlockId(targetWorkflowId, entry.blockId) }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry chat deployments onto the target side of a fork or sync: each LIVE source chat whose
|
||||
* target workflow has NO chat row at all (live or archived) is copied with a freshly generated
|
||||
* identifier - `{target-workspace}-{workflow}-{randomnum}` - so the copy serves at its own URL
|
||||
* immediately once the workflow deploys. Config copies verbatim (title, customizations, auth
|
||||
* incl. the hashed password and allowed emails); `outputConfigs` block ids are remapped through
|
||||
* the SAME block-id resolver the workflow write used, so the outputs bind to the target's
|
||||
* blocks.
|
||||
*
|
||||
* Targets with ANY existing chat row are left completely untouched ("maintained"): an already
|
||||
* carried-over chat keeps its identifier and config on every subsequent sync, and a chat the
|
||||
* target side deliberately archived is never resurrected. Bounded by the synced workflow count;
|
||||
* identifiers are collision-checked against live chats and fall back to a long random suffix.
|
||||
*/
|
||||
export async function copyForkChatDeployments(params: {
|
||||
tx: DbOrTx
|
||||
pairs: ForkChatCopyPair[]
|
||||
/** The TARGET workspace's display name, the identifier's first segment. */
|
||||
targetWorkspaceName: string
|
||||
userId: string
|
||||
now: Date
|
||||
resolveBlockId: (targetWorkflowId: string, sourceBlockId: string) => string
|
||||
requestId?: string
|
||||
}): Promise<{ created: number }> {
|
||||
const { tx, pairs, targetWorkspaceName, userId, now, resolveBlockId, requestId } = params
|
||||
if (pairs.length === 0) return { created: 0 }
|
||||
|
||||
const pairBySource = new Map(pairs.map((pair) => [pair.sourceWorkflowId, pair]))
|
||||
const sourceChats = await tx
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(inArray(chat.workflowId, [...pairBySource.keys()]), isNull(chat.archivedAt)))
|
||||
if (sourceChats.length === 0) return { created: 0 }
|
||||
|
||||
// A target workflow with ANY chat row (live or archived) keeps it: live means the carry-over
|
||||
// already happened (or the target made its own); archived means the target deliberately
|
||||
// retired it - recreating would resurrect against their intent.
|
||||
const candidateTargetIds = [
|
||||
...new Set(
|
||||
sourceChats
|
||||
.map((row) => pairBySource.get(row.workflowId)?.targetWorkflowId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
),
|
||||
]
|
||||
const existingTargetRows = await tx
|
||||
.select({ workflowId: chat.workflowId })
|
||||
.from(chat)
|
||||
.where(inArray(chat.workflowId, candidateTargetIds))
|
||||
const targetsWithChat = new Set(existingTargetRows.map((row) => row.workflowId))
|
||||
|
||||
const toCopy = sourceChats.filter((row) => {
|
||||
const pair = pairBySource.get(row.workflowId)
|
||||
return pair && !targetsWithChat.has(pair.targetWorkflowId)
|
||||
})
|
||||
if (toCopy.length === 0) return { created: 0 }
|
||||
|
||||
// Generate identifiers, retrying collisions against LIVE chats (the unique index is partial
|
||||
// on `archived_at IS NULL`, so archived identifiers are reusable) and against this batch.
|
||||
const workspaceSlug = slugifyForIdentifier(targetWorkspaceName)
|
||||
const identifierByChatId = new Map<string, string>()
|
||||
let pending = toCopy.map((row) => ({
|
||||
chatId: row.id,
|
||||
workflowName: pairBySource.get(row.workflowId)?.workflowName ?? 'chat',
|
||||
}))
|
||||
for (let attempt = 0; attempt < IDENTIFIER_ATTEMPTS && pending.length > 0; attempt++) {
|
||||
const claimed = new Set(identifierByChatId.values())
|
||||
const candidates = pending.map((entry) => {
|
||||
let candidate = buildIdentifierCandidate(workspaceSlug, entry.workflowName)
|
||||
while (claimed.has(candidate)) {
|
||||
candidate = buildIdentifierCandidate(workspaceSlug, entry.workflowName)
|
||||
}
|
||||
claimed.add(candidate)
|
||||
return { ...entry, candidate }
|
||||
})
|
||||
const taken = new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ identifier: chat.identifier })
|
||||
.from(chat)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
chat.identifier,
|
||||
candidates.map((entry) => entry.candidate)
|
||||
),
|
||||
isNull(chat.archivedAt)
|
||||
)
|
||||
)
|
||||
).map((row) => row.identifier)
|
||||
)
|
||||
pending = []
|
||||
for (const entry of candidates) {
|
||||
if (taken.has(entry.candidate)) pending.push(entry)
|
||||
else identifierByChatId.set(entry.chatId, entry.candidate)
|
||||
}
|
||||
}
|
||||
for (const entry of pending) {
|
||||
// Exhausted the friendly attempts: a long random suffix is effectively collision-free
|
||||
// (the global unique index still backstops it).
|
||||
identifierByChatId.set(
|
||||
entry.chatId,
|
||||
`${workspaceSlug}-${slugifyForIdentifier(entry.workflowName)}-${generateShortId(10)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '0')}`
|
||||
)
|
||||
}
|
||||
|
||||
const inserts: (typeof chat.$inferInsert)[] = []
|
||||
for (const row of toCopy) {
|
||||
const pair = pairBySource.get(row.workflowId)
|
||||
const identifier = identifierByChatId.get(row.id)
|
||||
if (!pair || !identifier) continue
|
||||
inserts.push({
|
||||
...row,
|
||||
id: generateId(),
|
||||
workflowId: pair.targetWorkflowId,
|
||||
userId,
|
||||
identifier,
|
||||
outputConfigs: remapChatOutputConfigs(
|
||||
row.outputConfigs,
|
||||
pair.targetWorkflowId,
|
||||
resolveBlockId
|
||||
),
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
if (inserts.length > 0) {
|
||||
await tx.insert(chat).values(inserts)
|
||||
logger.info(`[${requestId ?? 'unknown'}] Carried ${inserts.length} chat deployment(s)`, {
|
||||
identifiers: inserts.map((row) => row.identifier),
|
||||
})
|
||||
}
|
||||
return { created: inserts.length }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { storageServiceMock, storageServiceMockFns } from '@sim/testing'
|
||||
import { omit } from '@sim/utils/object'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockIncrementStorageUsage } = vi.hoisted(() => ({
|
||||
mockIncrementStorageUsage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
vi.mock('@/lib/billing/storage', () => ({
|
||||
incrementStorageUsage: mockIncrementStorageUsage,
|
||||
}))
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
generateWorkspaceFileKey: vi.fn(
|
||||
(workspaceId: string, fileName: string) => `workspace/${workspaceId}/generated-${fileName}`
|
||||
),
|
||||
}))
|
||||
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
type BlobCopyTask,
|
||||
executeForkFileBlobCopies,
|
||||
planForkFileCopies,
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-files'
|
||||
|
||||
function makeTask(overrides: Partial<BlobCopyTask> = {}): BlobCopyTask {
|
||||
return {
|
||||
sourceKey: 'workspace/src-ws/source-a.txt',
|
||||
targetKey: 'workspace/child-ws/target-a.txt',
|
||||
context: 'workspace',
|
||||
fileName: 'a.txt',
|
||||
contentType: 'text/plain',
|
||||
size: 100,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'child-ws',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('executeForkFileBlobCopies storage accounting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storageServiceMockFns.mockHeadObject.mockResolvedValue(null)
|
||||
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('blob-bytes'))
|
||||
storageServiceMockFns.mockUploadFile.mockResolvedValue({ key: 'workspace/child-ws/target' })
|
||||
mockIncrementStorageUsage.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('charges the initiating user exactly once per landed blob, by the metadata row size', async () => {
|
||||
const tasks = [
|
||||
makeTask({ targetKey: 'workspace/child-ws/t1', size: 100 }),
|
||||
makeTask({ targetKey: 'workspace/child-ws/t2', size: 200, fileName: 'b.txt' }),
|
||||
]
|
||||
|
||||
const result = await executeForkFileBlobCopies(tasks, 'test')
|
||||
|
||||
expect(result).toEqual({ copied: 2, failed: 0, failedTargetKeys: [] })
|
||||
expect(mockIncrementStorageUsage).toHaveBeenCalledTimes(2)
|
||||
expect(mockIncrementStorageUsage).toHaveBeenNthCalledWith(1, 'user-1', 100, 'child-ws')
|
||||
expect(mockIncrementStorageUsage).toHaveBeenNthCalledWith(2, 'user-1', 200, 'child-ws')
|
||||
})
|
||||
|
||||
it('skips an already-existing target blob without re-copying or re-charging (replayed run)', async () => {
|
||||
storageServiceMockFns.mockHeadObject.mockResolvedValue({ size: 100 })
|
||||
|
||||
const result = await executeForkFileBlobCopies([makeTask()], 'test')
|
||||
|
||||
expect(result).toEqual({ copied: 1, failed: 0, failedTargetKeys: [] })
|
||||
expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
|
||||
expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled()
|
||||
expect(mockIncrementStorageUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never charges a failed copy (the blob did not land)', async () => {
|
||||
storageServiceMockFns.mockDownloadFile.mockRejectedValue(new Error('source gone'))
|
||||
|
||||
const result = await executeForkFileBlobCopies([makeTask()], 'test')
|
||||
|
||||
expect(result).toEqual({
|
||||
copied: 0,
|
||||
failed: 1,
|
||||
failedTargetKeys: ['workspace/child-ws/target-a.txt'],
|
||||
})
|
||||
expect(mockIncrementStorageUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a tracking failure as best-effort - the copy still counts as landed', async () => {
|
||||
mockIncrementStorageUsage.mockRejectedValue(new Error('billing hiccup'))
|
||||
|
||||
const result = await executeForkFileBlobCopies([makeTask()], 'test')
|
||||
|
||||
expect(result).toEqual({ copied: 1, failed: 0, failedTargetKeys: [] })
|
||||
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the charge for a legacy payload enqueued before size existed', async () => {
|
||||
// Simulates a Trigger.dev payload serialized by a pre-`size` deploy (rolling upgrade).
|
||||
const legacyTask = omit(makeTask(), ['size']) as BlobCopyTask
|
||||
|
||||
const result = await executeForkFileBlobCopies([legacyTask], 'test')
|
||||
|
||||
expect(result.copied).toBe(1)
|
||||
expect(mockIncrementStorageUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('planForkFileCopies', () => {
|
||||
it('carries the source metadata size onto each blob task and the child row', async () => {
|
||||
const sourceMeta = {
|
||||
id: 'wf_src1',
|
||||
key: 'workspace/src-ws/1-abc-a.txt',
|
||||
userId: 'uploader-1',
|
||||
workspaceId: 'src-ws',
|
||||
folderId: 'folder-1',
|
||||
context: 'workspace',
|
||||
chatId: null,
|
||||
originalName: 'a.txt',
|
||||
displayName: null,
|
||||
contentType: 'text/plain',
|
||||
size: 4321,
|
||||
deletedAt: null,
|
||||
uploadedAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
}
|
||||
const inserted: Array<Record<string, unknown>> = []
|
||||
const tx = {
|
||||
select: vi.fn(() => ({ from: () => ({ where: () => Promise.resolve([sourceMeta]) }) })),
|
||||
insert: vi.fn(() => ({
|
||||
values: (row: Record<string, unknown>) => {
|
||||
inserted.push(row)
|
||||
return Promise.resolve()
|
||||
},
|
||||
})),
|
||||
} as unknown as DbOrTx
|
||||
|
||||
const result = await planForkFileCopies({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
fileIds: ['wf_src1'],
|
||||
now: new Date('2026-02-01'),
|
||||
})
|
||||
|
||||
expect(result.blobTasks).toHaveLength(1)
|
||||
expect(result.blobTasks[0]).toMatchObject({
|
||||
sourceKey: 'workspace/src-ws/1-abc-a.txt',
|
||||
targetKey: 'workspace/child-ws/generated-a.txt',
|
||||
size: 4321,
|
||||
userId: 'user-1',
|
||||
workspaceId: 'child-ws',
|
||||
})
|
||||
expect(inserted[0]).toMatchObject({ size: 4321, workspaceId: 'child-ws' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import { workspaceFiles } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
|
||||
import { incrementStorageUsage } from '@/lib/billing/storage'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service'
|
||||
import type { StorageContext } from '@/lib/uploads/shared/types'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
import {
|
||||
type ForkContentRefMaps,
|
||||
rewriteForkContentRefs,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-content-refs'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCopyFiles')
|
||||
|
||||
const MARKDOWN_CONTENT_TYPES = new Set(['text/markdown', 'text/x-markdown'])
|
||||
|
||||
/** Whether a copied blob is markdown text whose in-content references should be rewritten. */
|
||||
function isMarkdownBlob(task: Pick<BlobCopyTask, 'contentType' | 'fileName'>): boolean {
|
||||
if (MARKDOWN_CONTENT_TYPES.has(task.contentType)) return true
|
||||
const name = task.fileName.toLowerCase()
|
||||
return name.endsWith('.md') || name.endsWith('.mdx') || name.endsWith('.markdown')
|
||||
}
|
||||
|
||||
export interface BlobCopyTask {
|
||||
sourceKey: string
|
||||
targetKey: string
|
||||
context: StorageContext
|
||||
fileName: string
|
||||
contentType: string
|
||||
/**
|
||||
* Byte size from the source metadata row - the child `workspace_files` row was inserted
|
||||
* with this same size, so the storage-usage increment after a successful blob copy
|
||||
* charges exactly the bytes the row advertises (matching the upload path, where the
|
||||
* incremented bytes always equal the row's `size`).
|
||||
*/
|
||||
size: number
|
||||
userId: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export interface PlanForkFileCopiesResult {
|
||||
/**
|
||||
* source storage key -> child storage key. `file-upload` subblocks reference
|
||||
* files by storage key (not `workspace_files.id`), so the fork remap keys on the
|
||||
* storage key. At sync time this map is persisted in the fork resource map
|
||||
* (`resourceType: 'file'`, keyed by storage key) so a re-sync resolves the copy
|
||||
* instead of re-copying; at create-fork time it is not (the child is brand new).
|
||||
*/
|
||||
keyMap: Map<string, string>
|
||||
/**
|
||||
* source `workspace_files.id` -> child id. Used to rewrite in-content file references
|
||||
* that key on the file id (`sim:file/<id>`, `/api/files/view/<id>`, the in-app files
|
||||
* path) inside copied skill/markdown content; not persisted in the fork resource map.
|
||||
*/
|
||||
idMap: Map<string, string>
|
||||
/** Blob duplications to run after the fork transaction commits. */
|
||||
blobTasks: BlobCopyTask[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert child `workspace_files` metadata rows for the selected files (new id +
|
||||
* new storage key) and return the source→child storage-key map plus the blob
|
||||
* copies to run post-commit. The metadata row must exist before the blob upload
|
||||
* (its idempotent metadata insert reuses the row), and both must run after the
|
||||
* child workspace row exists (FK). Runs in the fork transaction; blob I/O is
|
||||
* deferred to {@link executeForkFileBlobCopies}.
|
||||
*
|
||||
* Files are selected EITHER by `workspace_files.id` (the fork modal's picker lists files
|
||||
* by id) OR by storage `key` (sync references key files by their storage key, not id). At
|
||||
* least one of the two must be non-empty; both may be supplied (their matched rows union).
|
||||
*/
|
||||
export async function planForkFileCopies(params: {
|
||||
tx: DbOrTx
|
||||
sourceWorkspaceId: string
|
||||
childWorkspaceId: string
|
||||
userId: string
|
||||
fileIds?: string[]
|
||||
fileKeys?: string[]
|
||||
now: Date
|
||||
}): Promise<PlanForkFileCopiesResult> {
|
||||
const { tx, sourceWorkspaceId, childWorkspaceId, userId, now } = params
|
||||
const fileIds = params.fileIds ?? []
|
||||
const fileKeys = params.fileKeys ?? []
|
||||
const keyMap = new Map<string, string>()
|
||||
const idMap = new Map<string, string>()
|
||||
const blobTasks: BlobCopyTask[] = []
|
||||
if (fileIds.length === 0 && fileKeys.length === 0) return { keyMap, idMap, blobTasks }
|
||||
|
||||
// Match by id and/or storage key (OR'd) so either selection shape resolves to the same
|
||||
// source rows. Batch the metadata read (one query for all selected files): non-deleted,
|
||||
// scoped to the source workspace, and restricted to durable `workspace` files. Only
|
||||
// workspace files are forkable - chat/copilot/mothership uploads are session-scoped and
|
||||
// their chat-bound unique index can't be duplicated - so any non-workspace id/key passed
|
||||
// here is ignored rather than copied.
|
||||
const selectors = [
|
||||
fileIds.length > 0 ? inArray(workspaceFiles.id, fileIds) : undefined,
|
||||
fileKeys.length > 0 ? inArray(workspaceFiles.key, fileKeys) : undefined,
|
||||
].filter((clause): clause is NonNullable<typeof clause> => clause !== undefined)
|
||||
const metas = await tx
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
selectors.length === 1 ? selectors[0] : or(...selectors),
|
||||
eq(workspaceFiles.workspaceId, sourceWorkspaceId),
|
||||
eq(workspaceFiles.context, 'workspace'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
for (const meta of metas) {
|
||||
const childFileId = generateId()
|
||||
// Use the canonical workspace-file key (`workspace/{id}/...`) so the file-serve
|
||||
// API can infer the storage context; a bare `{id}/...` key has no context prefix.
|
||||
const targetKey = generateWorkspaceFileKey(childWorkspaceId, meta.originalName)
|
||||
await tx.insert(workspaceFiles).values({
|
||||
...meta,
|
||||
id: childFileId,
|
||||
key: targetKey,
|
||||
workspaceId: childWorkspaceId,
|
||||
userId,
|
||||
folderId: null,
|
||||
deletedAt: null,
|
||||
uploadedAt: now,
|
||||
})
|
||||
keyMap.set(meta.key, targetKey)
|
||||
idMap.set(meta.id, childFileId)
|
||||
blobTasks.push({
|
||||
sourceKey: meta.key,
|
||||
targetKey,
|
||||
context: meta.context as StorageContext,
|
||||
fileName: meta.originalName,
|
||||
contentType: meta.contentType,
|
||||
size: meta.size,
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
})
|
||||
}
|
||||
|
||||
return { keyMap, idMap, blobTasks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate each planned file blob to its new key. `uploadFile`'s metadata insert
|
||||
* is idempotent on the key (the row was already created in the transaction), so
|
||||
* this only copies bytes. Markdown blobs additionally have their in-content references
|
||||
* (`sim:` links, embedded file/image URLs) rewritten through `contentRefMaps` so they
|
||||
* point at the copied resources (unmapped targets are left as graceful broken links).
|
||||
* Best-effort: a content-rewrite failure falls back to copying the raw bytes. A failed
|
||||
* blob's child storage key is returned in `failedTargetKeys` so the caller can clear the
|
||||
* `file-upload` references pointing at the now-missing object (the metadata row is left in
|
||||
* place, so the user can still re-upload the blob).
|
||||
*
|
||||
* Storage accounting: each blob that actually lands increments the initiating user's
|
||||
* storage usage by the metadata row's size - the copied bytes are charged exactly as if
|
||||
* the file had been uploaded to the target workspace. The increment cannot double-count:
|
||||
* the content-copy job is at-most-once by config (`maxAttempts: 1`), each task increments
|
||||
* only after its own successful upload, and the target-existence skip below means a
|
||||
* manually replayed run neither re-copies nor re-charges a blob a prior attempt landed.
|
||||
* Like the upload path, a tracking failure is logged and never fails the copy - and is
|
||||
* never retried, so a landed blob whose increment failed stays uncounted (a manual replay
|
||||
* skips it without charging). Accepted trade-off, matching the platform's upload paths:
|
||||
* storage may undercount, but a user is never charged twice or for bytes that didn't land.
|
||||
*/
|
||||
export async function executeForkFileBlobCopies(
|
||||
blobTasks: BlobCopyTask[],
|
||||
requestId = 'unknown',
|
||||
contentRefMaps?: ForkContentRefMaps
|
||||
): Promise<{ copied: number; failed: number; failedTargetKeys: string[] }> {
|
||||
let copied = 0
|
||||
const failedTargetKeys: string[] = []
|
||||
for (const task of blobTasks) {
|
||||
try {
|
||||
// Replay guard: target keys are freshly generated per fork/sync, so an existing
|
||||
// object can only mean an earlier attempt already landed this exact copy. Skip
|
||||
// without incrementing - a replay must never double-charge, so if the prior
|
||||
// attempt's best-effort increment failed those bytes stay uncounted (the same
|
||||
// accepted undercount as a tracking failure on the upload path). `headObject`
|
||||
// returns null on local storage, where the copy is simply repeated (same bytes
|
||||
// to the same key).
|
||||
const existing = await headObject(task.targetKey, task.context)
|
||||
if (existing) {
|
||||
copied += 1
|
||||
continue
|
||||
}
|
||||
const buffer = await downloadFile({
|
||||
key: task.sourceKey,
|
||||
context: task.context,
|
||||
maxBytes: MAX_FILE_SIZE,
|
||||
})
|
||||
let body: Buffer = buffer
|
||||
if (contentRefMaps && isMarkdownBlob(task)) {
|
||||
try {
|
||||
const text = buffer.toString('utf8')
|
||||
const rewritten = rewriteForkContentRefs(text, contentRefMaps)
|
||||
if (rewritten !== text) body = Buffer.from(rewritten, 'utf8')
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Failed to rewrite markdown blob content; copying raw bytes`, {
|
||||
targetKey: task.targetKey,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
await uploadFile({
|
||||
file: body,
|
||||
fileName: task.fileName,
|
||||
contentType: task.contentType,
|
||||
context: task.context,
|
||||
customKey: task.targetKey,
|
||||
preserveKey: true,
|
||||
metadata: {
|
||||
userId: task.userId,
|
||||
workspaceId: task.workspaceId,
|
||||
originalName: task.fileName,
|
||||
},
|
||||
})
|
||||
copied += 1
|
||||
// The typeof guard covers payloads enqueued before `size` existed (rolling deploy).
|
||||
if (typeof task.size === 'number' && task.size > 0) {
|
||||
try {
|
||||
await incrementStorageUsage(task.userId, task.size, task.workspaceId)
|
||||
} catch (storageError) {
|
||||
logger.error(`[${requestId}] Failed to update storage tracking for copied file blob`, {
|
||||
targetKey: task.targetKey,
|
||||
error: getErrorMessage(storageError),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failedTargetKeys.push(task.targetKey)
|
||||
logger.warn(`[${requestId}] Failed to copy file blob during fork`, {
|
||||
targetKey: task.targetKey,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
return { copied, failed: failedTargetKeys.length, failedTargetKeys }
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
resetDbChainMock,
|
||||
storageServiceMock,
|
||||
storageServiceMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockIncrementStorageUsage } = vi.hoisted(() => ({
|
||||
mockIncrementStorageUsage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
vi.mock('@/lib/billing/storage', () => ({
|
||||
incrementStorageUsage: mockIncrementStorageUsage,
|
||||
}))
|
||||
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
copyForkResourceContainers,
|
||||
copyForkResourceContent,
|
||||
type ForkContentPlan,
|
||||
planForkMappedKbDocumentCopies,
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
function basePlan(overrides: Partial<ForkContentPlan> = {}): ForkContentPlan {
|
||||
return {
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
skills: [],
|
||||
documents: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const sourceDoc = {
|
||||
id: 'doc-1',
|
||||
knowledgeBaseId: 'src-kb',
|
||||
storageKey: 'kb/source-key',
|
||||
fileUrl: '/api/files/serve/kb%2Fsource-key',
|
||||
filename: 'report.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
}
|
||||
|
||||
describe('copyForkResourceContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('blob-bytes'))
|
||||
storageServiceMockFns.mockUploadFile.mockResolvedValue({
|
||||
key: 'kb/child-key',
|
||||
path: '/api/files/serve/kb/child-key',
|
||||
})
|
||||
})
|
||||
|
||||
it('rewrites in-workspace resource URLs nested in copied table cell data', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'r1',
|
||||
tableId: 'src-tbl',
|
||||
workspaceId: 'src-ws',
|
||||
data: {
|
||||
kb: '/workspace/src-ws/knowledge/kb-1',
|
||||
nested: { wf: '/workspace/src-ws/w/wf-1' },
|
||||
plain: 'no url here',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const result = await copyForkResourceContent({
|
||||
contentPlan: basePlan({ tables: [{ sourceId: 'src-tbl', childId: 'child-tbl' }] }),
|
||||
contentRefMaps: {
|
||||
workspaceId: { from: 'src-ws', to: 'child-ws' },
|
||||
knowledgeBases: new Map([['kb-1', 'kb-2']]),
|
||||
workflows: new Map([['wf-1', 'wf-2']]),
|
||||
},
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(result.failed).toBe(0)
|
||||
// The first insert is the table-rows copy (no KBs/docs/skills in this plan).
|
||||
const inserted = dbChainMockFns.values.mock.calls[0][0] as Array<{
|
||||
data: { kb: string; nested: { wf: string }; plain: string }
|
||||
}>
|
||||
expect(inserted[0].data.kb).toBe('/workspace/child-ws/knowledge/kb-2')
|
||||
expect(inserted[0].data.nested.wf).toBe('/workspace/child-ws/w/wf-2')
|
||||
expect(inserted[0].data.plain).toBe('no url here')
|
||||
})
|
||||
|
||||
it('#1 binds a copied KB document blob to the CHILD workspace + initiating user', async () => {
|
||||
// One live document page, then the embeddings page resolves empty (default).
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc])
|
||||
|
||||
const result = await copyForkResourceContent({
|
||||
contentPlan: basePlan({
|
||||
knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }],
|
||||
}),
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(result.failed).toBe(0)
|
||||
expect(result.copied).toBe(1)
|
||||
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1)
|
||||
const uploadArg = storageServiceMockFns.mockUploadFile.mock.calls[0][0]
|
||||
expect(uploadArg.context).toBe('knowledge-base')
|
||||
expect(uploadArg.preserveKey).toBe(true)
|
||||
// The ownership binding is what verifyKBFileAccess resolves the owning workspace from;
|
||||
// it must name the CHILD workspace and the initiating user, or the copy is download-denied.
|
||||
expect(uploadArg.metadata).toEqual({
|
||||
userId: 'user-1',
|
||||
workspaceId: 'child-ws',
|
||||
originalName: 'report.pdf',
|
||||
})
|
||||
})
|
||||
|
||||
it('#1 never touches storage accounting for copied KB document blobs (mirrors the KB upload path)', async () => {
|
||||
// The normal KB upload path never increments `storage_used_bytes` (and embeddings are
|
||||
// uncounted DB rows), so a fork-copied KB blob must not be charged either - copied KB
|
||||
// bytes are only headroom-checked pre-fork, exactly like the multipart-initiate check.
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc])
|
||||
|
||||
const result = await copyForkResourceContent({
|
||||
contentPlan: basePlan({
|
||||
knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }],
|
||||
}),
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(result.copied).toBe(1)
|
||||
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockIncrementStorageUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('#4 re-reads a copied skill body post-commit and rewrites it via db.update (never from payload)', async () => {
|
||||
// The body is no longer carried in the plan - the content phase keyset-re-reads the child row.
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ id: 'child-skill-1', content: 'see [K](sim:knowledge/src-kb)' },
|
||||
])
|
||||
|
||||
const result = await copyForkResourceContent({
|
||||
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
|
||||
contentRefMaps: { knowledgeBases: new Map([['src-kb', 'child-kb']]) },
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(result.failed).toBe(0)
|
||||
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith({
|
||||
content: 'see [K](sim:knowledge/child-kb)',
|
||||
})
|
||||
})
|
||||
|
||||
it('#4 leaves a skill untouched when nothing in its re-read body remaps', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{ id: 'child-skill-1', content: 'no references here' },
|
||||
])
|
||||
|
||||
const result = await copyForkResourceContent({
|
||||
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
|
||||
contentRefMaps: { knowledgeBases: new Map([['src-kb', 'child-kb']]) },
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(result.failed).toBe(0)
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('#4 skips the skill re-read + rewrite entirely when no content maps are supplied', async () => {
|
||||
await copyForkResourceContent({
|
||||
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
// No maps -> the body is neither re-read from the DB nor updated.
|
||||
expect(dbChainMockFns.select).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('#3 fails the whole KB (all-or-nothing) when one document copy throws', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc])
|
||||
// The document row insert throws; the blob copy is best-effort (never throws) so the
|
||||
// failure must come from the persisted copy, marking the entire KB failed for cleanup.
|
||||
dbChainMockFns.values.mockImplementationOnce(() => {
|
||||
throw new Error('insert failed')
|
||||
})
|
||||
|
||||
const result = await copyForkResourceContent({
|
||||
contentPlan: basePlan({
|
||||
knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }],
|
||||
}),
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(result.copied).toBe(0)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.failures).toEqual([
|
||||
{ kind: 'knowledge-base', childId: 'child-kb', documentChildIds: [] },
|
||||
])
|
||||
})
|
||||
|
||||
it('U-docs: fills a document copied into an existing target KB (blob re-key + placeholder update)', async () => {
|
||||
const result = await copyForkResourceContent({
|
||||
contentPlan: basePlan({
|
||||
documents: [
|
||||
{
|
||||
sourceDocId: 'doc-1',
|
||||
childDocId: 'child-doc-1',
|
||||
childKnowledgeBaseId: 'existing-target-kb',
|
||||
storageKey: 'kb/source-key',
|
||||
filename: 'report.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
}),
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(result.failed).toBe(0)
|
||||
expect(result.copied).toBe(1)
|
||||
// The blob is re-keyed and the pre-created placeholder row's blob fields are updated.
|
||||
expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1)
|
||||
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('U-docs: a failed document fill is reported as a knowledge-document failure (for cleanup)', async () => {
|
||||
// The placeholder blob update throws; the doc fails on its own without touching its KB.
|
||||
dbChainMockFns.set.mockImplementationOnce(() => {
|
||||
throw new Error('update failed')
|
||||
})
|
||||
|
||||
const result = await copyForkResourceContent({
|
||||
contentPlan: basePlan({
|
||||
documents: [
|
||||
{
|
||||
sourceDocId: 'doc-1',
|
||||
childDocId: 'child-doc-1',
|
||||
childKnowledgeBaseId: 'existing-target-kb',
|
||||
storageKey: 'kb/source-key',
|
||||
filename: 'report.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
}),
|
||||
requestId: 'test',
|
||||
})
|
||||
|
||||
expect(result.copied).toBe(0)
|
||||
expect(result.failed).toBe(1)
|
||||
expect(result.failures).toEqual([{ kind: 'knowledge-document', childId: 'child-doc-1' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyForkResourceContainers custom-tool code env rewrite', () => {
|
||||
function makeContainerTx(rows: Array<Record<string, unknown>>) {
|
||||
const inserted: Array<Record<string, unknown>> = []
|
||||
const tx = {
|
||||
select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }),
|
||||
insert: () => ({
|
||||
values: (values: Array<Record<string, unknown>>) => {
|
||||
inserted.push(...values)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}
|
||||
return { tx: tx as unknown as DbOrTx, inserted }
|
||||
}
|
||||
|
||||
const customToolSelection = {
|
||||
customTools: ['ct-1'],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
}
|
||||
|
||||
it('rewrites {{ENV}} refs in copied custom-tool code when a sync renames the env var', async () => {
|
||||
const { tx, inserted } = makeContainerTx([
|
||||
{ id: 'ct-1', title: 'Tool', code: 'fetch("{{SLACK_API_KEY}}", "{{KEEP}}")' },
|
||||
])
|
||||
await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: customToolSelection,
|
||||
workflowIdMap: new Map(),
|
||||
resolveEnvName: (key) => (key === 'SLACK_API_KEY' ? 'SLACK_API_KEY_TEST' : key),
|
||||
})
|
||||
expect(inserted).toHaveLength(1)
|
||||
// The renamed key is rewritten; the same-name key is left verbatim.
|
||||
expect(inserted[0].code).toBe('fetch("{{SLACK_API_KEY_TEST}}", "{{KEEP}}")')
|
||||
expect(inserted[0].workspaceId).toBe('child-ws')
|
||||
})
|
||||
|
||||
it('preserves custom-tool code verbatim when no env resolver is provided (fork-create)', async () => {
|
||||
const { tx, inserted } = makeContainerTx([
|
||||
{ id: 'ct-1', title: 'Tool', code: 'fetch("{{SLACK_API_KEY}}")' },
|
||||
])
|
||||
await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: customToolSelection,
|
||||
workflowIdMap: new Map(),
|
||||
})
|
||||
expect(inserted[0].code).toBe('fetch("{{SLACK_API_KEY}}")')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyForkResourceContainers external MCP server copy', () => {
|
||||
function makeServerTx(rows: Array<Record<string, unknown>>) {
|
||||
const inserted: Array<Record<string, unknown>> = []
|
||||
const tx = {
|
||||
select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }),
|
||||
insert: () => ({
|
||||
values: (values: Array<Record<string, unknown>>) => {
|
||||
inserted.push(...values)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}
|
||||
return { tx: tx as unknown as DbOrTx, inserted }
|
||||
}
|
||||
|
||||
it('copies the config row with runtime status reset, records the mapping, and never copies tokens', async () => {
|
||||
const { tx, inserted } = makeServerTx([
|
||||
{
|
||||
id: 'mcp-1',
|
||||
workspaceId: 'src-ws',
|
||||
createdBy: 'src-user',
|
||||
name: 'Linear MCP',
|
||||
transport: 'streamable-http',
|
||||
url: 'https://mcp.linear.app/mcp',
|
||||
authType: 'headers',
|
||||
headers: { Authorization: 'Bearer {{LINEAR_KEY}}' },
|
||||
connectionStatus: 'connected',
|
||||
lastConnected: new Date(),
|
||||
lastError: 'old error',
|
||||
statusConfig: { consecutiveFailures: 2, lastSuccessfulDiscovery: 'x' },
|
||||
toolCount: 12,
|
||||
lastToolsRefresh: new Date(),
|
||||
totalRequests: 99,
|
||||
lastUsed: new Date(),
|
||||
deletedAt: null,
|
||||
},
|
||||
])
|
||||
|
||||
const result = await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: {
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: ['mcp-1'],
|
||||
workflowMcpServers: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
},
|
||||
workflowIdMap: new Map(),
|
||||
})
|
||||
|
||||
expect(inserted).toHaveLength(1)
|
||||
const child = inserted[0]
|
||||
expect(child.id).not.toBe('mcp-1')
|
||||
expect(child.workspaceId).toBe('child-ws')
|
||||
expect(child.createdBy).toBe('user-1')
|
||||
// Config copies verbatim - url/headers ({{ENV}} refs resolve against the child's env).
|
||||
expect(child.url).toBe('https://mcp.linear.app/mcp')
|
||||
expect(child.headers).toEqual({ Authorization: 'Bearer {{LINEAR_KEY}}' })
|
||||
// Runtime status resets: tools re-discover on first use in the child (cache is
|
||||
// workspace-keyed), and no `mcp_server_oauth` row is ever inserted (re-auth required).
|
||||
expect(child.connectionStatus).toBe('disconnected')
|
||||
expect(child.lastConnected).toBeNull()
|
||||
expect(child.lastError).toBeNull()
|
||||
expect(child.toolCount).toBe(0)
|
||||
expect(child.lastToolsRefresh).toBeNull()
|
||||
// The id map + mapping rows record the copy so subblock references remap onto it.
|
||||
expect(result.idMap.get('mcp_server')?.get('mcp-1')).toBe(child.id)
|
||||
expect(result.mappingEntries).toContainEqual({
|
||||
resourceType: 'mcp_server',
|
||||
parentResourceId: 'mcp-1',
|
||||
childResourceId: child.id,
|
||||
})
|
||||
expect(result.names.mcpServers).toEqual(['Linear MCP'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyForkResourceContainers skill copy', () => {
|
||||
function makeSkillTx(rows: Array<Record<string, unknown>>) {
|
||||
const inserted: Array<Record<string, unknown>> = []
|
||||
const tx = {
|
||||
select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }),
|
||||
insert: () => ({
|
||||
values: (values: Array<Record<string, unknown>>) => {
|
||||
inserted.push(...values)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}
|
||||
return { tx: tx as unknown as DbOrTx, inserted }
|
||||
}
|
||||
|
||||
const skillSelection = {
|
||||
customTools: [],
|
||||
skills: ['sk-1'],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
}
|
||||
|
||||
it('copies the skill body IN-DB and carries only the child id in the content plan', async () => {
|
||||
// The source projection deliberately omits `content` (it is copied server-side), so the row
|
||||
// fed to the tx mock has none - the body must never be materialized in app memory here.
|
||||
const { tx, inserted } = makeSkillTx([
|
||||
{
|
||||
id: 'sk-1',
|
||||
name: 'My Skill',
|
||||
description: 'desc',
|
||||
workspaceId: 'src-ws',
|
||||
userId: 'src-user',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
const result = await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: skillSelection,
|
||||
workflowIdMap: new Map(),
|
||||
})
|
||||
|
||||
expect(inserted).toHaveLength(1)
|
||||
const childId = inserted[0].id as string
|
||||
expect(childId).not.toBe('sk-1')
|
||||
expect(inserted[0].workspaceId).toBe('child-ws')
|
||||
expect(inserted[0].userId).toBe('user-1')
|
||||
// The body is deferred to a correlated subquery (in-DB copy), never a materialized string.
|
||||
expect(typeof inserted[0].content).not.toBe('string')
|
||||
// The content plan carries ONLY the child id - no skill body text crosses the job payload.
|
||||
expect(result.contentPlan.skills).toEqual([{ childId }])
|
||||
expect(result.names.skills).toEqual(['My Skill'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyForkResourceContainers knowledge-base tag definitions', () => {
|
||||
/** Sequential tx mock: each select resolves the next queued row set; inserts are captured per call. */
|
||||
function makeKbTx(selects: Array<Array<Record<string, unknown>>>) {
|
||||
let call = 0
|
||||
const inserts: Array<Array<Record<string, unknown>>> = []
|
||||
const tx = {
|
||||
select: () => ({
|
||||
from: () => ({ where: () => Promise.resolve(selects[call++] ?? []) }),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (rows: Array<Record<string, unknown>>) => {
|
||||
inserts.push(rows)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}
|
||||
return { tx: tx as unknown as DbOrTx, inserts }
|
||||
}
|
||||
|
||||
const kbSelection = {
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
tables: [],
|
||||
knowledgeBases: ['kb-1'],
|
||||
}
|
||||
|
||||
const sourceBase = { id: 'kb-1', name: 'Docs KB', workspaceId: 'src-ws', deletedAt: null }
|
||||
|
||||
it('copies the source KB tag definitions to the child KB with fresh ids (other columns verbatim)', async () => {
|
||||
const { tx, inserts } = makeKbTx([
|
||||
[sourceBase],
|
||||
[
|
||||
{
|
||||
id: 'tag-1',
|
||||
knowledgeBaseId: 'kb-1',
|
||||
tagSlot: 'tag1',
|
||||
displayName: 'Category',
|
||||
fieldType: 'text',
|
||||
},
|
||||
{
|
||||
id: 'tag-2',
|
||||
knowledgeBaseId: 'kb-1',
|
||||
tagSlot: 'boolean1',
|
||||
displayName: 'Reviewed',
|
||||
fieldType: 'boolean',
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const result = await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: kbSelection,
|
||||
workflowIdMap: new Map(),
|
||||
})
|
||||
|
||||
const childKbId = result.idMap.get('knowledge_base')?.get('kb-1')
|
||||
expect(childKbId).toBeTruthy()
|
||||
// insert #0 is the KB row; insert #1 is the tag-definition batch.
|
||||
expect(inserts).toHaveLength(2)
|
||||
const tagRows = inserts[1]
|
||||
expect(tagRows).toHaveLength(2)
|
||||
for (const row of tagRows) {
|
||||
expect(row.knowledgeBaseId).toBe(childKbId)
|
||||
expect(row.id).not.toBe('tag-1')
|
||||
expect(row.id).not.toBe('tag-2')
|
||||
}
|
||||
expect(tagRows.map((row) => [row.tagSlot, row.displayName, row.fieldType])).toEqual([
|
||||
['tag1', 'Category', 'text'],
|
||||
['boolean1', 'Reviewed', 'boolean'],
|
||||
])
|
||||
})
|
||||
|
||||
it('no-ops the tag-definition copy for a KB with zero definitions', async () => {
|
||||
const { tx, inserts } = makeKbTx([[sourceBase], []])
|
||||
|
||||
await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: kbSelection,
|
||||
workflowIdMap: new Map(),
|
||||
})
|
||||
|
||||
// Only the KB row itself is inserted - no empty tag-definition insert.
|
||||
expect(inserts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planForkMappedKbDocumentCopies', () => {
|
||||
const sourceRow = (id: string, knowledgeBaseId: string) => ({
|
||||
id,
|
||||
knowledgeBaseId,
|
||||
storageKey: `kb/${id}`,
|
||||
filename: `${id}.pdf`,
|
||||
mimeType: 'application/pdf',
|
||||
connectorId: 'connector-1',
|
||||
deletedAt: null,
|
||||
archivedAt: null,
|
||||
})
|
||||
|
||||
function makeTx(docs: ReturnType<typeof sourceRow>[]) {
|
||||
const inserted: Array<Record<string, unknown>> = []
|
||||
let selectCalled = false
|
||||
const tx = {
|
||||
select: () => {
|
||||
selectCalled = true
|
||||
return { from: () => ({ where: () => Promise.resolve(docs) }) }
|
||||
},
|
||||
insert: () => ({
|
||||
values: (rows: Array<Record<string, unknown>>) => {
|
||||
inserted.push(...rows)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}
|
||||
return { tx: tx as unknown as DbOrTx, inserted, wasSelectCalled: () => selectCalled }
|
||||
}
|
||||
|
||||
const mappedKbResolver: ForkReferenceResolver = (kind, id) =>
|
||||
kind === 'knowledge-base' && id === 'src-kb' ? 'target-kb' : null
|
||||
|
||||
it('places a referenced doc into its already-mapped existing KB and returns the maps', async () => {
|
||||
const { tx, inserted } = makeTx([sourceRow('doc-1', 'src-kb')])
|
||||
const result = await planForkMappedKbDocumentCopies({
|
||||
tx,
|
||||
resolver: mappedKbResolver,
|
||||
referencedDocumentIds: ['doc-1'],
|
||||
alreadyCopiedSourceDocIds: new Set(),
|
||||
})
|
||||
|
||||
const childId = result.docIdMap.get('doc-1')
|
||||
expect(childId).toBeTruthy()
|
||||
expect(inserted).toHaveLength(1)
|
||||
expect(inserted[0]).toMatchObject({
|
||||
id: childId,
|
||||
knowledgeBaseId: 'target-kb',
|
||||
connectorId: null,
|
||||
deletedAt: null,
|
||||
archivedAt: null,
|
||||
})
|
||||
expect(result.mappingEntries).toEqual([
|
||||
{ resourceType: 'knowledge_document', parentResourceId: 'doc-1', childResourceId: childId },
|
||||
])
|
||||
expect(result.documents).toEqual([
|
||||
{
|
||||
sourceDocId: 'doc-1',
|
||||
childDocId: childId,
|
||||
childKnowledgeBaseId: 'target-kb',
|
||||
storageKey: 'kb/doc-1',
|
||||
filename: 'doc-1.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('skips a referenced doc whose parent KB is not mapped (reference is left to be cleared)', async () => {
|
||||
const { tx, inserted } = makeTx([sourceRow('doc-1', 'unmapped-kb')])
|
||||
const result = await planForkMappedKbDocumentCopies({
|
||||
tx,
|
||||
resolver: mappedKbResolver,
|
||||
referencedDocumentIds: ['doc-1'],
|
||||
alreadyCopiedSourceDocIds: new Set(),
|
||||
})
|
||||
expect(inserted).toHaveLength(0)
|
||||
expect(result.docIdMap.size).toBe(0)
|
||||
expect(result.documents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips a doc already placed under a copied KB this sync (no duplicate query)', async () => {
|
||||
const { tx, wasSelectCalled } = makeTx([sourceRow('doc-1', 'src-kb')])
|
||||
const result = await planForkMappedKbDocumentCopies({
|
||||
tx,
|
||||
resolver: mappedKbResolver,
|
||||
referencedDocumentIds: ['doc-1'],
|
||||
alreadyCopiedSourceDocIds: new Set(['doc-1']),
|
||||
})
|
||||
expect(result.documents).toHaveLength(0)
|
||||
expect(wasSelectCalled()).toBe(false)
|
||||
})
|
||||
|
||||
it('skips a doc that already resolves (mapped by a prior sync)', async () => {
|
||||
const { tx, wasSelectCalled } = makeTx([sourceRow('doc-1', 'src-kb')])
|
||||
const result = await planForkMappedKbDocumentCopies({
|
||||
tx,
|
||||
resolver: (kind, id) =>
|
||||
kind === 'knowledge-document' && id === 'doc-1' ? 'existing-child-doc' : null,
|
||||
referencedDocumentIds: ['doc-1'],
|
||||
alreadyCopiedSourceDocIds: new Set(),
|
||||
})
|
||||
expect(result.documents).toHaveLength(0)
|
||||
expect(wasSelectCalled()).toBe(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
const { mockSaveWorkflowToNormalizedTables } = vi.hoisted(() => ({
|
||||
mockSaveWorkflowToNormalizedTables: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
|
||||
}))
|
||||
|
||||
import {
|
||||
buildWorkflowNameRegistry,
|
||||
copyWorkflowStateIntoTarget,
|
||||
resolveForkFolderMapping,
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-workflows'
|
||||
|
||||
describe('buildWorkflowNameRegistry', () => {
|
||||
it('reports a name as taken by another workflow in the same folder', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
|
||||
expect(reg.isTaken('f1', 'Onboarding', null)).toBe(true)
|
||||
expect(reg.isTaken('f1', 'Onboarding', 'w2')).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes the workflow itself so a replace can keep its own name', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
|
||||
expect(reg.isTaken('f1', 'Onboarding', 'w1')).toBe(false)
|
||||
})
|
||||
|
||||
it('is folder-scoped: the same name in another folder is free', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
|
||||
expect(reg.isTaken('f2', 'Onboarding', null)).toBe(false)
|
||||
expect(reg.isTaken(null, 'Onboarding', null)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats the root (null) folder distinctly, matching coalesce(folderId, "")', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: null, name: 'Root WF' }])
|
||||
expect(reg.isTaken(null, 'Root WF', null)).toBe(true)
|
||||
expect(reg.isTaken('f1', 'Root WF', null)).toBe(false)
|
||||
})
|
||||
|
||||
it('claims a new name so a later workflow in the same copy loop sees it taken', () => {
|
||||
const reg = buildWorkflowNameRegistry([])
|
||||
expect(reg.isTaken('f1', 'Report', null)).toBe(false)
|
||||
reg.claim('f1', 'Report', 'wA')
|
||||
expect(reg.isTaken('f1', 'Report', null)).toBe(true)
|
||||
expect(reg.isTaken('f1', 'Report', 'wA')).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the prior name when a workflow is renamed (claim moves keys)', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Old' }])
|
||||
reg.claim('f1', 'New', 'w1')
|
||||
expect(reg.isTaken('f1', 'Old', null)).toBe(false)
|
||||
expect(reg.isTaken('f1', 'New', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('re-claiming the same (folder, name) is a no-op', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Same' }])
|
||||
reg.claim('f1', 'Same', 'w1')
|
||||
expect(reg.isTaken('f1', 'Same', 'w1')).toBe(false)
|
||||
expect(reg.isTaken('f1', 'Same', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles multiple holders (legacy duplicates) and partial release', () => {
|
||||
const reg = buildWorkflowNameRegistry([
|
||||
{ id: 'w1', folderId: 'f1', name: 'Dup' },
|
||||
{ id: 'w2', folderId: 'f1', name: 'Dup' },
|
||||
])
|
||||
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(true)
|
||||
reg.claim('f1', 'Other', 'w2')
|
||||
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
interface FolderRow {
|
||||
id: string
|
||||
name: string
|
||||
userId: string
|
||||
workspaceId: string
|
||||
parentId: string | null
|
||||
color: string | null
|
||||
isExpanded: boolean
|
||||
locked: boolean
|
||||
sortOrder: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
archivedAt: Date | null
|
||||
}
|
||||
|
||||
function folderRow(id: string, name: string, parentId: string | null = null): FolderRow {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
userId: 'source-user',
|
||||
workspaceId: 'ws-source',
|
||||
parentId,
|
||||
color: '#6B7280',
|
||||
isExpanded: true,
|
||||
locked: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
archivedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction stub for {@link resolveForkFolderMapping}: the first awaited select resolves
|
||||
* the source folders, the second the target folders, and inserted rows are captured.
|
||||
*/
|
||||
function buildFolderTx(sourceFolders: FolderRow[], targetFolders: FolderRow[] = []) {
|
||||
const insertedRows: FolderRow[] = []
|
||||
const selects = [sourceFolders, targetFolders]
|
||||
let selectIndex = 0
|
||||
const tx = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve(selects[selectIndex++] ?? []),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: (rows: FolderRow[]) => {
|
||||
insertedRows.push(...rows)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
} as unknown as DbOrTx
|
||||
return { tx, insertedRows }
|
||||
}
|
||||
|
||||
function resolveMapping(params: {
|
||||
tx: DbOrTx
|
||||
contentFolderIds: ReadonlyArray<string | null>
|
||||
}): Promise<Map<string, string>> {
|
||||
return resolveForkFolderMapping({
|
||||
tx: params.tx,
|
||||
sourceWorkspaceId: 'ws-source',
|
||||
targetWorkspaceId: 'ws-target',
|
||||
userId: 'target-user',
|
||||
now: new Date('2026-07-01'),
|
||||
contentFolderIds: params.contentFolderIds,
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveForkFolderMapping', () => {
|
||||
it('keeps the full ancestor chain of a nested folder holding a copied workflow', async () => {
|
||||
const { tx, insertedRows } = buildFolderTx([
|
||||
folderRow('A', 'Alpha'),
|
||||
folderRow('B', 'Beta', 'A'),
|
||||
folderRow('C', 'Gamma', 'B'),
|
||||
])
|
||||
|
||||
const map = await resolveMapping({ tx, contentFolderIds: ['C'] })
|
||||
|
||||
expect(map.size).toBe(3)
|
||||
expect(insertedRows).toHaveLength(3)
|
||||
const byName = new Map(insertedRows.map((row) => [row.name, row]))
|
||||
expect(byName.get('Alpha')?.parentId).toBeNull()
|
||||
expect(byName.get('Beta')?.parentId).toBe(map.get('A'))
|
||||
expect(byName.get('Gamma')?.parentId).toBe(map.get('B'))
|
||||
for (const row of insertedRows) {
|
||||
expect(row.workspaceId).toBe('ws-target')
|
||||
expect(row.userId).toBe('target-user')
|
||||
expect(row.locked).toBe(false)
|
||||
expect(['A', 'B', 'C']).not.toContain(row.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes an empty sibling subtree while keeping the occupied folder', async () => {
|
||||
const { tx, insertedRows } = buildFolderTx([
|
||||
folderRow('A', 'Occupied'),
|
||||
folderRow('D', 'Empty parent'),
|
||||
folderRow('E', 'Empty child', 'D'),
|
||||
])
|
||||
|
||||
const map = await resolveMapping({ tx, contentFolderIds: ['A'] })
|
||||
|
||||
expect(insertedRows).toHaveLength(1)
|
||||
expect(insertedRows[0].name).toBe('Occupied')
|
||||
expect(map.has('A')).toBe(true)
|
||||
expect(map.has('D')).toBe(false)
|
||||
expect(map.has('E')).toBe(false)
|
||||
})
|
||||
|
||||
it('prunes a root-level empty folder when the copied workflows live at root', async () => {
|
||||
const { tx, insertedRows } = buildFolderTx([folderRow('F', 'Never used')])
|
||||
|
||||
const map = await resolveMapping({ tx, contentFolderIds: [null, null] })
|
||||
|
||||
expect(insertedRows).toHaveLength(0)
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
|
||||
it('creates no folders when nothing is copied into any folder', async () => {
|
||||
const { tx, insertedRows } = buildFolderTx([
|
||||
folderRow('A', 'Alpha'),
|
||||
folderRow('B', 'Beta', 'A'),
|
||||
])
|
||||
|
||||
const map = await resolveMapping({ tx, contentFolderIds: [] })
|
||||
|
||||
expect(insertedRows).toHaveLength(0)
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
|
||||
it('reuses an existing target folder for a kept folder instead of duplicating it', async () => {
|
||||
const existing = { ...folderRow('T1', 'Shared'), workspaceId: 'ws-target' }
|
||||
const { tx, insertedRows } = buildFolderTx([folderRow('G', 'Shared')], [existing])
|
||||
|
||||
const map = await resolveMapping({ tx, contentFolderIds: ['G'] })
|
||||
|
||||
expect(insertedRows).toHaveLength(0)
|
||||
expect(map.get('G')).toBe('T1')
|
||||
})
|
||||
|
||||
it('maps a pruned folder onto a matching existing target folder without creating it', async () => {
|
||||
const existing = { ...folderRow('T1', 'Prior sync'), workspaceId: 'ws-target' }
|
||||
const { tx, insertedRows } = buildFolderTx([folderRow('P', 'Prior sync')], [existing])
|
||||
|
||||
const map = await resolveMapping({ tx, contentFolderIds: [] })
|
||||
|
||||
expect(insertedRows).toHaveLength(0)
|
||||
expect(map.get('P')).toBe('T1')
|
||||
})
|
||||
|
||||
it('never root-aliases a pruned nested folder onto a same-named root target folder', async () => {
|
||||
// Source X is nested under unmatched P; the target's root-level "X" is unrelated.
|
||||
const existing = { ...folderRow('T-root-x', 'X'), workspaceId: 'ws-target' }
|
||||
const { tx, insertedRows } = buildFolderTx(
|
||||
[folderRow('P', 'Parent'), folderRow('X', 'X', 'P')],
|
||||
[existing]
|
||||
)
|
||||
|
||||
const map = await resolveMapping({ tx, contentFolderIds: [] })
|
||||
|
||||
expect(insertedRows).toHaveLength(0)
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
|
||||
it('creates a kept child under a reused existing parent folder', async () => {
|
||||
const existingParent = { ...folderRow('T-parent', 'Parent'), workspaceId: 'ws-target' }
|
||||
const { tx, insertedRows } = buildFolderTx(
|
||||
[folderRow('P', 'Parent'), folderRow('C', 'Child', 'P')],
|
||||
[existingParent]
|
||||
)
|
||||
|
||||
const map = await resolveMapping({ tx, contentFolderIds: ['C'] })
|
||||
|
||||
expect(map.get('P')).toBe('T-parent')
|
||||
expect(insertedRows).toHaveLength(1)
|
||||
expect(insertedRows[0].name).toBe('Child')
|
||||
expect(insertedRows[0].parentId).toBe('T-parent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyWorkflowStateIntoTarget folder fallback', () => {
|
||||
it('places a copied workflow at the target root when its source folder has no mapping', async () => {
|
||||
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
|
||||
const insertedWorkflows: Array<Record<string, unknown>> = []
|
||||
const tx = {
|
||||
insert: () => ({
|
||||
values: (row: Record<string, unknown>) => {
|
||||
insertedWorkflows.push(row)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
} as unknown as DbOrTx
|
||||
|
||||
const result = await copyWorkflowStateIntoTarget({
|
||||
tx,
|
||||
targetWorkflowId: 'wf-child',
|
||||
targetWorkspaceId: 'ws-target',
|
||||
userId: 'target-user',
|
||||
mode: 'create',
|
||||
now: new Date('2026-07-01'),
|
||||
sourceState: { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} },
|
||||
sourceMeta: {
|
||||
name: 'Orphaned placement',
|
||||
description: null,
|
||||
folderId: 'folder-with-no-mapping',
|
||||
sortOrder: 0,
|
||||
},
|
||||
workflowIdMap: new Map(),
|
||||
folderIdMap: new Map(),
|
||||
nameRegistry: buildWorkflowNameRegistry([]),
|
||||
})
|
||||
|
||||
expect(insertedWorkflows).toHaveLength(1)
|
||||
expect(insertedWorkflows[0].folderId).toBeNull()
|
||||
expect(result.name).toBe('Orphaned placement')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => {
|
||||
it(
|
||||
"persists a transform's reindexed canonicalModes on the copied block, and uses that " +
|
||||
"SAME reindexed value (not the source's stale one) for every subsequent remap step",
|
||||
async () => {
|
||||
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
|
||||
const seenCanonicalModes: Array<Record<string, 'basic' | 'advanced'> | undefined> = []
|
||||
const tx = {
|
||||
insert: () => ({ values: () => Promise.resolve() }),
|
||||
} as unknown as DbOrTx
|
||||
|
||||
await copyWorkflowStateIntoTarget({
|
||||
tx,
|
||||
targetWorkflowId: 'wf-child',
|
||||
targetWorkspaceId: 'ws-target',
|
||||
userId: 'target-user',
|
||||
mode: 'create',
|
||||
now: new Date('2026-07-01'),
|
||||
sourceState: {
|
||||
blocks: {
|
||||
block1: {
|
||||
id: 'block1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
subBlocks: {},
|
||||
// The source's ORIGINAL (pre-drop) canonicalModes - every step after the
|
||||
// transform must see the REINDEXED value below instead, not this one.
|
||||
data: { canonicalModes: { '1:credential': 'advanced' } },
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
},
|
||||
sourceMeta: { name: 'Reindex test', description: null, folderId: null, sortOrder: 0 },
|
||||
workflowIdMap: new Map(),
|
||||
folderIdMap: new Map(),
|
||||
nameRegistry: buildWorkflowNameRegistry([]),
|
||||
// Simulates a `tool-input` drop shifting tool 1 -> 0: returns subBlocks unchanged but
|
||||
// reports the reindexed canonicalModes via the callback, exactly like
|
||||
// `createForkBootstrapTransform`/`createForkSubBlockTransform` do.
|
||||
transformSubBlocks: (subBlocks, _blockType, canonicalModes, onCanonicalModesChanged) => {
|
||||
seenCanonicalModes.push(canonicalModes)
|
||||
onCanonicalModesChanged?.({ '0:credential': 'advanced' })
|
||||
return subBlocks
|
||||
},
|
||||
})
|
||||
|
||||
const [, remappedState] = mockSaveWorkflowToNormalizedTables.mock.calls.at(-1)!
|
||||
const persistedBlock = Object.values(remappedState.blocks)[0] as {
|
||||
data?: { canonicalModes?: Record<string, 'basic' | 'advanced'> }
|
||||
}
|
||||
// The transform received the source's original value...
|
||||
expect(seenCanonicalModes).toEqual([{ '1:credential': 'advanced' }])
|
||||
// ...and the PERSISTED block carries the reindexed one, not the stale source value.
|
||||
expect(persistedBlock.data?.canonicalModes).toEqual({ '0:credential': 'advanced' })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,610 @@
|
||||
import { workflow, workflowBlocks, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import {
|
||||
remapConditionIdsInSubBlocks,
|
||||
remapVariableIdsInSubBlocks,
|
||||
remapWorkflowReferencesInSubBlocks,
|
||||
type SubBlockRecord,
|
||||
sanitizeSubBlocksForDuplicate,
|
||||
} from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
|
||||
import {
|
||||
deriveForkBlockId,
|
||||
type ForkBlockIdResolver,
|
||||
} from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import {
|
||||
applyDependentOverrides,
|
||||
collectClearedDependents,
|
||||
type NeedsConfigurationField,
|
||||
type SubBlockTransform,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
import type {
|
||||
BlockData,
|
||||
BlockState,
|
||||
Loop,
|
||||
Parallel,
|
||||
SubBlockState,
|
||||
WorkflowState,
|
||||
Variable as WorkflowStateVariable,
|
||||
} from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCopyWorkflows')
|
||||
|
||||
interface ResolveForkFolderMappingParams {
|
||||
tx: DbOrTx
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
userId: string
|
||||
now: Date
|
||||
/**
|
||||
* Source folder ids that will directly hold copied content (workflows); null entries
|
||||
* (root-placed content) are ignored. A source folder is copied into the target only when
|
||||
* its subtree contains at least one of these, so a fork/sync never creates folders that
|
||||
* would end up empty. Copied workspace FILES never influence this set: they live in the
|
||||
* separate `workspace_file_folders` entity and are flattened to root by the copy.
|
||||
*/
|
||||
contentFolderIds: ReadonlyArray<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror into the target workspace the part of the source folder tree that will actually
|
||||
* receive copied content: the folders in `contentFolderIds` plus their ancestor chains (so
|
||||
* nesting stays intact). Target folders that already match by name within the same (mapped)
|
||||
* parent are reused instead of duplicated. Folders whose subtree holds no copied content are
|
||||
* pruned - never created - though a pruned folder still maps onto an existing target folder
|
||||
* when one matches, so previously-synced content refs keep resolving. Returns a map from
|
||||
* source folder id to target folder id; a copied workflow whose folder is absent from the
|
||||
* map is placed at the target's root (see {@link copyWorkflowStateIntoTarget}).
|
||||
*/
|
||||
export async function resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
contentFolderIds,
|
||||
}: ResolveForkFolderMappingParams): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
|
||||
const sourceFolders = await tx
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(eq(workflowFolder.workspaceId, sourceWorkspaceId), isNull(workflowFolder.archivedAt))
|
||||
)
|
||||
|
||||
if (sourceFolders.length === 0) return map
|
||||
|
||||
const byId = new Map(sourceFolders.map((folder) => [folder.id, folder]))
|
||||
|
||||
// Kept = folders that directly hold copied content plus every ancestor; everything else
|
||||
// would be empty in the target and is pruned. A dangling (archived) parent ends the walk,
|
||||
// matching the re-root fallback below.
|
||||
const kept = new Set<string>()
|
||||
for (const folderId of contentFolderIds) {
|
||||
let current = folderId ? byId.get(folderId) : undefined
|
||||
while (current && !kept.has(current.id)) {
|
||||
kept.add(current.id)
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const targetFolders = await tx
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(eq(workflowFolder.workspaceId, targetWorkspaceId), isNull(workflowFolder.archivedAt))
|
||||
)
|
||||
|
||||
const targetByKey = new Map<string, string>()
|
||||
for (const folder of targetFolders) {
|
||||
targetByKey.set(`${folder.parentId ?? ''}::${folder.name}`, folder.id)
|
||||
}
|
||||
|
||||
const ordered: typeof sourceFolders = []
|
||||
const seen = new Set<string>()
|
||||
const visit = (folder: (typeof sourceFolders)[number]) => {
|
||||
if (seen.has(folder.id)) return
|
||||
const parent = folder.parentId ? byId.get(folder.parentId) : undefined
|
||||
if (parent) visit(parent)
|
||||
seen.add(folder.id)
|
||||
ordered.push(folder)
|
||||
}
|
||||
for (const folder of sourceFolders) visit(folder)
|
||||
|
||||
const newFolders: (typeof sourceFolders)[number][] = []
|
||||
for (const folder of ordered) {
|
||||
const isKept = kept.has(folder.id)
|
||||
const mappedParentId = folder.parentId ? (map.get(folder.parentId) ?? null) : null
|
||||
const key = `${mappedParentId ?? ''}::${folder.name}`
|
||||
const existing = targetByKey.get(key)
|
||||
if (existing) {
|
||||
// A pruned folder may still MAP onto an existing target folder, but only when its
|
||||
// parent chain actually resolved: an unmapped pruned parent aliases the key to root
|
||||
// level, which could match an unrelated same-named root folder.
|
||||
if (isKept || !folder.parentId || map.has(folder.parentId)) {
|
||||
map.set(folder.id, existing)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!isKept) continue
|
||||
const newFolderId = generateId()
|
||||
map.set(folder.id, newFolderId)
|
||||
targetByKey.set(key, newFolderId)
|
||||
newFolders.push({
|
||||
...folder,
|
||||
id: newFolderId,
|
||||
userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
parentId: mappedParentId,
|
||||
locked: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (newFolders.length > 0) {
|
||||
await tx.insert(workflowFolder).values(newFolders)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
// `\u0000` (a NUL byte) can never appear in a Postgres text column, so it is a
|
||||
// collision-free separator between the folder id and the name.
|
||||
const workflowNameKey = (folderId: string | null, name: string) => `${folderId ?? ''}\u0000${name}`
|
||||
|
||||
/**
|
||||
* In-memory registry of a workspace's active workflow names, keyed by
|
||||
* (folderId, name) - the exact columns of the `workflow_workspace_folder_name_active_unique`
|
||||
* partial index. Lets fork/promote resolve name collisions across many copied workflows
|
||||
* from a single load instead of one `nameTaken` query per workflow inside the (locked)
|
||||
* transaction.
|
||||
*
|
||||
* Correctness is still guaranteed by that DB unique index; this is only a proactive
|
||||
* collision avoider. A stale snapshot (e.g. a concurrent non-fork rename mid-promote) can
|
||||
* therefore only cause a rare, retry-able unique violation - never a duplicate name -
|
||||
* exactly as the prior per-workflow check-then-write already could.
|
||||
*/
|
||||
export interface WorkflowNameRegistry {
|
||||
/** True when (folderId, name) is held by any active workflow other than `excludeWorkflowId`. */
|
||||
isTaken(folderId: string | null, name: string, excludeWorkflowId: string | null): boolean
|
||||
/** Record that `workflowId` now holds (folderId, name), releasing any name it held before. */
|
||||
claim(folderId: string | null, name: string, workflowId: string): void
|
||||
}
|
||||
|
||||
/** Build a {@link WorkflowNameRegistry} from already-loaded rows (pure - unit-testable). */
|
||||
export function buildWorkflowNameRegistry(
|
||||
rows: Array<{ id: string; folderId: string | null; name: string }>
|
||||
): WorkflowNameRegistry {
|
||||
const holdersByKey = new Map<string, Set<string>>()
|
||||
const keyByWorkflow = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
const key = workflowNameKey(row.folderId, row.name)
|
||||
const holders = holdersByKey.get(key)
|
||||
if (holders) holders.add(row.id)
|
||||
else holdersByKey.set(key, new Set([row.id]))
|
||||
keyByWorkflow.set(row.id, key)
|
||||
}
|
||||
|
||||
return {
|
||||
isTaken(folderId, name, excludeWorkflowId) {
|
||||
const holders = holdersByKey.get(workflowNameKey(folderId, name))
|
||||
if (!holders) return false
|
||||
for (const id of holders) if (id !== excludeWorkflowId) return true
|
||||
return false
|
||||
},
|
||||
claim(folderId, name, workflowId) {
|
||||
const newKey = workflowNameKey(folderId, name)
|
||||
const prevKey = keyByWorkflow.get(workflowId)
|
||||
if (prevKey === newKey) return
|
||||
if (prevKey) holdersByKey.get(prevKey)?.delete(workflowId)
|
||||
const holders = holdersByKey.get(newKey)
|
||||
if (holders) holders.add(workflowId)
|
||||
else holdersByKey.set(newKey, new Set([workflowId]))
|
||||
keyByWorkflow.set(workflowId, newKey)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Load every active workflow name in a workspace into a {@link WorkflowNameRegistry}. */
|
||||
export async function loadWorkflowNameRegistry(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<WorkflowNameRegistry> {
|
||||
const rows = await executor
|
||||
.select({ id: workflow.id, folderId: workflow.folderId, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
|
||||
return buildWorkflowNameRegistry(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched read of the current DRAFT subBlocks for a set of (replace) target
|
||||
* workflows, keyed `workflowId -> blockId -> subBlocks`. One query for the whole
|
||||
* promote so the locked apply phase doesn't do N per-workflow loads; called
|
||||
* pre-write so it reflects the target state the user configured before this sync
|
||||
* overwrites it. Promote uses it to detect required dependents the sync left empty
|
||||
* (see {@link collectClearedDependents}); fork-create has no prior target and skips it.
|
||||
*/
|
||||
export async function loadTargetDraftSubBlocks(
|
||||
executor: DbOrTx,
|
||||
workflowIds: string[]
|
||||
): Promise<Map<string, Map<string, SubBlockRecord>>> {
|
||||
const byWorkflow = new Map<string, Map<string, SubBlockRecord>>()
|
||||
if (workflowIds.length === 0) return byWorkflow
|
||||
const rows = await executor
|
||||
.select({
|
||||
workflowId: workflowBlocks.workflowId,
|
||||
blockId: workflowBlocks.id,
|
||||
subBlocks: workflowBlocks.subBlocks,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(inArray(workflowBlocks.workflowId, workflowIds))
|
||||
for (const row of rows) {
|
||||
let blocks = byWorkflow.get(row.workflowId)
|
||||
if (!blocks) {
|
||||
blocks = new Map<string, SubBlockRecord>()
|
||||
byWorkflow.set(row.workflowId, blocks)
|
||||
}
|
||||
blocks.set(row.blockId, (row.subBlocks ?? {}) as SubBlockRecord)
|
||||
}
|
||||
return byWorkflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a non-colliding name for a copied workflow against the preloaded registry, which
|
||||
* mirrors the workspace's (folder, name, not-archived, exclude-self) predicate from one
|
||||
* query instead of one per candidate. Mirrors {@link deduplicateWorkflowName}'s ` (n)`
|
||||
* numbering, but reads from memory so the copy loop issues no per-workflow name queries.
|
||||
*/
|
||||
function resolveTargetWorkflowName(
|
||||
registry: WorkflowNameRegistry,
|
||||
folderId: string | null,
|
||||
name: string,
|
||||
excludeWorkflowId: string | null
|
||||
): string {
|
||||
const taken = (candidate: string) => registry.isTaken(folderId, candidate, excludeWorkflowId)
|
||||
if (!taken(name)) return name
|
||||
for (let i = 2; i < 100; i++) {
|
||||
const candidate = `${name} (${i})`
|
||||
if (!taken(candidate)) return candidate
|
||||
}
|
||||
return `${name} (${generateId().slice(0, 6)})`
|
||||
}
|
||||
|
||||
export interface CopyWorkflowResult {
|
||||
targetWorkflowId: string
|
||||
mode: 'create' | 'replace'
|
||||
name: string
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
/**
|
||||
* `dependsOn` fields (top-level and nested tool-input) a remapped parent left empty
|
||||
* that weren't restored from the target draft - the parent legitimately changed.
|
||||
* Carries `required` per field: promote skips redeploy + gates on required ones and
|
||||
* surfaces optional ones so a cleared filter never broadens behavior silently.
|
||||
*/
|
||||
clearedDependents: NeedsConfigurationField[]
|
||||
/**
|
||||
* Source block id -> assigned target block id, so the caller can persist the
|
||||
* block-identity pairs (see `recordForkBlockPairs`) that keep promotes reversible.
|
||||
*/
|
||||
blockIdMapping: Map<string, string>
|
||||
}
|
||||
|
||||
export interface CopyWorkflowStateParams {
|
||||
tx: DbOrTx
|
||||
targetWorkflowId: string
|
||||
targetWorkspaceId: string
|
||||
userId: string
|
||||
mode: 'create' | 'replace'
|
||||
now: Date
|
||||
/** Source workflow's deployed state (the only thing fork/promote copies). */
|
||||
sourceState: WorkflowState
|
||||
/** Source workflow metadata for naming, folder placement, and sort order. */
|
||||
sourceMeta: {
|
||||
name: string
|
||||
description: string | null
|
||||
folderId: string | null
|
||||
sortOrder: number
|
||||
/**
|
||||
* Whether the source's deployed API is public (unauthenticated). Carried onto sync targets
|
||||
* so a public source stays public after push/pull - the target org's own access-control
|
||||
* gate (`validatePublicApiAllowed`) still applies at execution. Omitted at fork-create:
|
||||
* the child starts undeployed and private (going public is an explicit act there).
|
||||
*/
|
||||
isPublicApi?: boolean
|
||||
}
|
||||
/** source workflow id -> target workflow id, for `workflow-selector` references */
|
||||
workflowIdMap: Map<string, string>
|
||||
/** source folder id -> target folder id */
|
||||
folderIdMap: Map<string, string>
|
||||
/** Optional resource-reference remap applied to every block's subBlocks. */
|
||||
transformSubBlocks?: SubBlockTransform
|
||||
/**
|
||||
* The target workflow's current draft subBlocks (block id -> subBlocks), for
|
||||
* `replace` mode only. When present, required dependents that the sync left empty
|
||||
* (the parent change cleared and the stored mapping didn't fill) are reported in
|
||||
* {@link CopyWorkflowResult.needsConfiguration}.
|
||||
*/
|
||||
targetCurrentBlocks?: Map<string, SubBlockRecord>
|
||||
/**
|
||||
* Per-block (block id -> subBlock key -> value) stored dependent values applied last,
|
||||
* after the reference transform cleared the source's, so the stored mapping is the sole
|
||||
* source of truth for what each dependent selector resolves to.
|
||||
*/
|
||||
dependentOverrides?: Map<string, Map<string, string>>
|
||||
/**
|
||||
* Preloaded name registry so name-collision resolution reads from memory instead of one
|
||||
* query per workflow inside the tx. Build once per copy loop via {@link loadWorkflowNameRegistry}.
|
||||
*/
|
||||
nameRegistry: WorkflowNameRegistry
|
||||
/**
|
||||
* Resolve each source block to its target block id, reusing the persisted counterpart
|
||||
* when one exists so a push keeps the parent's original block ids (and webhook URLs)
|
||||
* instead of re-deriving them (see {@link buildForkBlockIdResolver}). Omitted on fork
|
||||
* creation, where every id is derived fresh.
|
||||
*/
|
||||
resolveBlockId?: ForkBlockIdResolver
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a source workflow's deployed `WorkflowState` into a target workflow,
|
||||
* assigning deterministic block ids (so trigger webhook URLs and external block
|
||||
* references stay stable across promotes) and applying the resource-reference
|
||||
* transform. Writes the remapped state to the target's draft via
|
||||
* `saveWorkflowToNormalizedTables`. In `create` mode a new workflow row is
|
||||
* inserted (undeployed); in `replace` mode the existing target row is kept and
|
||||
* its draft is overwritten. Deploying the target (and capturing the rollback
|
||||
* point) is the caller's responsibility.
|
||||
*/
|
||||
export async function copyWorkflowStateIntoTarget(
|
||||
params: CopyWorkflowStateParams
|
||||
): Promise<CopyWorkflowResult> {
|
||||
const {
|
||||
tx,
|
||||
targetWorkflowId,
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
mode,
|
||||
now,
|
||||
sourceState,
|
||||
sourceMeta,
|
||||
workflowIdMap,
|
||||
folderIdMap,
|
||||
transformSubBlocks,
|
||||
targetCurrentBlocks,
|
||||
dependentOverrides,
|
||||
nameRegistry,
|
||||
resolveBlockId,
|
||||
requestId = 'unknown',
|
||||
} = params
|
||||
|
||||
const targetFolderId = sourceMeta.folderId ? (folderIdMap.get(sourceMeta.folderId) ?? null) : null
|
||||
|
||||
const varIdMapping = new Map<string, string>()
|
||||
const remappedVariables: Record<string, WorkflowStateVariable> = {}
|
||||
for (const [oldVarId, variable] of Object.entries(sourceState.variables ?? {})) {
|
||||
const newVarId = generateId()
|
||||
varIdMapping.set(oldVarId, newVarId)
|
||||
remappedVariables[newVarId] = { ...variable, id: newVarId }
|
||||
}
|
||||
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
for (const oldBlockId of Object.keys(sourceState.blocks)) {
|
||||
blockIdMapping.set(
|
||||
oldBlockId,
|
||||
resolveBlockId
|
||||
? resolveBlockId(targetWorkflowId, oldBlockId)
|
||||
: deriveForkBlockId(targetWorkflowId, oldBlockId)
|
||||
)
|
||||
}
|
||||
|
||||
const newBlocks: Record<string, BlockState> = {}
|
||||
const clearedDependents: NeedsConfigurationField[] = []
|
||||
for (const [oldBlockId, block] of Object.entries(sourceState.blocks)) {
|
||||
const newBlockId = blockIdMapping.get(oldBlockId)!
|
||||
|
||||
let updatedData = block.data
|
||||
if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) {
|
||||
const dataObj = block.data as Record<string, unknown>
|
||||
if (typeof dataObj.parentId === 'string' && blockIdMapping.has(dataObj.parentId)) {
|
||||
updatedData = {
|
||||
...dataObj,
|
||||
parentId: blockIdMapping.get(dataObj.parentId)!,
|
||||
extent: 'parent',
|
||||
} as BlockData
|
||||
}
|
||||
}
|
||||
|
||||
// double-cast-allowed: SubBlockState is structurally a SubBlockRecord entry but lacks the open index signature SubBlockRecord declares
|
||||
const sourceSubBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
|
||||
const sanitizedSource = sanitizeSubBlocksForDuplicate(sourceSubBlocks)
|
||||
let subBlocks: SubBlockRecord = sanitizedSource
|
||||
// Tracks the block's live `canonicalModes` through this pass, so a `tool-input` reindex
|
||||
// (a dropped custom-tool/MCP entry shifts later tools' array positions) is visible to every
|
||||
// later step below that resolves a nested tool's basic/advanced mode - not just the final
|
||||
// persisted `updatedData`. Starts as the source value; `transformSubBlocks` may replace it.
|
||||
let activeCanonicalModes: CanonicalModeOverrides | undefined = (
|
||||
block.data as { canonicalModes?: Record<string, 'basic' | 'advanced'> } | undefined
|
||||
)?.canonicalModes
|
||||
if (transformSubBlocks) {
|
||||
subBlocks = transformSubBlocks(subBlocks, block.type, activeCanonicalModes, (next) => {
|
||||
activeCanonicalModes = next
|
||||
updatedData = { ...updatedData, canonicalModes: next } as BlockData
|
||||
})
|
||||
}
|
||||
if (varIdMapping.size > 0) {
|
||||
subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping)
|
||||
}
|
||||
// Cross-workspace copy: clear references to workflows that weren't copied
|
||||
// rather than leave them pointing at the source workspace.
|
||||
subBlocks = remapWorkflowReferencesInSubBlocks(subBlocks, workflowIdMap, {
|
||||
clearUnmapped: true,
|
||||
canonicalModes: activeCanonicalModes,
|
||||
})
|
||||
subBlocks = remapConditionIdsInSubBlocks(subBlocks, oldBlockId, newBlockId) as SubBlockRecord
|
||||
|
||||
// Apply the stored dependent values for this block (the modal's mapping). The reference
|
||||
// transform already cleared the source's dependent values when their parent was remapped,
|
||||
// so the stored mapping is the SOLE source of truth - no implicit "preserve the target's
|
||||
// value" path. Allowlisted (top-level + nested tool params) inside applyDependentOverrides
|
||||
// so a crafted value can't touch a parent/credential field or inject a bogus subblock.
|
||||
const targetCurrent = targetCurrentBlocks?.get(newBlockId)
|
||||
const blockOverrides = dependentOverrides?.get(newBlockId)
|
||||
if (blockOverrides && blockOverrides.size > 0) {
|
||||
subBlocks = applyDependentOverrides(subBlocks, block.type, blockOverrides)
|
||||
}
|
||||
|
||||
// Dependents the TARGET had configured that the parent change cleared and nothing
|
||||
// restored: the target must re-pick required ones (promote skips this workflow's
|
||||
// redeploy) and is told about optional ones. Keyed on the target draft so a field the
|
||||
// source carried but the target never set isn't flagged.
|
||||
if (mode === 'replace' && targetCurrent) {
|
||||
clearedDependents.push(
|
||||
...collectClearedDependents(
|
||||
block.type,
|
||||
newBlockId,
|
||||
block.name,
|
||||
targetCurrent,
|
||||
subBlocks,
|
||||
activeCanonicalModes
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
newBlocks[newBlockId] = {
|
||||
...block,
|
||||
id: newBlockId,
|
||||
// double-cast-allowed: remap helpers return SubBlockRecord; the entries retain the SubBlockState shape this block requires
|
||||
subBlocks: subBlocks as unknown as Record<string, SubBlockState>,
|
||||
data: updatedData,
|
||||
}
|
||||
}
|
||||
|
||||
const newEdges = sourceState.edges.flatMap((edge) => {
|
||||
const newSource = blockIdMapping.get(edge.source)
|
||||
const newTarget = blockIdMapping.get(edge.target)
|
||||
if (!newSource || !newTarget) {
|
||||
logger.warn(`[${requestId}] Skipping edge with unmapped block reference during fork copy`, {
|
||||
edgeId: edge.id,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const newSourceHandle = edge.sourceHandle
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
|
||||
: edge.sourceHandle
|
||||
return [
|
||||
{
|
||||
...edge,
|
||||
id: generateId(),
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
sourceHandle: newSourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const newLoops: Record<string, Loop> = {}
|
||||
for (const [oldId, loop] of Object.entries(sourceState.loops ?? {})) {
|
||||
const newId = blockIdMapping.get(oldId) ?? oldId
|
||||
newLoops[newId] = {
|
||||
...loop,
|
||||
id: newId,
|
||||
nodes: loop.nodes.flatMap((nodeId) => {
|
||||
const mapped = blockIdMapping.get(nodeId)
|
||||
return mapped ? [mapped] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const newParallels: Record<string, Parallel> = {}
|
||||
for (const [oldId, parallel] of Object.entries(sourceState.parallels ?? {})) {
|
||||
const newId = blockIdMapping.get(oldId) ?? oldId
|
||||
newParallels[newId] = {
|
||||
...parallel,
|
||||
id: newId,
|
||||
nodes: parallel.nodes.flatMap((nodeId) => {
|
||||
const mapped = blockIdMapping.get(nodeId)
|
||||
return mapped ? [mapped] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedName = resolveTargetWorkflowName(
|
||||
nameRegistry,
|
||||
targetFolderId,
|
||||
sourceMeta.name,
|
||||
mode === 'replace' ? targetWorkflowId : null
|
||||
)
|
||||
// Claim the resolved name so the next workflow in this copy loop sees it taken. The DB
|
||||
// write below uses the same (folderId, name), so the registry stays consistent with it.
|
||||
nameRegistry.claim(targetFolderId, resolvedName, targetWorkflowId)
|
||||
|
||||
if (mode === 'create') {
|
||||
await tx.insert(workflow).values({
|
||||
id: targetWorkflowId,
|
||||
userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
folderId: targetFolderId,
|
||||
sortOrder: sourceMeta.sortOrder,
|
||||
name: resolvedName,
|
||||
description: sourceMeta.description,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
locked: false,
|
||||
variables: remappedVariables,
|
||||
// Deployment visibility follows the source on sync (a public source stays public in
|
||||
// the target); fork-create omits the field, so the child starts private.
|
||||
...(sourceMeta.isPublicApi !== undefined ? { isPublicApi: sourceMeta.isPublicApi } : {}),
|
||||
})
|
||||
} else {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
name: resolvedName,
|
||||
description: sourceMeta.description,
|
||||
folderId: targetFolderId,
|
||||
variables: remappedVariables,
|
||||
lastSynced: now,
|
||||
updatedAt: now,
|
||||
...(sourceMeta.isPublicApi !== undefined ? { isPublicApi: sourceMeta.isPublicApi } : {}),
|
||||
})
|
||||
.where(eq(workflow.id, targetWorkflowId))
|
||||
}
|
||||
|
||||
const remappedState: WorkflowState = {
|
||||
blocks: newBlocks,
|
||||
edges: newEdges,
|
||||
loops: newLoops,
|
||||
parallels: newParallels,
|
||||
variables: remappedVariables,
|
||||
}
|
||||
const saved = await saveWorkflowToNormalizedTables(targetWorkflowId, remappedState, tx)
|
||||
if (!saved.success) {
|
||||
throw new Error(`Failed to write forked workflow ${targetWorkflowId}: ${saved.error}`)
|
||||
}
|
||||
|
||||
return {
|
||||
targetWorkflowId,
|
||||
mode,
|
||||
name: resolvedName,
|
||||
blocksCount: Object.keys(newBlocks).length,
|
||||
edgesCount: newEdges.length,
|
||||
subflowsCount: Object.keys(newLoops).length + Object.keys(newParallels).length,
|
||||
clearedDependents,
|
||||
blockIdMapping,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { db, runOutsideTransactionContext } from '@sim/db'
|
||||
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
||||
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkspaceForkDeployBridge')
|
||||
|
||||
/**
|
||||
* Hard ceiling on how many deployed workflows one fork/promote loads into memory at
|
||||
* once (each as a full `WorkflowState`). There is no per-workspace workflow cap in
|
||||
* the product, so this is the safety valve: real workspaces hold tens to low
|
||||
* hundreds, making this ~5-10x headroom that never blocks legitimate use, it sits
|
||||
* below the fork feature's other item caps (resource selection 2000, mapping
|
||||
* entries 5000 - both lighter-weight than full states), and it bounds a pathological
|
||||
* workspace to a few hundred MB of transient state instead of an unbounded load.
|
||||
*/
|
||||
export const MAX_FORK_DEPLOYED_WORKFLOWS = 1000
|
||||
|
||||
export interface DeployedWorkflowSummary {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
folderId: string | null
|
||||
sortOrder: number
|
||||
/** Whether the deployed API accepts unauthenticated calls; carried onto sync targets. */
|
||||
isPublicApi: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflows in a workspace that are deployed and not archived - the only ones that
|
||||
* fork/promote. Requires an actually-active deployment version, not just the
|
||||
* `isDeployed` flag: a workflow flagged deployed with no active version (a "ghost"
|
||||
* left by an inconsistent state) has nothing to copy, so excluding it here keeps the
|
||||
* diff/plan counts aligned with what apply actually writes instead of over-reporting
|
||||
* then silently skipping it. Correlated `exists` (not a join) so a workflow is never
|
||||
* double-listed if more than one active version row ever exists.
|
||||
*/
|
||||
export async function listDeployedWorkflows(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<DeployedWorkflowSummary[]> {
|
||||
return executor
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder,
|
||||
isPublicApi: workflow.isPublicApi,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
eq(workflow.isDeployed, true),
|
||||
isNull(workflow.archivedAt),
|
||||
exists(
|
||||
db
|
||||
.select({ one: sql`1` })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** The active deployment version number for a workflow, or null when it has none. */
|
||||
export async function getActiveDeploymentVersionNumber(
|
||||
executor: DbOrTx,
|
||||
workflowId: string
|
||||
): Promise<number | null> {
|
||||
const [row] = await executor
|
||||
.select({ version: workflowDeploymentVersion.version })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row?.version ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched {@link getActiveDeploymentVersionNumber}: the active deployed version per
|
||||
* workflow id, so promote's apply phase resolves every prior version in one query
|
||||
* instead of N round-trips inside the (locked) transaction. Workflows with no active
|
||||
* version are absent from the map.
|
||||
*/
|
||||
export async function getActiveDeploymentVersionNumbers(
|
||||
executor: DbOrTx,
|
||||
workflowIds: string[]
|
||||
): Promise<Map<string, number>> {
|
||||
if (workflowIds.length === 0) return new Map()
|
||||
const rows = await executor
|
||||
.select({
|
||||
workflowId: workflowDeploymentVersion.workflowId,
|
||||
version: workflowDeploymentVersion.version,
|
||||
})
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
inArray(workflowDeploymentVersion.workflowId, workflowIds),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
return new Map(rows.map((row) => [row.workflowId, row.version]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a source workspace's deployed workflows and each one's active deployed state
|
||||
* on the global pool. Fork/promote callers MUST run this BEFORE opening their
|
||||
* transaction: doing these heavy per-workflow reads inside the tx checks out a
|
||||
* SECOND pooled connection while the tx holds the first, which can deadlock the
|
||||
* pool at saturation (primary pool max is 15). The source is read-only for the
|
||||
* operation, so a pre-transaction snapshot is the value that gets force-pushed.
|
||||
*
|
||||
* Holds every source state in memory at once (bounded by the workspace's deployed
|
||||
* workflow count) - the apply step needs each state to write its target inside the
|
||||
* single atomic transaction, so it cannot stream them one at a time.
|
||||
*/
|
||||
export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promise<{
|
||||
deployedWorkflows: DeployedWorkflowSummary[]
|
||||
sourceStates: Map<string, WorkflowState>
|
||||
}> {
|
||||
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
|
||||
// Fail fast on the cheap count before loading any heavy state into memory.
|
||||
if (deployedWorkflows.length > MAX_FORK_DEPLOYED_WORKFLOWS) {
|
||||
throw new ForkError(
|
||||
`This workspace has ${deployedWorkflows.length} deployed workflows, which exceeds the fork/sync limit of ${MAX_FORK_DEPLOYED_WORKFLOWS}.`,
|
||||
400
|
||||
)
|
||||
}
|
||||
// Read states in bounded-concurrency batches instead of one serial await per workflow:
|
||||
// serial cost is O(workflows) round trips (this also runs on the diff preview, refetched
|
||||
// while the sync modal is open). The cap keeps concurrent global-pool checkouts well
|
||||
// under the pool max even at the workflow ceiling, and this runs BEFORE any transaction.
|
||||
const sourceStates = new Map<string, WorkflowState>()
|
||||
const READ_CONCURRENCY = 5
|
||||
for (let i = 0; i < deployedWorkflows.length; i += READ_CONCURRENCY) {
|
||||
const batch = deployedWorkflows.slice(i, i + READ_CONCURRENCY)
|
||||
const states = await Promise.all(batch.map((wf) => readDeployedState(wf.id, sourceWorkspaceId)))
|
||||
batch.forEach((wf, index) => {
|
||||
const state = states[index]
|
||||
if (state) sourceStates.set(wf.id, state)
|
||||
})
|
||||
}
|
||||
return { deployedWorkflows, sourceStates }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a workflow's active deployed state as a `WorkflowState`. Returns null ONLY
|
||||
* when the workflow genuinely has no active deployment (a legitimate skip); real
|
||||
* DB/migration errors propagate so the caller fails loudly instead of silently
|
||||
* dropping the workflow from the fork/promote. Block migrations (credential remap
|
||||
* to current ids) are applied so copied references reflect current resources.
|
||||
*/
|
||||
export async function readDeployedState(
|
||||
workflowId: string,
|
||||
workspaceId: string
|
||||
): Promise<WorkflowState | null> {
|
||||
// This reads the (unchanged) SOURCE workspace on the global pool. Callers like
|
||||
// promote run it inside their transaction, so escape the tx context: the read
|
||||
// must not join the promote's transaction (and the tripwire forbids global-pool
|
||||
// queries inside a tx). Outside a transaction this is a no-op.
|
||||
return runOutsideTransactionContext(async () => {
|
||||
const version = await getActiveDeploymentVersionNumber(db, workflowId)
|
||||
if (version == null) {
|
||||
logger.warn('No active deployment for workflow during fork/promote', { workflowId })
|
||||
return null
|
||||
}
|
||||
const data = await loadDeployedWorkflowState(workflowId, workspaceId)
|
||||
return {
|
||||
blocks: data.blocks,
|
||||
edges: data.edges,
|
||||
loops: data.loops,
|
||||
parallels: data.parallels,
|
||||
variables: (data.variables ?? {}) as Record<string, Variable>,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckStorageQuota } = vi.hoisted(() => ({
|
||||
mockCheckStorageQuota: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/storage', () => ({
|
||||
checkStorageQuota: mockCheckStorageQuota,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Minimal stand-in for the domain error so this unit test never loads the authz module's
|
||||
* billing/feature-flag import chain. Shape-compatible with the real `ForkError`.
|
||||
*/
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
|
||||
ForkError: class ForkError extends Error {
|
||||
statusCode: number
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message)
|
||||
this.name = 'ForkError'
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
assertForkStorageHeadroom,
|
||||
sumForkCopyBytes,
|
||||
} from '@/ee/workspace-forking/lib/copy/storage-quota'
|
||||
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
|
||||
/**
|
||||
* Fake executor resolving one aggregate row per query, in call order. Supports both sum
|
||||
* shapes: `select().from().where()` (files) and `select().from().innerJoin().where()` (KB
|
||||
* documents joined to their live KB row).
|
||||
*/
|
||||
function makeExecutor(totals: Array<number | string>) {
|
||||
let call = 0
|
||||
const next = () => Promise.resolve([{ total: totals[call++] ?? 0 }])
|
||||
const select = vi.fn(() => ({
|
||||
from: () => ({
|
||||
where: next,
|
||||
innerJoin: () => ({ where: next }),
|
||||
}),
|
||||
}))
|
||||
return { executor: { select } as unknown as DbOrTx, select }
|
||||
}
|
||||
|
||||
describe('sumForkCopyBytes', () => {
|
||||
it('adds the workspace-file and KB-document byte sums', async () => {
|
||||
const { executor, select } = makeExecutor([300, 700])
|
||||
|
||||
const bytes = await sumForkCopyBytes(executor, 'src-ws', {
|
||||
fileIds: ['wf-1'],
|
||||
knowledgeBaseIds: ['kb-1'],
|
||||
})
|
||||
|
||||
expect(bytes).toBe(1000)
|
||||
expect(select).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('coerces driver string aggregates (bigint sums) to numbers', async () => {
|
||||
const { executor } = makeExecutor(['1024'])
|
||||
|
||||
const bytes = await sumForkCopyBytes(executor, 'src-ws', { fileKeys: ['workspace/src/k1'] })
|
||||
|
||||
expect(bytes).toBe(1024)
|
||||
})
|
||||
|
||||
it('runs no query for an empty selection', async () => {
|
||||
const { executor, select } = makeExecutor([])
|
||||
|
||||
const bytes = await sumForkCopyBytes(executor, 'src-ws', {
|
||||
fileIds: [],
|
||||
fileKeys: [],
|
||||
knowledgeBaseIds: [],
|
||||
})
|
||||
|
||||
expect(bytes).toBe(0)
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the file query when only KBs are selected (and vice versa)', async () => {
|
||||
const { executor, select } = makeExecutor([555])
|
||||
|
||||
const bytes = await sumForkCopyBytes(executor, 'src-ws', { knowledgeBaseIds: ['kb-1'] })
|
||||
|
||||
expect(bytes).toBe(555)
|
||||
expect(select).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertForkStorageHeadroom', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('never consults the quota helper for zero bytes', async () => {
|
||||
await assertForkStorageHeadroom({ userId: 'user-1', bytes: 0 })
|
||||
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves when the scope has headroom', async () => {
|
||||
mockCheckStorageQuota.mockResolvedValue({ allowed: true, currentUsage: 10, limit: 100 })
|
||||
|
||||
await expect(
|
||||
assertForkStorageHeadroom({ userId: 'user-1', bytes: 50 })
|
||||
).resolves.toBeUndefined()
|
||||
expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 50)
|
||||
})
|
||||
|
||||
it("throws a 413 ForkError carrying the upload path's quota message when over quota", async () => {
|
||||
mockCheckStorageQuota.mockResolvedValue({
|
||||
allowed: false,
|
||||
currentUsage: 99,
|
||||
limit: 100,
|
||||
error: 'Storage limit exceeded. Used: 10.50GB, Limit: 10GB',
|
||||
})
|
||||
|
||||
const rejection = expect(assertForkStorageHeadroom({ userId: 'user-1', bytes: 50 })).rejects
|
||||
await rejection.toBeInstanceOf(ForkError)
|
||||
await rejection.toMatchObject({
|
||||
statusCode: 413,
|
||||
message:
|
||||
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a generic storage message when the quota helper omits one', async () => {
|
||||
mockCheckStorageQuota.mockResolvedValue({ allowed: false, currentUsage: 0, limit: 0 })
|
||||
|
||||
await expect(assertForkStorageHeadroom({ userId: 'user-1', bytes: 1 })).rejects.toThrow(
|
||||
'Not enough storage to copy the selected resources. Storage limit exceeded'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { document, knowledgeBase, workspaceFiles } from '@sim/db/schema'
|
||||
import { and, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'
|
||||
import { checkStorageQuota } from '@/lib/billing/storage'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
|
||||
/** Resource ids whose blob bytes a fork/sync copy would duplicate into the target. */
|
||||
export interface ForkCopyBytesSelection {
|
||||
/** Workspace files selected by `workspace_files.id` (the fork modal's picker shape). */
|
||||
fileIds?: string[]
|
||||
/** Workspace files selected by storage key (the sync copy selection shape). */
|
||||
fileKeys?: string[]
|
||||
/** Knowledge bases whose live documents' stored blobs would be re-keyed into the target. */
|
||||
knowledgeBaseIds?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte total of the workspace-file blobs a copy selection would duplicate. Applies the
|
||||
* same row filters as `planForkFileCopies` (source workspace, durable `workspace`
|
||||
* context, non-deleted, id/key selectors OR'd), so the sum covers exactly the rows the
|
||||
* copy would plan.
|
||||
*/
|
||||
async function sumWorkspaceFileBytes(
|
||||
executor: DbOrTx,
|
||||
sourceWorkspaceId: string,
|
||||
fileIds: string[],
|
||||
fileKeys: string[]
|
||||
): Promise<number> {
|
||||
if (fileIds.length === 0 && fileKeys.length === 0) return 0
|
||||
const selectors = [
|
||||
fileIds.length > 0 ? inArray(workspaceFiles.id, fileIds) : undefined,
|
||||
fileKeys.length > 0 ? inArray(workspaceFiles.key, fileKeys) : undefined,
|
||||
].filter((clause): clause is NonNullable<typeof clause> => clause !== undefined)
|
||||
const rows = await executor
|
||||
.select({ total: sql<string>`coalesce(sum(${workspaceFiles.size}), 0)` })
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
selectors.length === 1 ? selectors[0] : or(...selectors),
|
||||
eq(workspaceFiles.workspaceId, sourceWorkspaceId),
|
||||
eq(workspaceFiles.context, 'workspace'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
// `sum()` comes back as a string (bigint) from the driver; coerce explicitly.
|
||||
return Number(rows[0]?.total ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte total of the KB document blobs the selected knowledge bases would re-key into the
|
||||
* target. Scoped to live KBs in the source workspace (mirroring the container copy) and
|
||||
* to LIVE documents with an internal blob: external/`data:` documents have a null
|
||||
* `storageKey` (no blob is duplicated), and embeddings are DB rows the upload path never
|
||||
* counts, so neither contributes bytes here.
|
||||
*/
|
||||
async function sumKbDocumentBytes(
|
||||
executor: DbOrTx,
|
||||
sourceWorkspaceId: string,
|
||||
knowledgeBaseIds: string[]
|
||||
): Promise<number> {
|
||||
if (knowledgeBaseIds.length === 0) return 0
|
||||
const rows = await executor
|
||||
.select({ total: sql<string>`coalesce(sum(${document.fileSize}), 0)` })
|
||||
.from(document)
|
||||
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(knowledgeBase.id, knowledgeBaseIds),
|
||||
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
|
||||
isNull(knowledgeBase.deletedAt),
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt),
|
||||
isNotNull(document.storageKey)
|
||||
)
|
||||
)
|
||||
return Number(rows[0]?.total ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte total a fork/sync copy selection would duplicate into the target: selected
|
||||
* workspace-file blobs plus the selected knowledge bases' stored document blobs. Sizes
|
||||
* come from the metadata rows (`workspace_files.size`, `document.file_size`) - no blob
|
||||
* reads. Both sums scope to the source workspace with the same filters the copy itself
|
||||
* applies, so an id that is not actually copyable can only over-count (block), never
|
||||
* under-count.
|
||||
*/
|
||||
export async function sumForkCopyBytes(
|
||||
executor: DbOrTx,
|
||||
sourceWorkspaceId: string,
|
||||
selection: ForkCopyBytesSelection
|
||||
): Promise<number> {
|
||||
const fileBytes = await sumWorkspaceFileBytes(
|
||||
executor,
|
||||
sourceWorkspaceId,
|
||||
selection.fileIds ?? [],
|
||||
selection.fileKeys ?? []
|
||||
)
|
||||
const kbBytes = await sumKbDocumentBytes(
|
||||
executor,
|
||||
sourceWorkspaceId,
|
||||
selection.knowledgeBaseIds ?? []
|
||||
)
|
||||
return fileBytes + kbBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the initiating user's storage scope has headroom for `bytes` of copied blobs,
|
||||
* using the exact quota helper the upload path uses (`checkStorageQuota`, which resolves
|
||||
* the org-pooled vs personal scope from the user's subscription and always allows when
|
||||
* billing is disabled). Over quota throws a {@link ForkError} (413, matching the upload
|
||||
* routes' storage-limit status) carrying the upload path's quota error message, so the
|
||||
* fork/sync modals surface the same user-facing text an over-quota upload would.
|
||||
*/
|
||||
export async function assertForkStorageHeadroom(params: {
|
||||
userId: string
|
||||
bytes: number
|
||||
}): Promise<void> {
|
||||
const { userId, bytes } = params
|
||||
if (bytes <= 0) return
|
||||
const quota = await checkStorageQuota(userId, bytes)
|
||||
if (quota.allowed) return
|
||||
throw new ForkError(
|
||||
`Not enough storage to copy the selected resources. ${quota.error ?? 'Storage limit exceeded'}`,
|
||||
413
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildForkWorkflowIdMap } from '@/ee/workspace-forking/lib/copy/workflow-id-map'
|
||||
|
||||
describe('buildForkWorkflowIdMap', () => {
|
||||
const sequentialIds = () => {
|
||||
let n = 0
|
||||
return () => `child-${++n}`
|
||||
}
|
||||
|
||||
it('excludes a deployed source whose state failed to load from the map (and so the identity seed)', () => {
|
||||
const deployed = [{ id: 'wf-a' }, { id: 'wf-b' }, { id: 'wf-c' }]
|
||||
// wf-b's deployed state failed to load - the copy loop skips it.
|
||||
const map = buildForkWorkflowIdMap(deployed, new Set(['wf-a', 'wf-c']), sequentialIds())
|
||||
// wf-b is absent, so a copied workflow's ref to it clears (not dangle) and the identity seed
|
||||
// (derived from this map's entries) never gets an orphan row pointing at a never-created child.
|
||||
expect([...map.keys()]).toEqual(['wf-a', 'wf-c'])
|
||||
expect(map.has('wf-b')).toBe(false)
|
||||
expect(map.get('wf-a')).toBe('child-1')
|
||||
expect(map.get('wf-c')).toBe('child-2')
|
||||
})
|
||||
|
||||
it('maps a both-deployed pair when both states loaded (refs remap, not clear)', () => {
|
||||
const deployed = [{ id: 'parent-wf' }, { id: 'child-wf' }]
|
||||
const map = buildForkWorkflowIdMap(
|
||||
deployed,
|
||||
new Set(['parent-wf', 'child-wf']),
|
||||
sequentialIds()
|
||||
)
|
||||
expect([...map.keys()]).toEqual(['parent-wf', 'child-wf'])
|
||||
expect(map.get('parent-wf')).toBe('child-1')
|
||||
expect(map.get('child-wf')).toBe('child-2')
|
||||
})
|
||||
|
||||
it('returns an empty map when no states loaded', () => {
|
||||
const map = buildForkWorkflowIdMap([{ id: 'wf-a' }], new Set(), () => 'x')
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { generateId } from '@sim/utils/id'
|
||||
|
||||
/**
|
||||
* Build the source->child workflow id map for a fork create, scoped to the deployed workflows whose
|
||||
* state actually LOADED (the set the copy loop writes). A deployed source whose state failed to load
|
||||
* is EXCLUDED, so a copied workflow's reference to it clears (clearUnmapped) instead of pointing at a
|
||||
* never-created child, and no orphan `workspace_fork_resource_map` identity row is seeded for it (the
|
||||
* identity seed is derived from this map). Mirrors promote's writtenItems-only scoping
|
||||
* (`buildPromoteWorkflowIdMap`). `generateChildId` is injectable for deterministic tests.
|
||||
*/
|
||||
export function buildForkWorkflowIdMap(
|
||||
deployedWorkflows: ReadonlyArray<{ id: string }>,
|
||||
loadedStateWorkflowIds: ReadonlySet<string>,
|
||||
generateChildId: () => string = generateId
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
for (const wf of deployedWorkflows) {
|
||||
if (loadedStateWorkflowIds.has(wf.id)) map.set(wf.id, generateChildId())
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetEdgeMappingRows, mockAcquireLock } = vi.hoisted(() => ({
|
||||
mockGetEdgeMappingRows: vi.fn(),
|
||||
mockAcquireLock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
|
||||
getEdgeMappingRows: mockGetEdgeMappingRows,
|
||||
}))
|
||||
vi.mock('@/lib/mcp/server-locks', () => ({
|
||||
acquireWorkflowMcpServerLock: mockAcquireLock,
|
||||
}))
|
||||
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
copyForkWorkflowMcpAttachments,
|
||||
reconcileForkWorkflowMcpAttachments,
|
||||
} from '@/ee/workspace-forking/lib/copy/workflow-mcp-attachments'
|
||||
|
||||
/** Sequenced select mock + captured inserts/updates. */
|
||||
function makeTx(selectResults: unknown[][]) {
|
||||
const inserted: Array<Record<string, unknown>> = []
|
||||
const updates: Array<Record<string, unknown>> = []
|
||||
let call = 0
|
||||
const select = vi.fn(() => ({
|
||||
from: () => ({ where: () => Promise.resolve(selectResults[call++] ?? []) }),
|
||||
}))
|
||||
const tx = {
|
||||
select,
|
||||
insert: () => ({
|
||||
values: (values: Array<Record<string, unknown>>) => {
|
||||
inserted.push(...values)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (set: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
updates.push(set)
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}
|
||||
return { tx: tx as unknown as DbOrTx, inserted, updates, select }
|
||||
}
|
||||
|
||||
const attachment = (overrides: Record<string, unknown> = {}) => ({
|
||||
serverId: 'srv-src',
|
||||
workflowId: 'wf-src',
|
||||
toolName: 'run_support_flow',
|
||||
toolDescription: 'Runs the support flow',
|
||||
parameterSchema: { type: 'object', properties: {} },
|
||||
parameterDescriptionOverrides: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const serverMappingRow = {
|
||||
id: 'map-1',
|
||||
childWorkspaceId: 'child-ws',
|
||||
resourceType: 'workflow_mcp_server' as const,
|
||||
parentResourceId: 'srv-parent',
|
||||
childResourceId: 'srv-child',
|
||||
}
|
||||
|
||||
describe('reconcileForkWorkflowMcpAttachments', () => {
|
||||
it('creates the target attachment for a mapped server + written pair (push: child -> parent)', async () => {
|
||||
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
|
||||
mockAcquireLock.mockClear()
|
||||
const { tx, inserted, select } = makeTx([
|
||||
[{ id: 'srv-child' }, { id: 'srv-parent' }], // both mapped servers still live
|
||||
[attachment({ serverId: 'srv-child', workflowId: 'wf-child' })],
|
||||
[], // no existing target attachments
|
||||
])
|
||||
const result = await reconcileForkWorkflowMcpAttachments({
|
||||
tx,
|
||||
childWorkspaceId: 'child-ws',
|
||||
sourceIsParent: false, // push: source is the child
|
||||
now: new Date(),
|
||||
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
|
||||
})
|
||||
expect(inserted).toHaveLength(1)
|
||||
expect(inserted[0]).toMatchObject({
|
||||
serverId: 'srv-parent',
|
||||
workflowId: 'wf-parent',
|
||||
toolName: 'run_support_flow',
|
||||
})
|
||||
expect(result.affectedServerIds).toEqual(['srv-parent'])
|
||||
expect(mockAcquireLock).toHaveBeenCalledWith(tx, 'srv-parent')
|
||||
// The lock must precede every read: locking after the diff is computed would let a
|
||||
// concurrent attach commit in between and abort the promote on the unique constraint.
|
||||
expect(mockAcquireLock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
select.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('archives a target attachment whose source counterpart was detached', async () => {
|
||||
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
|
||||
const { tx, inserted, updates } = makeTx([
|
||||
[{ id: 'srv-child' }, { id: 'srv-parent' }], // both mapped servers still live
|
||||
[], // source has no attachments left
|
||||
[
|
||||
{
|
||||
id: 'tool-tgt',
|
||||
serverId: 'srv-parent',
|
||||
workflowId: 'wf-parent',
|
||||
toolName: 'run_support_flow',
|
||||
toolDescription: null,
|
||||
parameterDescriptionOverrides: {},
|
||||
},
|
||||
],
|
||||
])
|
||||
const result = await reconcileForkWorkflowMcpAttachments({
|
||||
tx,
|
||||
childWorkspaceId: 'child-ws',
|
||||
sourceIsParent: false,
|
||||
now: new Date(),
|
||||
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
|
||||
})
|
||||
expect(inserted).toHaveLength(0)
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0].archivedAt).toBeInstanceOf(Date)
|
||||
expect(result.affectedServerIds).toEqual(['srv-parent'])
|
||||
})
|
||||
|
||||
it('refreshes drifted metadata on an existing target attachment', async () => {
|
||||
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
|
||||
const { tx, updates } = makeTx([
|
||||
[{ id: 'srv-child' }, { id: 'srv-parent' }], // both mapped servers still live
|
||||
[attachment({ serverId: 'srv-child', workflowId: 'wf-child', toolName: 'renamed_tool' })],
|
||||
[
|
||||
{
|
||||
id: 'tool-tgt',
|
||||
serverId: 'srv-parent',
|
||||
workflowId: 'wf-parent',
|
||||
toolName: 'run_support_flow',
|
||||
toolDescription: 'Runs the support flow',
|
||||
parameterDescriptionOverrides: {},
|
||||
},
|
||||
],
|
||||
])
|
||||
await reconcileForkWorkflowMcpAttachments({
|
||||
tx,
|
||||
childWorkspaceId: 'child-ws',
|
||||
sourceIsParent: false,
|
||||
now: new Date(),
|
||||
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
|
||||
})
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0].toolName).toBe('renamed_tool')
|
||||
})
|
||||
|
||||
it('skips a mapped pair whose target server was deleted (stale identity row must never FK-crash the sync)', async () => {
|
||||
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
|
||||
const { tx, inserted } = makeTx([
|
||||
[{ id: 'srv-child' }], // target server srv-parent hard-deleted: only the source is live
|
||||
])
|
||||
const result = await reconcileForkWorkflowMcpAttachments({
|
||||
tx,
|
||||
childWorkspaceId: 'child-ws',
|
||||
sourceIsParent: false,
|
||||
now: new Date(),
|
||||
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
|
||||
})
|
||||
expect(inserted).toHaveLength(0)
|
||||
expect(result.affectedServerIds).toEqual([])
|
||||
})
|
||||
|
||||
it('no-ops with no mapped servers (attachments follow the server identity)', async () => {
|
||||
mockGetEdgeMappingRows.mockResolvedValue([
|
||||
{ ...serverMappingRow, resourceType: 'table' as const },
|
||||
])
|
||||
const { tx, inserted } = makeTx([])
|
||||
const result = await reconcileForkWorkflowMcpAttachments({
|
||||
tx,
|
||||
childWorkspaceId: 'child-ws',
|
||||
sourceIsParent: false,
|
||||
now: new Date(),
|
||||
writtenPairs: [{ sourceWorkflowId: 'wf-child', targetWorkflowId: 'wf-parent' }],
|
||||
})
|
||||
expect(inserted).toHaveLength(0)
|
||||
expect(result.affectedServerIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyForkWorkflowMcpAttachments', () => {
|
||||
it('copies an attachment only when BOTH its server and workflow were copied', async () => {
|
||||
const { tx, inserted } = makeTx([
|
||||
[
|
||||
attachment(), // both mapped
|
||||
attachment({ serverId: 'srv-uncopied', workflowId: 'wf-src' }),
|
||||
attachment({ serverId: 'srv-src', workflowId: 'wf-uncopied' }),
|
||||
],
|
||||
])
|
||||
const result = await copyForkWorkflowMcpAttachments({
|
||||
tx,
|
||||
serverIdMap: new Map([['srv-src', 'srv-copy']]),
|
||||
workflowIdMap: new Map([['wf-src', 'wf-copy']]),
|
||||
now: new Date(),
|
||||
})
|
||||
expect(result.copied).toBe(1)
|
||||
expect(inserted[0]).toMatchObject({
|
||||
serverId: 'srv-copy',
|
||||
workflowId: 'wf-copy',
|
||||
toolName: 'run_support_flow',
|
||||
})
|
||||
})
|
||||
|
||||
it('no-ops when either id map is empty', async () => {
|
||||
const { tx, inserted } = makeTx([])
|
||||
const result = await copyForkWorkflowMcpAttachments({
|
||||
tx,
|
||||
serverIdMap: new Map(),
|
||||
workflowIdMap: new Map([['wf-src', 'wf-copy']]),
|
||||
now: new Date(),
|
||||
})
|
||||
expect(result.copied).toBe(0)
|
||||
expect(inserted).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { acquireWorkflowMcpServerLock } from '@/lib/mcp/server-locks'
|
||||
import { validateMcpToolMetadataForStorage } from '@/lib/mcp/tool-limits'
|
||||
import { getEdgeMappingRows } from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
|
||||
/**
|
||||
* The seed `parameterSchema` for a copied attachment. The source's schema is copied so the tool
|
||||
* serves correctly before the target's first deploy, UNLESS it exceeds the per-tool storage
|
||||
* limit - then the empty schema is seeded instead (the same degradation the deploy-time sync
|
||||
* applies) and the deployment outbox re-derives the real one when the target deploys.
|
||||
*/
|
||||
function seedParameterSchema(parameterSchema: unknown): unknown {
|
||||
const invalid = validateMcpToolMetadataForStorage({
|
||||
parameterSchema: parameterSchema as Record<string, unknown>,
|
||||
})
|
||||
return invalid ? { type: 'object', properties: {} } : parameterSchema
|
||||
}
|
||||
|
||||
export interface ForkMcpAttachmentPair {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `workflow_mcp_tool` attachments into a fresh fork: every source attachment whose server
|
||||
* AND workflow were both copied gets a child row (fresh id; metadata + schema copied - the child
|
||||
* re-derives the schema when it first deploys). Insert-only: the child is brand new, so there is
|
||||
* nothing to update or archive, and no server locks are needed (the child's servers are
|
||||
* invisible until the fork transaction commits). Must run AFTER the child workflow rows exist
|
||||
* (FK). A no-op when either map is empty.
|
||||
*/
|
||||
export async function copyForkWorkflowMcpAttachments(params: {
|
||||
tx: DbOrTx
|
||||
/** Source workflow-publishing server id -> child copy id. */
|
||||
serverIdMap: ReadonlyMap<string, string>
|
||||
/** Source workflow id -> child workflow id. */
|
||||
workflowIdMap: ReadonlyMap<string, string>
|
||||
now: Date
|
||||
}): Promise<{ copied: number }> {
|
||||
const { tx, serverIdMap, workflowIdMap, now } = params
|
||||
if (serverIdMap.size === 0 || workflowIdMap.size === 0) return { copied: 0 }
|
||||
|
||||
const sourceAttachments = await tx
|
||||
.select({
|
||||
serverId: workflowMcpTool.serverId,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
inArray(workflowMcpTool.serverId, [...serverIdMap.keys()]),
|
||||
inArray(workflowMcpTool.workflowId, [...workflowIdMap.keys()]),
|
||||
isNull(workflowMcpTool.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
const inserts: (typeof workflowMcpTool.$inferInsert)[] = []
|
||||
for (const attachment of sourceAttachments) {
|
||||
const childServerId = serverIdMap.get(attachment.serverId)
|
||||
const childWorkflowId = workflowIdMap.get(attachment.workflowId)
|
||||
if (!childServerId || !childWorkflowId) continue
|
||||
inserts.push({
|
||||
id: generateId(),
|
||||
serverId: childServerId,
|
||||
workflowId: childWorkflowId,
|
||||
toolName: attachment.toolName,
|
||||
toolDescription: attachment.toolDescription,
|
||||
parameterSchema: seedParameterSchema(attachment.parameterSchema),
|
||||
parameterDescriptionOverrides: attachment.parameterDescriptionOverrides,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
if (inserts.length > 0) await tx.insert(workflowMcpTool).values(inserts)
|
||||
return { copied: inserts.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror `workflow_mcp_tool` attachments (a workflow exposed as a tool on a
|
||||
* workflow-publishing MCP server) onto the target side of a sync, through the edge's
|
||||
* `workflow_mcp_server` identity map (seeded when a fork copies the server shells).
|
||||
*
|
||||
* For each written workflow pair whose source is attached to a MAPPED server:
|
||||
* - a missing target attachment is created (metadata copied; `parameterSchema` is copied as a
|
||||
* seed and re-derived by the deployment outbox when the target deploys),
|
||||
* - an existing one has its user-set metadata (tool name / description / description
|
||||
* overrides) refreshed to the source's,
|
||||
* - a target attachment on a mapped server + synced workflow with NO source counterpart is
|
||||
* archived (the source detached it) - target attachments on unmapped servers or unsynced
|
||||
* workflows are never touched.
|
||||
*
|
||||
* Unmapped servers are skipped entirely: attachment sync follows the server identity, exactly
|
||||
* like subblock references follow resource mappings. Bounded by (written workflows x mapped
|
||||
* servers); acquires the same per-server advisory locks the deploy-time tool sync takes.
|
||||
* Returns the affected target server ids so the caller can notify them post-commit.
|
||||
*/
|
||||
export async function reconcileForkWorkflowMcpAttachments(params: {
|
||||
tx: DbOrTx
|
||||
childWorkspaceId: string
|
||||
/** True when the sync SOURCE is the parent workspace (a pull). */
|
||||
sourceIsParent: boolean
|
||||
now: Date
|
||||
/** The workflow pairs THIS sync wrote (replace + create). */
|
||||
writtenPairs: ForkMcpAttachmentPair[]
|
||||
}): Promise<{ affectedServerIds: string[] }> {
|
||||
const { tx, childWorkspaceId, sourceIsParent, now, writtenPairs } = params
|
||||
if (writtenPairs.length === 0) return { affectedServerIds: [] }
|
||||
|
||||
const mappingRows = await getEdgeMappingRows(tx, childWorkspaceId)
|
||||
const serverMap = new Map<string, string>()
|
||||
for (const row of mappingRows) {
|
||||
if (row.resourceType !== 'workflow_mcp_server' || row.childResourceId == null) continue
|
||||
if (sourceIsParent) serverMap.set(row.parentResourceId, row.childResourceId)
|
||||
else serverMap.set(row.childResourceId, row.parentResourceId)
|
||||
}
|
||||
if (serverMap.size === 0) return { affectedServerIds: [] }
|
||||
|
||||
// Same per-server serialization as the deploy-time tool sync and the attach/delete routes,
|
||||
// in sorted order so two concurrent syncs can't deadlock on each other's server locks.
|
||||
// Acquired BEFORE the reads below: every other attachment writer locks first, so locking
|
||||
// after computing the diff would let a concurrent attach commit in between and turn our
|
||||
// insert into a unique-constraint abort of the whole promote transaction (and a concurrent
|
||||
// server delete into an FK abort).
|
||||
for (const serverId of [...new Set(serverMap.values())].sort()) {
|
||||
await acquireWorkflowMcpServerLock(tx, serverId)
|
||||
}
|
||||
|
||||
// Liveness guard: a mapped server may have been deleted since the fork (server deletion is a
|
||||
// hard delete that cascades its tools but leaves the identity row). A dead SOURCE server has
|
||||
// nothing to mirror; a dead TARGET server must be skipped or the insert below would violate
|
||||
// the `server_id` FK and abort the whole promote transaction. Target liveness is stable for
|
||||
// the rest of the transaction: the delete route takes the per-server lock we now hold.
|
||||
const mappedServerIds = [...new Set([...serverMap.keys(), ...serverMap.values()])]
|
||||
const liveServerIds = new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(inArray(workflowMcpServer.id, mappedServerIds), isNull(workflowMcpServer.deletedAt))
|
||||
)
|
||||
).map((row) => row.id)
|
||||
)
|
||||
for (const [sourceServerId, targetServerId] of serverMap) {
|
||||
if (!liveServerIds.has(sourceServerId) || !liveServerIds.has(targetServerId)) {
|
||||
serverMap.delete(sourceServerId)
|
||||
}
|
||||
}
|
||||
if (serverMap.size === 0) return { affectedServerIds: [] }
|
||||
|
||||
const targetBySource = new Map(
|
||||
writtenPairs.map((pair) => [pair.sourceWorkflowId, pair.targetWorkflowId])
|
||||
)
|
||||
const sourceAttachments = await tx
|
||||
.select({
|
||||
serverId: workflowMcpTool.serverId,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
inArray(workflowMcpTool.workflowId, [...targetBySource.keys()]),
|
||||
inArray(workflowMcpTool.serverId, [...serverMap.keys()]),
|
||||
isNull(workflowMcpTool.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
/** Desired live target pairs, keyed `${targetServerId}\u0000${targetWorkflowId}`. */
|
||||
const desired = new Map<
|
||||
string,
|
||||
{
|
||||
serverId: string
|
||||
workflowId: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema: unknown
|
||||
parameterDescriptionOverrides: Record<string, string>
|
||||
}
|
||||
>()
|
||||
for (const attachment of sourceAttachments) {
|
||||
const targetServerId = serverMap.get(attachment.serverId)
|
||||
const targetWorkflowId = targetBySource.get(attachment.workflowId)
|
||||
if (!targetServerId || !targetWorkflowId) continue
|
||||
desired.set(`${targetServerId}\u0000${targetWorkflowId}`, {
|
||||
serverId: targetServerId,
|
||||
workflowId: targetWorkflowId,
|
||||
toolName: attachment.toolName,
|
||||
toolDescription: attachment.toolDescription,
|
||||
parameterSchema: attachment.parameterSchema,
|
||||
parameterDescriptionOverrides: attachment.parameterDescriptionOverrides,
|
||||
})
|
||||
}
|
||||
|
||||
// The reconcile scope: every mapped TARGET server x every synced TARGET workflow. Rows
|
||||
// outside this product are never touched.
|
||||
const mappedTargetServerIds = [...new Set(serverMap.values())].sort()
|
||||
const syncedTargetWorkflowIds = [...new Set(targetBySource.values())]
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
serverId: workflowMcpTool.serverId,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
inArray(workflowMcpTool.serverId, mappedTargetServerIds),
|
||||
inArray(workflowMcpTool.workflowId, syncedTargetWorkflowIds),
|
||||
isNull(workflowMcpTool.archivedAt)
|
||||
)
|
||||
)
|
||||
const existingByKey = new Map(
|
||||
existing.map((row) => [`${row.serverId}\u0000${row.workflowId}`, row])
|
||||
)
|
||||
|
||||
const inserts: (typeof workflowMcpTool.$inferInsert)[] = []
|
||||
const updates: Array<{ id: string; set: Partial<typeof workflowMcpTool.$inferInsert> }> = []
|
||||
const archiveIds: string[] = []
|
||||
const affectedServerIds = new Set<string>()
|
||||
|
||||
for (const [key, want] of desired) {
|
||||
const current = existingByKey.get(key)
|
||||
if (!current) {
|
||||
inserts.push({
|
||||
id: generateId(),
|
||||
serverId: want.serverId,
|
||||
workflowId: want.workflowId,
|
||||
toolName: want.toolName,
|
||||
toolDescription: want.toolDescription,
|
||||
// Seed with the source's schema; the deployment outbox re-derives it from the
|
||||
// target's deployed state right after this sync deploys the workflow.
|
||||
parameterSchema: seedParameterSchema(want.parameterSchema),
|
||||
parameterDescriptionOverrides: want.parameterDescriptionOverrides,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
affectedServerIds.add(want.serverId)
|
||||
continue
|
||||
}
|
||||
const overridesChanged =
|
||||
JSON.stringify(current.parameterDescriptionOverrides ?? {}) !==
|
||||
JSON.stringify(want.parameterDescriptionOverrides ?? {})
|
||||
if (
|
||||
current.toolName !== want.toolName ||
|
||||
current.toolDescription !== want.toolDescription ||
|
||||
overridesChanged
|
||||
) {
|
||||
updates.push({
|
||||
id: current.id,
|
||||
set: {
|
||||
toolName: want.toolName,
|
||||
toolDescription: want.toolDescription,
|
||||
parameterDescriptionOverrides: want.parameterDescriptionOverrides,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
affectedServerIds.add(current.serverId)
|
||||
}
|
||||
}
|
||||
for (const [key, row] of existingByKey) {
|
||||
if (desired.has(key)) continue
|
||||
archiveIds.push(row.id)
|
||||
affectedServerIds.add(row.serverId)
|
||||
}
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && archiveIds.length === 0) {
|
||||
return { affectedServerIds: [] }
|
||||
}
|
||||
|
||||
if (inserts.length > 0) await tx.insert(workflowMcpTool).values(inserts)
|
||||
for (const update of updates) {
|
||||
await tx.update(workflowMcpTool).set(update.set).where(eq(workflowMcpTool.id, update.id))
|
||||
}
|
||||
if (archiveIds.length > 0) {
|
||||
await tx
|
||||
.update(workflowMcpTool)
|
||||
.set({ archivedAt: now, updatedAt: now })
|
||||
.where(inArray(workflowMcpTool.id, archiveIds))
|
||||
}
|
||||
|
||||
return { affectedServerIds: [...affectedServerIds] }
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockSumForkCopyBytes,
|
||||
mockAssertForkStorageHeadroom,
|
||||
mockLoadSourceDeployedStates,
|
||||
mockPlanForkFileCopies,
|
||||
mockCopyForkResourceContainers,
|
||||
mockStartBackgroundWork,
|
||||
mockFinishBackgroundWork,
|
||||
mockScheduleForkContentCopy,
|
||||
mockSeedEdgeMappings,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSumForkCopyBytes: vi.fn(),
|
||||
mockAssertForkStorageHeadroom: vi.fn(),
|
||||
mockLoadSourceDeployedStates: vi.fn(),
|
||||
mockPlanForkFileCopies: vi.fn(),
|
||||
mockCopyForkResourceContainers: vi.fn(),
|
||||
mockStartBackgroundWork: vi.fn(),
|
||||
mockFinishBackgroundWork: vi.fn(),
|
||||
mockScheduleForkContentCopy: vi.fn(),
|
||||
mockSeedEdgeMappings: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
vi.mock('@/lib/workflows/defaults', () => ({
|
||||
buildDefaultWorkflowArtifacts: vi.fn(() => ({ workflowState: {} })),
|
||||
}))
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
saveWorkflowToNormalizedTables: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
|
||||
startBackgroundWork: mockStartBackgroundWork,
|
||||
finishBackgroundWork: mockFinishBackgroundWork,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/content-copy-runner', () => ({
|
||||
hasForkContentToCopy: vi.fn(() => false),
|
||||
scheduleForkContentCopy: mockScheduleForkContentCopy,
|
||||
serializeContentRefMaps: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/copy-chats', () => ({
|
||||
copyForkChatDeployments: vi.fn(async () => ({ created: 0 })),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({
|
||||
planForkFileCopies: mockPlanForkFileCopies,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/workflow-mcp-attachments', () => ({
|
||||
copyForkWorkflowMcpAttachments: vi.fn(async () => ({ copied: 0 })),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/copy-resources', () => ({
|
||||
copyForkResourceContainers: mockCopyForkResourceContainers,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/storage-quota', () => ({
|
||||
sumForkCopyBytes: mockSumForkCopyBytes,
|
||||
assertForkStorageHeadroom: mockAssertForkStorageHeadroom,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/copy-workflows', () => ({
|
||||
copyWorkflowStateIntoTarget: vi.fn(),
|
||||
loadWorkflowNameRegistry: vi.fn(async () => new Map()),
|
||||
resolveForkFolderMapping: vi.fn(async () => new Map()),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
|
||||
loadSourceDeployedStates: mockLoadSourceDeployedStates,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
|
||||
setForkLockTimeout: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
|
||||
reconcileForkBlockPairs: vi.fn(),
|
||||
toForkBlockPairs: vi.fn(() => []),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
|
||||
seedEdgeMappings: mockSeedEdgeMappings,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/remap/fork-bootstrap', () => ({
|
||||
createForkBootstrapTransform: vi.fn(() => (subBlocks: unknown) => subBlocks),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/remap/reference-scan', () => ({
|
||||
collectReferencedDocumentIds: vi.fn(() => new Set<string>()),
|
||||
}))
|
||||
vi.mock('@/lib/workspaces/policy', () => ({
|
||||
WORKSPACE_MODE: {
|
||||
PERSONAL: 'personal',
|
||||
ORGANIZATION: 'organization',
|
||||
GRANDFATHERED_SHARED: 'grandfathered_shared',
|
||||
},
|
||||
}))
|
||||
|
||||
import { createFork } from '@/ee/workspace-forking/lib/create-fork'
|
||||
|
||||
const SOURCE = { id: 'src-ws', name: 'Parent' } as never
|
||||
const POLICY = {
|
||||
organizationId: null,
|
||||
workspaceMode: 'personal',
|
||||
billedAccountUserId: null,
|
||||
} as never
|
||||
|
||||
function forkParams(selection?: {
|
||||
files?: string[]
|
||||
knowledgeBases?: string[]
|
||||
}): Parameters<typeof createFork>[0] {
|
||||
return {
|
||||
source: SOURCE,
|
||||
policy: POLICY,
|
||||
userId: 'user-1',
|
||||
name: 'My Fork',
|
||||
selection: {
|
||||
files: selection?.files ?? [],
|
||||
tables: [],
|
||||
knowledgeBases: selection?.knowledgeBases ?? [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
},
|
||||
requestId: 'test',
|
||||
}
|
||||
}
|
||||
|
||||
describe('createFork storage headroom gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockSumForkCopyBytes.mockResolvedValue(0)
|
||||
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
|
||||
mockLoadSourceDeployedStates.mockResolvedValue({
|
||||
deployedWorkflows: [],
|
||||
sourceStates: new Map(),
|
||||
})
|
||||
mockPlanForkFileCopies.mockResolvedValue({
|
||||
keyMap: new Map(),
|
||||
idMap: new Map(),
|
||||
blobTasks: [],
|
||||
})
|
||||
mockCopyForkResourceContainers.mockResolvedValue({
|
||||
idMap: new Map(),
|
||||
mappingEntries: [],
|
||||
contentPlan: {
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'child-ws',
|
||||
userId: 'user-1',
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
skills: [],
|
||||
documents: [],
|
||||
},
|
||||
names: {
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
},
|
||||
})
|
||||
mockStartBackgroundWork.mockResolvedValue('status-1')
|
||||
mockFinishBackgroundWork.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('fails an over-quota fork BEFORE any read or write, with the storage error', async () => {
|
||||
mockSumForkCopyBytes.mockResolvedValue(999_999)
|
||||
mockAssertForkStorageHeadroom.mockRejectedValue(
|
||||
new Error(
|
||||
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] }))
|
||||
).rejects.toThrow('Not enough storage to copy the selected resources')
|
||||
|
||||
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 })
|
||||
// Nothing was read, created, or recorded: the fork failed before all of it.
|
||||
expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled()
|
||||
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
|
||||
expect(mockStartBackgroundWork).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds under quota, summing exactly the selected files + knowledge bases', async () => {
|
||||
mockSumForkCopyBytes.mockResolvedValue(500)
|
||||
|
||||
const result = await createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] }))
|
||||
|
||||
expect(result.workspace.name).toBe('My Fork')
|
||||
expect(result.workflowsCopied).toBe(0)
|
||||
expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', {
|
||||
fileIds: ['wf-1'],
|
||||
knowledgeBaseIds: ['kb-1'],
|
||||
})
|
||||
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 500 })
|
||||
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('seeds identity mappings for copied FILES by storage key (a later sync must not re-offer them)', async () => {
|
||||
mockPlanForkFileCopies.mockResolvedValue({
|
||||
keyMap: new Map([['workspace/src-ws/a.png', 'workspace/child/a.png']]),
|
||||
idMap: new Map([['file-1', 'file-1-copy']]),
|
||||
blobTasks: [],
|
||||
})
|
||||
|
||||
await createFork(forkParams({ files: ['file-1'] }))
|
||||
|
||||
expect(mockSeedEdgeMappings).toHaveBeenCalledTimes(1)
|
||||
const seeded = mockSeedEdgeMappings.mock.calls[0][3] as Array<Record<string, unknown>>
|
||||
expect(seeded).toContainEqual({
|
||||
resourceType: 'file',
|
||||
parentResourceId: 'workspace/src-ws/a.png',
|
||||
childResourceId: 'workspace/child/a.png',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,496 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PermissionType } from '@sim/platform-authz/workspace'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { Workspace } from '@/lib/api/contracts/workspaces'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import type { WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import type { WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
|
||||
import { WORKSPACE_MODE } from '@/lib/workspaces/policy'
|
||||
import {
|
||||
finishBackgroundWork,
|
||||
startBackgroundWork,
|
||||
} from '@/ee/workspace-forking/lib/background-work/store'
|
||||
import {
|
||||
type ForkContentCopyPayload,
|
||||
hasForkContentToCopy,
|
||||
scheduleForkContentCopy,
|
||||
serializeContentRefMaps,
|
||||
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
|
||||
import { copyForkChatDeployments } from '@/ee/workspace-forking/lib/copy/copy-chats'
|
||||
import { planForkFileCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
|
||||
import {
|
||||
copyForkResourceContainers,
|
||||
type ForkCopiedResourceNames,
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import {
|
||||
copyWorkflowStateIntoTarget,
|
||||
loadWorkflowNameRegistry,
|
||||
resolveForkFolderMapping,
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-workflows'
|
||||
import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
|
||||
import {
|
||||
assertForkStorageHeadroom,
|
||||
sumForkCopyBytes,
|
||||
} from '@/ee/workspace-forking/lib/copy/storage-quota'
|
||||
import { buildForkWorkflowIdMap } from '@/ee/workspace-forking/lib/copy/workflow-id-map'
|
||||
import { copyForkWorkflowMcpAttachments } from '@/ee/workspace-forking/lib/copy/workflow-mcp-attachments'
|
||||
import { setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import {
|
||||
type ForkBlockPair,
|
||||
reconcileForkBlockPairs,
|
||||
toForkBlockPairs,
|
||||
} from '@/ee/workspace-forking/lib/mapping/block-map-store'
|
||||
import {
|
||||
type ForkMappingUpsert,
|
||||
type ForkResourceType,
|
||||
seedEdgeMappings,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import { deriveForkBlockId } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import { createForkBootstrapTransform } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
|
||||
import { collectReferencedDocumentIds } from '@/ee/workspace-forking/lib/remap/reference-scan'
|
||||
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCreate')
|
||||
|
||||
/** Source resource ids the user selected to copy into the child, by kind. */
|
||||
export interface ForkResourceSelection {
|
||||
files: string[]
|
||||
tables: string[]
|
||||
knowledgeBases: string[]
|
||||
customTools: string[]
|
||||
skills: string[]
|
||||
/** External MCP servers, copied as config rows (OAuth tokens never copied - re-auth in child). */
|
||||
mcpServers: string[]
|
||||
/** Workflow-publishing MCP servers, copied as config-only shells with no workflows attached. */
|
||||
workflowMcpServers: string[]
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: ForkResourceSelection = {
|
||||
files: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
}
|
||||
|
||||
export interface CreateForkParams {
|
||||
source: WorkspaceWithOwner
|
||||
policy: WorkspaceCreationPolicy
|
||||
userId: string
|
||||
/** Display name of the user forking, recorded on the activity entry. */
|
||||
actorName?: string
|
||||
name?: string
|
||||
selection?: ForkResourceSelection
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface CreateForkResult {
|
||||
/** Full child workspace row so callers can merge it into the workspace-list cache. */
|
||||
workspace: Workspace
|
||||
workflowsCopied: number
|
||||
}
|
||||
|
||||
// Credentials are intentionally absent: a fork never copies them, so their references
|
||||
// resolve to null here and are cleared on remap (re-connect in the child).
|
||||
const FORK_KIND_TO_RESOURCE_TYPE: Partial<Record<ForkRemapKind, ForkResourceType>> = {
|
||||
'custom-tool': 'custom_tool',
|
||||
skill: 'skill',
|
||||
table: 'table',
|
||||
'knowledge-base': 'knowledge_base',
|
||||
'knowledge-document': 'knowledge_document',
|
||||
'mcp-server': 'mcp_server',
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fork of `source`: a new child workspace that copies the parent's
|
||||
* **deployed** workflows (left undeployed in the child), snapshots the parent's
|
||||
* member list, copies the user-selected resources (files, tables, knowledge bases,
|
||||
* custom tools, skills, MCP server configs) with fresh ids, and records the
|
||||
* source→child identity for each. Workflow references to copied resources are
|
||||
* rewritten to the child ids; references to resources that were not copied (and
|
||||
* all credential references) are cleared; env-var references are preserved.
|
||||
*/
|
||||
export async function createFork(params: CreateForkParams): Promise<CreateForkResult> {
|
||||
const { source, policy, userId, requestId = 'unknown' } = params
|
||||
const selection = params.selection ?? EMPTY_SELECTION
|
||||
const childName = params.name?.trim() || `${source.name} (fork)`
|
||||
|
||||
// Copied blob bytes (workspace files + KB document blobs) are charged to the initiating
|
||||
// user's storage scope exactly as if uploaded to the child workspace, so enforce
|
||||
// headroom BEFORE any fork work. Sizes come from the metadata rows the fork tx would
|
||||
// load anyway; over quota fails the fork here with the upload path's error shape.
|
||||
const copyBytes = await sumForkCopyBytes(db, source.id, {
|
||||
fileIds: selection.files,
|
||||
knowledgeBaseIds: selection.knowledgeBases,
|
||||
})
|
||||
await assertForkStorageHeadroom({ userId, bytes: copyBytes })
|
||||
|
||||
// Read the source's deployed workflows + states BEFORE the transaction so these
|
||||
// global-pool reads don't check out a second pooled connection from inside the
|
||||
// fork tx (which can deadlock the pool at saturation).
|
||||
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(source.id)
|
||||
|
||||
// Documents the copied workflows reference (document-selector values + nested documentId
|
||||
// tool params). Those whose parent KB is being copied get a placeholder + id map inside the
|
||||
// fork tx so their references remap to the copied document instead of being cleared.
|
||||
const referencedDocumentIds = collectReferencedDocumentIds(
|
||||
deployedWorkflows.flatMap((wf) => {
|
||||
const sourceState = sourceStates.get(wf.id)
|
||||
return sourceState ? [sourceState] : []
|
||||
})
|
||||
)
|
||||
|
||||
const forkedWorkflowNames: string[] = []
|
||||
let forkedResourceNames: ForkCopiedResourceNames = {
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
}
|
||||
const { result, blobTasks, contentPlan, contentRefMaps } = await db.transaction(async (tx) => {
|
||||
await setForkLockTimeout(tx)
|
||||
const now = new Date()
|
||||
const childWorkspaceId = generateId()
|
||||
|
||||
await tx.insert(workspace).values({
|
||||
id: childWorkspaceId,
|
||||
name: childName,
|
||||
ownerId: userId,
|
||||
organizationId: policy.organizationId,
|
||||
workspaceMode: policy.workspaceMode,
|
||||
billedAccountUserId: policy.billedAccountUserId,
|
||||
allowPersonalApiKeys: true,
|
||||
forkedFromWorkspaceId: source.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const sourcePermissions = await tx
|
||||
.select({ userId: permissions.userId, permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, source.id)))
|
||||
|
||||
const permissionByUser = new Map<string, PermissionType>()
|
||||
for (const row of sourcePermissions) {
|
||||
permissionByUser.set(row.userId, row.permissionType)
|
||||
}
|
||||
permissionByUser.set(userId, 'admin')
|
||||
if (
|
||||
policy.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
|
||||
policy.billedAccountUserId &&
|
||||
policy.billedAccountUserId !== userId
|
||||
) {
|
||||
permissionByUser.set(policy.billedAccountUserId, 'admin')
|
||||
}
|
||||
|
||||
await tx.insert(permissions).values(
|
||||
Array.from(permissionByUser.entries()).map(([memberUserId, permissionType]) => ({
|
||||
id: generateId(),
|
||||
entityType: 'workspace' as const,
|
||||
entityId: childWorkspaceId,
|
||||
userId: memberUserId,
|
||||
permissionType,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
|
||||
// The id map (and the identity seed below) covers only the workflows ACTUALLY copied -
|
||||
// those whose deployed state loaded. A deployed source whose state failed to load is
|
||||
// skipped by the copy loop, so it must be excluded here too: keeping it would (1) remap a
|
||||
// copied workflow's reference to a child id that is never created (a dangling ref) instead
|
||||
// of clearing it, and (2) seed a `workspace_fork_resource_map` workflow row pointing at
|
||||
// that never-created target, which a later push would treat as an orphan and archive the
|
||||
// parent's real workflow. Mirrors promote's writtenItems-only identity seed.
|
||||
const workflowIdMap = buildForkWorkflowIdMap(deployedWorkflows, new Set(sourceStates.keys()))
|
||||
|
||||
const fileResult = await planForkFileCopies({
|
||||
tx,
|
||||
sourceWorkspaceId: source.id,
|
||||
childWorkspaceId,
|
||||
userId,
|
||||
fileIds: selection.files,
|
||||
now,
|
||||
})
|
||||
|
||||
// Source -> child folder id map: remaps folder references in the copied workflows below and
|
||||
// feeds the post-commit content-ref rewrite (`sim:folder/<id>` mentions in skill/file bodies).
|
||||
// Scoped to the folders that will actually receive a copied workflow (plus ancestors): a
|
||||
// fork copies only DEPLOYED workflows, so folders holding none would be created empty in
|
||||
// the child and are pruned instead. Copied files don't extend this set - they use the
|
||||
// separate workspace-file-folder entity and land at the child's root.
|
||||
const folderIdMap = await resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId: source.id,
|
||||
targetWorkspaceId: childWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
contentFolderIds: deployedWorkflows
|
||||
.filter((wf) => workflowIdMap.has(wf.id))
|
||||
.map((wf) => wf.folderId),
|
||||
})
|
||||
|
||||
const resourceResult = await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: source.id,
|
||||
childWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
selection: {
|
||||
customTools: selection.customTools,
|
||||
skills: selection.skills,
|
||||
mcpServers: selection.mcpServers,
|
||||
workflowMcpServers: selection.workflowMcpServers,
|
||||
tables: selection.tables,
|
||||
knowledgeBases: selection.knowledgeBases,
|
||||
},
|
||||
workflowIdMap,
|
||||
referencedDocumentIds: Array.from(referencedDocumentIds),
|
||||
})
|
||||
forkedResourceNames = resourceResult.names
|
||||
|
||||
const resolveCopied = (kind: ForkRemapKind, sourceId: string): string | null => {
|
||||
if (kind === 'file') return fileResult.keyMap.get(sourceId) ?? null
|
||||
const resourceType = FORK_KIND_TO_RESOURCE_TYPE[kind]
|
||||
if (!resourceType) return null
|
||||
return resourceResult.idMap.get(resourceType)?.get(sourceId) ?? null
|
||||
}
|
||||
const transform = createForkBootstrapTransform(resolveCopied)
|
||||
|
||||
// The child is brand new, so this loads an empty registry; name collisions can only
|
||||
// arise among the copied workflows themselves, which the in-loop claims resolve.
|
||||
const nameRegistry = await loadWorkflowNameRegistry(tx, childWorkspaceId)
|
||||
|
||||
let workflowsCopied = 0
|
||||
// Seed the block-identity map (parent block -> derived child block) so a later push of
|
||||
// this fork resolves each child block back to the parent's ORIGINAL id instead of
|
||||
// re-deriving and re-keying the parent's webhook URLs.
|
||||
const blockPairs: ForkBlockPair[] = []
|
||||
const sourceWorkflowIds: string[] = []
|
||||
for (const wf of deployedWorkflows) {
|
||||
const sourceState = sourceStates.get(wf.id)
|
||||
if (!sourceState) continue
|
||||
const targetWorkflowId = workflowIdMap.get(wf.id)!
|
||||
const copyResult = await copyWorkflowStateIntoTarget({
|
||||
tx,
|
||||
targetWorkflowId,
|
||||
targetWorkspaceId: childWorkspaceId,
|
||||
userId,
|
||||
mode: 'create',
|
||||
now,
|
||||
sourceState,
|
||||
sourceMeta: {
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
folderId: wf.folderId,
|
||||
sortOrder: wf.sortOrder,
|
||||
},
|
||||
workflowIdMap,
|
||||
folderIdMap,
|
||||
transformSubBlocks: transform,
|
||||
nameRegistry,
|
||||
requestId,
|
||||
})
|
||||
// Creation copies parent -> child, so the source side is the parent.
|
||||
blockPairs.push(...toForkBlockPairs(copyResult.blockIdMapping, true, wf.id, targetWorkflowId))
|
||||
sourceWorkflowIds.push(wf.id)
|
||||
workflowsCopied += 1
|
||||
forkedWorkflowNames.push(wf.name)
|
||||
}
|
||||
await reconcileForkBlockPairs(tx, childWorkspaceId, true, sourceWorkflowIds, blockPairs)
|
||||
|
||||
// Carry each copied workflow's chat deployment(s): a fresh identifier
|
||||
// (`{child-workspace}-{workflow}-{randomnum}`) with the config copied verbatim and its
|
||||
// output block ids remapped onto the derived child blocks. The chat serves at its new URL
|
||||
// as soon as the child workflow is deployed.
|
||||
await copyForkChatDeployments({
|
||||
tx,
|
||||
pairs: deployedWorkflows.flatMap((wf) => {
|
||||
const targetWorkflowId = workflowIdMap.get(wf.id)
|
||||
return targetWorkflowId
|
||||
? [{ sourceWorkflowId: wf.id, targetWorkflowId, workflowName: wf.name }]
|
||||
: []
|
||||
}),
|
||||
targetWorkspaceName: childName,
|
||||
userId,
|
||||
now,
|
||||
resolveBlockId: deriveForkBlockId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
// Carry workflow-as-MCP-tool attachments onto the copied server shells: an attachment
|
||||
// copies only when BOTH its server and its workflow were copied. Runs after the workflow
|
||||
// rows exist (FK); the child re-derives each tool's parameter schema on first deploy.
|
||||
await copyForkWorkflowMcpAttachments({
|
||||
tx,
|
||||
serverIdMap: resourceResult.idMap.get('workflow_mcp_server') ?? new Map(),
|
||||
workflowIdMap,
|
||||
now,
|
||||
})
|
||||
|
||||
// A fork carries only DEPLOYED workflows. When the source has none (e.g. it was
|
||||
// itself just forked and never redeployed), seed a default workflow so the child
|
||||
// is a usable workspace rather than a blank one with no workflow at all - the same
|
||||
// starter "New workspace" creates. Any copied resources still land alongside it.
|
||||
if (workflowsCopied === 0) {
|
||||
const defaultWorkflowId = generateId()
|
||||
await tx.insert(workflow).values({
|
||||
id: defaultWorkflowId,
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
folderId: null,
|
||||
name: 'default-agent',
|
||||
description: 'Your first workflow - start building here!',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
const { workflowState } = buildDefaultWorkflowArtifacts()
|
||||
await saveWorkflowToNormalizedTables(defaultWorkflowId, workflowState, tx)
|
||||
}
|
||||
|
||||
const seedEntries: ForkMappingUpsert[] = []
|
||||
for (const [sourceWorkflowId, childWorkflowId] of workflowIdMap.entries()) {
|
||||
seedEntries.push({
|
||||
resourceType: 'workflow',
|
||||
parentResourceId: sourceWorkflowId,
|
||||
childResourceId: childWorkflowId,
|
||||
})
|
||||
}
|
||||
seedEntries.push(...resourceResult.mappingEntries)
|
||||
// Copied files map by STORAGE KEY (matching `file-upload` references), from the file-copy
|
||||
// plan - files are copied outside `copyForkResourceContainers`, so their identity rows must
|
||||
// be added here explicitly. Without them a later sync re-offers every fork-copied file as a
|
||||
// copy candidate (and a push would duplicate a referenced file into the parent instead of
|
||||
// resolving it back to the parent's original).
|
||||
for (const [sourceKey, childKey] of fileResult.keyMap) {
|
||||
seedEntries.push({
|
||||
resourceType: 'file',
|
||||
parentResourceId: sourceKey,
|
||||
childResourceId: childKey,
|
||||
})
|
||||
}
|
||||
await seedEdgeMappings(tx, childWorkspaceId, userId, seedEntries)
|
||||
|
||||
logger.info(`[${requestId}] Created fork ${childWorkspaceId} from ${source.id}`, {
|
||||
workflowsCopied,
|
||||
mappingsSeeded: seedEntries.length,
|
||||
})
|
||||
|
||||
// Serialized in-content reference maps so the post-commit content copy can rewrite
|
||||
// `sim:` links + embedded URLs inside copied skill bodies and markdown file blobs. Maps
|
||||
// become Records to cross the background-job payload boundary.
|
||||
const contentRefMaps = serializeContentRefMaps({
|
||||
workspaceId: { from: source.id, to: childWorkspaceId },
|
||||
fileKeys: fileResult.keyMap,
|
||||
fileIds: fileResult.idMap,
|
||||
workflows: workflowIdMap,
|
||||
folders: folderIdMap,
|
||||
knowledgeBases: resourceResult.idMap.get('knowledge_base'),
|
||||
tables: resourceResult.idMap.get('table'),
|
||||
skills: resourceResult.idMap.get('skill'),
|
||||
})
|
||||
|
||||
return {
|
||||
result: {
|
||||
workspace: {
|
||||
id: childWorkspaceId,
|
||||
name: childName,
|
||||
ownerId: userId,
|
||||
organizationId: policy.organizationId,
|
||||
workspaceMode: policy.workspaceMode,
|
||||
billedAccountUserId: policy.billedAccountUserId,
|
||||
allowPersonalApiKeys: true,
|
||||
forkedFromWorkspaceId: source.id,
|
||||
},
|
||||
workflowsCopied,
|
||||
},
|
||||
blobTasks: fileResult.blobTasks,
|
||||
contentPlan: resourceResult.contentPlan,
|
||||
contentRefMaps,
|
||||
}
|
||||
})
|
||||
|
||||
// Bulk content (table rows, KB documents + embeddings) and file blobs are copied
|
||||
// AFTER the fork commits, in the background, so the fork request returns as soon
|
||||
// as the workflows exist and is never blocked on (or timed out by) heavy I/O.
|
||||
// Trigger.dev runs it out-of-process (surviving deploys); without it, runDetached
|
||||
// runs it inline best-effort. Both are batched/bounded internally.
|
||||
const hasContent = hasForkContentToCopy(contentPlan, blobTasks)
|
||||
|
||||
// Record a durable job for EVERY fork (the fork already committed), scoped to the
|
||||
// SOURCE workspace - that's where the fork was initiated and where its Activity tab
|
||||
// lives, so the record survives a reload of the fork modal. When there is heavy
|
||||
// content to copy in the background the row stays `processing` until the runner
|
||||
// finishes it (merging in copied/failed); otherwise the fork is already complete.
|
||||
const forkedName = result.workspace.name
|
||||
// The fork already committed; failing to record the tracking row must not turn it into
|
||||
// a 500. Log and continue without a status row - the background content copy below still
|
||||
// runs (its runner no-ops the status update when statusId is absent).
|
||||
let statusId: string | undefined
|
||||
try {
|
||||
statusId = await startBackgroundWork(db, {
|
||||
workspaceId: source.id,
|
||||
kind: 'fork_content_copy',
|
||||
// Append-only: each fork is a distinct entry in the source workspace's fork history.
|
||||
supersede: false,
|
||||
message: hasContent ? `Copying resources to "${forkedName}"` : `Forked into "${forkedName}"`,
|
||||
metadata: {
|
||||
childWorkspaceId: result.workspace.id,
|
||||
childWorkspaceName: forkedName,
|
||||
actorName: params.actorName,
|
||||
workflowsCopied: result.workflowsCopied,
|
||||
tables: contentPlan.tables.length,
|
||||
knowledgeBases: contentPlan.knowledgeBases.length,
|
||||
files: blobTasks.length,
|
||||
workflowNames: forkedWorkflowNames,
|
||||
tableNames: forkedResourceNames.tables,
|
||||
knowledgeBaseNames: forkedResourceNames.knowledgeBases,
|
||||
fileNames: blobTasks.map((task) => task.fileName),
|
||||
customToolNames: forkedResourceNames.customTools,
|
||||
skillNames: forkedResourceNames.skills,
|
||||
mcpServerNames: forkedResourceNames.mcpServers,
|
||||
workflowMcpServerNames: forkedResourceNames.workflowMcpServers,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to record fork background-work status`, {
|
||||
childWorkspaceId: result.workspace.id,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
if (statusId) {
|
||||
await finishBackgroundWork(db, statusId, {
|
||||
status: 'completed',
|
||||
message: `Forked into "${forkedName}"`,
|
||||
metadata: { copied: 0, failed: 0 },
|
||||
}).catch(() => {})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const payload: ForkContentCopyPayload = {
|
||||
contentPlan,
|
||||
blobTasks,
|
||||
contentRefMaps,
|
||||
statusId,
|
||||
requestId,
|
||||
}
|
||||
await scheduleForkContentCopy(payload, { detachedLabel: 'fork-content-copy', requestId })
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
||||
import { isAppConfigEnabled, isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { HttpError } from '@/lib/core/utils/http-error'
|
||||
import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
|
||||
import { type ForkEdge, resolveForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
|
||||
/** Direction of a promote, relative to the workspace the caller is acting from. */
|
||||
export type PromoteDirection = 'push' | 'pull'
|
||||
|
||||
/**
|
||||
* Gate shared by every fork/promote route. The deployment/entitlement gate is
|
||||
* unchanged: on Sim Cloud the gate is the Enterprise plan; on self-hosted it's
|
||||
* `FORKING_ENABLED`, which 404s when unset so a newer image doesn't silently expose
|
||||
* forking. Mirrors the data-drains gate - this repo gates EE features by plan + env
|
||||
* flag, not by directory.
|
||||
*
|
||||
* Layered on top is the runtime `workspace-forking` flag, a rollout switch enforced
|
||||
* ONLY where AppConfig is the source of truth (Sim Cloud). It lets us dark-launch
|
||||
* forking to specific orgs/users/admins without a redeploy; self-hosted/local
|
||||
* deployments have no AppConfig, so their behaviour is untouched by the flag.
|
||||
*/
|
||||
async function assertForkingEnabled(organizationId: string | null, userId: string): Promise<void> {
|
||||
if (!isBillingEnabled && !isForkingEnabled) {
|
||||
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
|
||||
}
|
||||
if (isBillingEnabled) {
|
||||
const hasEnterprise = organizationId
|
||||
? await isOrganizationOnEnterprisePlan(organizationId)
|
||||
: false
|
||||
if (!hasEnterprise) {
|
||||
throw new ForkError('Workspace forking is available on Enterprise plans only', 403)
|
||||
}
|
||||
}
|
||||
if (
|
||||
isAppConfigEnabled &&
|
||||
!(await isFeatureEnabled('workspace-forking', { userId, orgId: organizationId }))
|
||||
) {
|
||||
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing availability verdict of the exact {@link assertForkingEnabled} gate
|
||||
* (env/plan + AppConfig rollout flag), for surfaces that show/hide fork UI. The
|
||||
* availability route serves this to the client so tab visibility can never drift
|
||||
* from what the fork routes actually enforce.
|
||||
*/
|
||||
export async function isForkingAvailableForWorkspace(
|
||||
organizationId: string | null,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await assertForkingEnabled(organizationId, userId)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain error for fork/promote operations. Carries a concrete `statusCode` so
|
||||
* `withRouteHandler` maps it to the right HTTP status and forwards the
|
||||
* client-safe `message`.
|
||||
*/
|
||||
export class ForkError extends HttpError {
|
||||
readonly statusCode: number
|
||||
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message)
|
||||
this.name = 'ForkError'
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
async function requireWorkspace(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<{ workspace: WorkspaceWithOwner; canAdmin: boolean }> {
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
if (!access.exists || !access.workspace) {
|
||||
throw new ForkError('Workspace not found', 404)
|
||||
}
|
||||
await assertForkingEnabled(access.workspace.organizationId, userId)
|
||||
return { workspace: access.workspace, canAdmin: access.canAdmin }
|
||||
}
|
||||
|
||||
/** Require admin access; returns the (active) workspace. */
|
||||
export async function assertWorkspaceAdminAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceWithOwner> {
|
||||
const { workspace, canAdmin } = await requireWorkspace(workspaceId, userId)
|
||||
if (!canAdmin) {
|
||||
throw new ForkError('Admin access is required for this workspace', 403)
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
export interface ForkAuthorization {
|
||||
source: WorkspaceWithOwner
|
||||
policy: WorkspaceCreationPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize forking `sourceWorkspaceId`: the caller needs admin access to the
|
||||
* source (a fork copies its deployed workflows and resources en masse) and must
|
||||
* pass the workspace-creation policy for the parent's org (the child inherits the
|
||||
* parent's org/mode; plan caps apply). Org owners/admins derive workspace admin.
|
||||
*
|
||||
* `pinOrganization` makes the child ALWAYS land in the source's org - including a
|
||||
* personal source (org `null`) - rather than the acting user's membership org,
|
||||
* which the policy would otherwise fall back to when the source is personal.
|
||||
*/
|
||||
export async function assertCanFork(
|
||||
sourceWorkspaceId: string,
|
||||
userId: string
|
||||
): Promise<ForkAuthorization> {
|
||||
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
|
||||
const policy = await getWorkspaceCreationPolicy({
|
||||
userId,
|
||||
activeOrganizationId: source.organizationId,
|
||||
pinOrganization: true,
|
||||
})
|
||||
if (!policy.canCreate) {
|
||||
throw new ForkError(
|
||||
policy.reason ?? 'You cannot create another workspace on your current plan',
|
||||
policy.status >= 400 ? policy.status : 403
|
||||
)
|
||||
}
|
||||
return { source, policy }
|
||||
}
|
||||
|
||||
export interface PromoteAuthorization {
|
||||
edge: ForkEdge
|
||||
source: WorkspaceWithOwner
|
||||
target: WorkspaceWithOwner
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a promote along the strict edge between `currentWorkspaceId` and
|
||||
* `otherWorkspaceId`. Requires admin on BOTH the source and the target: a sync
|
||||
* reads the source's deployed workflows/resources and force-replaces the target's,
|
||||
* and the sync surface is only ever offered to workspace admins. `push` sends
|
||||
* current -> other; `pull` brings other -> current.
|
||||
*/
|
||||
export async function assertCanPromote(
|
||||
currentWorkspaceId: string,
|
||||
otherWorkspaceId: string,
|
||||
direction: PromoteDirection,
|
||||
userId: string
|
||||
): Promise<PromoteAuthorization> {
|
||||
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
|
||||
if (!edge) {
|
||||
throw new ForkError('These workspaces are not a direct fork edge', 400)
|
||||
}
|
||||
const sourceWorkspaceId = direction === 'push' ? currentWorkspaceId : otherWorkspaceId
|
||||
const targetWorkspaceId = direction === 'push' ? otherWorkspaceId : currentWorkspaceId
|
||||
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
|
||||
const target = await assertWorkspaceAdminAccess(targetWorkspaceId, userId)
|
||||
return { edge, source, target, sourceWorkspaceId, targetWorkspaceId }
|
||||
}
|
||||
|
||||
/** Authorize rolling back the last promote into `targetWorkspaceId` (admin only). */
|
||||
export async function assertCanRollback(
|
||||
targetWorkspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceWithOwner> {
|
||||
return assertWorkspaceAdminAccess(targetWorkspaceId, userId)
|
||||
}
|
||||
|
||||
export interface UnlinkAuthorization {
|
||||
edge: ForkEdge
|
||||
current: WorkspaceWithOwner
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize permanently dissolving the fork edge between `currentWorkspaceId` and
|
||||
* `otherWorkspaceId`. Requires admin on the ACTING side only (mirrors rollback):
|
||||
* either participant may sever the association about itself — unlinking removes
|
||||
* shared edge metadata without reading or writing the other workspace's content,
|
||||
* and requiring both-side admin would strand an edge whose other side lost its
|
||||
* admins.
|
||||
*/
|
||||
export async function assertCanUnlink(
|
||||
currentWorkspaceId: string,
|
||||
otherWorkspaceId: string,
|
||||
userId: string
|
||||
): Promise<UnlinkAuthorization> {
|
||||
const current = await assertWorkspaceAdminAccess(currentWorkspaceId, userId)
|
||||
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
|
||||
if (!edge) {
|
||||
throw new ForkError('These workspaces are not a direct fork edge', 400)
|
||||
}
|
||||
return { edge, current }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } from '@sim/db/schema'
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
export interface ForkLineageNode {
|
||||
id: string
|
||||
name: string
|
||||
organizationId: string | null
|
||||
}
|
||||
|
||||
export interface ForkLineageChild extends ForkLineageNode {
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface ForkEdge {
|
||||
childWorkspaceId: string
|
||||
parentWorkspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent workspace id a fork was created from, or null when the workspace
|
||||
* is not a fork (or has been archived).
|
||||
*/
|
||||
export async function getForkParentId(workspaceId: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ parentId: workspace.forkedFromWorkspaceId })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
return row?.parentId ?? null
|
||||
}
|
||||
|
||||
/** The parent lineage node for a fork, or null when it has no live parent. */
|
||||
export async function getForkParent(workspaceId: string): Promise<ForkLineageNode | null> {
|
||||
const parentId = await getForkParentId(workspaceId)
|
||||
if (!parentId) return null
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
organizationId: workspace.organizationId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, parentId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The live (non-archived) forks created from this workspace, newest first, for
|
||||
* the Forks settings page's read-only fork list.
|
||||
*/
|
||||
export async function getForkChildren(workspaceId: string): Promise<ForkLineageChild[]> {
|
||||
return db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
organizationId: workspace.organizationId,
|
||||
createdAt: workspace.createdAt,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.forkedFromWorkspaceId, workspaceId), isNull(workspace.archivedAt)))
|
||||
.orderBy(desc(workspace.createdAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the strict fork edge between two workspaces, identifying which is the
|
||||
* child (the one whose `forkedFromWorkspaceId` points at the other). Returns
|
||||
* null when the two workspaces are not a direct parent/child pair.
|
||||
*/
|
||||
export async function resolveForkEdge(
|
||||
workspaceAId: string,
|
||||
workspaceBId: string
|
||||
): Promise<ForkEdge | null> {
|
||||
if (workspaceAId === workspaceBId) return null
|
||||
if ((await getForkParentId(workspaceAId)) === workspaceBId) {
|
||||
return { childWorkspaceId: workspaceAId, parentWorkspaceId: workspaceBId }
|
||||
}
|
||||
if ((await getForkParentId(workspaceBId)) === workspaceAId) {
|
||||
return { childWorkspaceId: workspaceBId, parentWorkspaceId: workspaceAId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a fork transaction waits for a lock before aborting. Bounds the wait on the
|
||||
* target/edge advisory locks (and any incidental row lock) so a contended sync into the
|
||||
* same target fails fast and returns its pooled connection instead of piling waiters up
|
||||
* and stagnating the pool at scale. 10s favors completing a legit sync queued behind an
|
||||
* in-flight one, while still tripping on a pathological hold. Connection-level timeouts
|
||||
* are not used (PlanetScale rejects them) - this is transaction-scoped only.
|
||||
*/
|
||||
const FORK_LOCK_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Apply {@link FORK_LOCK_TIMEOUT_MS} to the current transaction (`set_config(local)`),
|
||||
* so it covers `pg_advisory_xact_lock` waits too. Call at the very start of a fork
|
||||
* transaction, before acquiring any lock.
|
||||
*/
|
||||
export async function setForkLockTimeout(tx: DbOrTx): Promise<void> {
|
||||
await tx.execute(sql`select set_config('lock_timeout', ${`${FORK_LOCK_TIMEOUT_MS}ms`}, true)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize concurrent promote/rollback on a fork edge with a transaction-scoped
|
||||
* advisory lock keyed by the edge (the child workspace id). `hashtextextended`
|
||||
* (64-bit, matching every other advisory lock in the repo) makes a collision
|
||||
* between distinct keys astronomically unlikely; a collision would only cause
|
||||
* unnecessary serialization, never a correctness issue.
|
||||
*/
|
||||
export async function acquireForkEdgeLock(tx: DbOrTx, childWorkspaceId: string): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-edge:${childWorkspaceId}`}, 0))`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize every promote/rollback whose TARGET is this workspace. Sibling forks
|
||||
* promote into the same parent on different edge locks, so the edge lock alone does
|
||||
* not serialize them; this lock does, keeping concurrent syncs into one target from
|
||||
* interleaving and keeping rollback's "newest sync" check race-free. Always acquire
|
||||
* this BEFORE {@link acquireForkEdgeLock} so the two are taken in a consistent order.
|
||||
*/
|
||||
export async function acquireForkTargetLock(tx: DbOrTx, targetWorkspaceId: string): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-target:${targetWorkspaceId}`}, 0))`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockTransaction, mockSetForkLockTimeout, mockAcquireForkEdgeLock } = vi.hoisted(() => ({
|
||||
mockTransaction: vi.fn(),
|
||||
mockSetForkLockTimeout: vi.fn(),
|
||||
mockAcquireForkEdgeLock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({ db: { transaction: mockTransaction } }))
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
|
||||
setForkLockTimeout: mockSetForkLockTimeout,
|
||||
acquireForkEdgeLock: mockAcquireForkEdgeLock,
|
||||
}))
|
||||
|
||||
import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
|
||||
|
||||
/** A fake tx whose update returns `updatedRows` and whose deletes record their calls. */
|
||||
function fakeTx(updatedRows: Array<{ id: string }>) {
|
||||
const updateWhere = vi.fn(() => ({ returning: vi.fn().mockResolvedValue(updatedRows) }))
|
||||
const updateSet = vi.fn(() => ({ where: updateWhere }))
|
||||
const update = vi.fn(() => ({ set: updateSet }))
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined)
|
||||
const del = vi.fn(() => ({ where: deleteWhere }))
|
||||
return { tx: { update, delete: del }, update, updateSet, del }
|
||||
}
|
||||
|
||||
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
|
||||
|
||||
describe('unlinkForkEdge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('nulls the child pointer and purges all four edge tables under the edge lock', async () => {
|
||||
const { tx, update, updateSet, del } = fakeTx([{ id: 'child-ws' }])
|
||||
mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx))
|
||||
|
||||
const result = await unlinkForkEdge(EDGE, 'req-1')
|
||||
|
||||
expect(result).toEqual({ unlinked: true })
|
||||
expect(mockSetForkLockTimeout).toHaveBeenCalledTimes(1)
|
||||
expect(mockAcquireForkEdgeLock).toHaveBeenCalledWith(tx, 'child-ws')
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ forkedFromWorkspaceId: null }))
|
||||
expect(del).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('is an idempotent no-op when the edge was already dissolved', async () => {
|
||||
const { tx, del } = fakeTx([])
|
||||
mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx))
|
||||
|
||||
const result = await unlinkForkEdge(EDGE)
|
||||
|
||||
expect(result).toEqual({ unlinked: false })
|
||||
expect(del).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates a transaction failure without swallowing it', async () => {
|
||||
mockTransaction.mockRejectedValue(new Error('lock timeout'))
|
||||
await expect(unlinkForkEdge(EDGE)).rejects.toThrow('lock timeout')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
workspace,
|
||||
workspaceForkBlockMap,
|
||||
workspaceForkDependentValue,
|
||||
workspaceForkPromoteRun,
|
||||
workspaceForkResourceMap,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
acquireForkEdgeLock,
|
||||
type ForkEdge,
|
||||
setForkLockTimeout,
|
||||
} from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
|
||||
const logger = createLogger('ForkUnlink')
|
||||
|
||||
export interface UnlinkForkResult {
|
||||
/** False when the edge was already dissolved by a concurrent unlink (idempotent no-op). */
|
||||
unlinked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently dissolve a fork edge: null the child's `forkedFromWorkspaceId` (the
|
||||
* edge's single source of truth) and purge the edge's fork state — resource map,
|
||||
* block map, dependent values, and promote-run undo points. Both workspaces are
|
||||
* left untouched; only the association and its metadata are removed.
|
||||
*
|
||||
* Runs in one transaction under the edge advisory lock, which every promote and
|
||||
* rollback on the edge also holds, so an in-flight sync either finishes before the
|
||||
* unlink or re-resolves the edge afterwards and fails with "not a direct fork edge".
|
||||
* The edge is re-verified inside the lock; a concurrently-dissolved edge is an
|
||||
* idempotent success rather than an error.
|
||||
*/
|
||||
export async function unlinkForkEdge(
|
||||
edge: ForkEdge,
|
||||
requestId?: string
|
||||
): Promise<UnlinkForkResult> {
|
||||
const { childWorkspaceId, parentWorkspaceId } = edge
|
||||
|
||||
const unlinked = await db.transaction(async (tx) => {
|
||||
await setForkLockTimeout(tx)
|
||||
await acquireForkEdgeLock(tx, childWorkspaceId)
|
||||
|
||||
const updated = await tx
|
||||
.update(workspace)
|
||||
.set({ forkedFromWorkspaceId: null, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(workspace.id, childWorkspaceId),
|
||||
eq(workspace.forkedFromWorkspaceId, parentWorkspaceId)
|
||||
)
|
||||
)
|
||||
.returning({ id: workspace.id })
|
||||
if (updated.length === 0) return false
|
||||
|
||||
await tx
|
||||
.delete(workspaceForkResourceMap)
|
||||
.where(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId))
|
||||
await tx
|
||||
.delete(workspaceForkBlockMap)
|
||||
.where(eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId))
|
||||
await tx
|
||||
.delete(workspaceForkDependentValue)
|
||||
.where(eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId))
|
||||
await tx
|
||||
.delete(workspaceForkPromoteRun)
|
||||
.where(eq(workspaceForkPromoteRun.childWorkspaceId, childWorkspaceId))
|
||||
return true
|
||||
})
|
||||
|
||||
logger.info(`[${requestId ?? 'unlink'}] Fork edge ${unlinked ? 'dissolved' : 'already gone'}`, {
|
||||
childWorkspaceId,
|
||||
parentWorkspaceId,
|
||||
})
|
||||
return { unlinked }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toForkBlockPairs } from '@/ee/workspace-forking/lib/mapping/block-map-store'
|
||||
|
||||
describe('toForkBlockPairs', () => {
|
||||
const mapping = new Map([
|
||||
['src-1', 'tgt-1'],
|
||||
['src-2', 'tgt-2'],
|
||||
])
|
||||
|
||||
it('orients source->target as parent->child on pull/create (source = parent)', () => {
|
||||
expect(toForkBlockPairs(mapping, true, 'wf-parent', 'wf-child')).toEqual([
|
||||
{
|
||||
parentWorkflowId: 'wf-parent',
|
||||
childWorkflowId: 'wf-child',
|
||||
parentBlockId: 'src-1',
|
||||
childBlockId: 'tgt-1',
|
||||
},
|
||||
{
|
||||
parentWorkflowId: 'wf-parent',
|
||||
childWorkflowId: 'wf-child',
|
||||
parentBlockId: 'src-2',
|
||||
childBlockId: 'tgt-2',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('orients source->target as child->parent on push (source = child)', () => {
|
||||
expect(toForkBlockPairs(mapping, false, 'wf-child', 'wf-parent')).toEqual([
|
||||
{
|
||||
parentWorkflowId: 'wf-parent',
|
||||
childWorkflowId: 'wf-child',
|
||||
parentBlockId: 'tgt-1',
|
||||
childBlockId: 'src-1',
|
||||
},
|
||||
{
|
||||
parentWorkflowId: 'wf-parent',
|
||||
childWorkflowId: 'wf-child',
|
||||
parentBlockId: 'tgt-2',
|
||||
childBlockId: 'src-2',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty list for an empty mapping', () => {
|
||||
expect(toForkBlockPairs(new Map(), true, 'wf-parent', 'wf-child')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { workspaceForkBlockMap } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type { ForkBlockMap } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
|
||||
/** One persisted block-identity pair for an edge (carries both workflow sides). */
|
||||
export interface ForkBlockPair {
|
||||
parentWorkflowId: string
|
||||
parentBlockId: string
|
||||
childWorkflowId: string
|
||||
childBlockId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an edge's persisted block-identity pairs into both lookup directions, each entry
|
||||
* carrying its target-side workflow so the resolver can scope a reuse to the right workflow
|
||||
* (see {@link buildForkBlockIdResolver}). Blocks without a pair (added since the last sync)
|
||||
* fall back to the deterministic derive.
|
||||
*/
|
||||
export async function loadForkBlockMap(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string
|
||||
): Promise<ForkBlockMap> {
|
||||
const rows = await executor
|
||||
.select({
|
||||
parentWorkflowId: workspaceForkBlockMap.parentWorkflowId,
|
||||
parentBlockId: workspaceForkBlockMap.parentBlockId,
|
||||
childWorkflowId: workspaceForkBlockMap.childWorkflowId,
|
||||
childBlockId: workspaceForkBlockMap.childBlockId,
|
||||
})
|
||||
.from(workspaceForkBlockMap)
|
||||
.where(eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId))
|
||||
const parentToChild = new Map<string, { targetBlockId: string; targetWorkflowId: string }>()
|
||||
const childToParent = new Map<string, { targetBlockId: string; targetWorkflowId: string }>()
|
||||
for (const row of rows) {
|
||||
parentToChild.set(row.parentBlockId, {
|
||||
targetBlockId: row.childBlockId,
|
||||
targetWorkflowId: row.childWorkflowId,
|
||||
})
|
||||
childToParent.set(row.childBlockId, {
|
||||
targetBlockId: row.parentBlockId,
|
||||
targetWorkflowId: row.parentWorkflowId,
|
||||
})
|
||||
}
|
||||
return { parentToChild, childToParent }
|
||||
}
|
||||
|
||||
/**
|
||||
* Orient one workflow's copy mapping (`sourceBlockId -> targetBlockId`) into block pairs.
|
||||
* On pull/create the source is the parent; on push it's the child. The workflow ids are
|
||||
* fixed for the whole mapping (one source workflow copied into one target workflow).
|
||||
*/
|
||||
export function toForkBlockPairs(
|
||||
blockIdMapping: ReadonlyMap<string, string>,
|
||||
sourceIsParent: boolean,
|
||||
sourceWorkflowId: string,
|
||||
targetWorkflowId: string
|
||||
): ForkBlockPair[] {
|
||||
const parentWorkflowId = sourceIsParent ? sourceWorkflowId : targetWorkflowId
|
||||
const childWorkflowId = sourceIsParent ? targetWorkflowId : sourceWorkflowId
|
||||
const pairs: ForkBlockPair[] = []
|
||||
for (const [sourceBlockId, targetBlockId] of blockIdMapping) {
|
||||
pairs.push({
|
||||
parentWorkflowId,
|
||||
childWorkflowId,
|
||||
parentBlockId: sourceIsParent ? sourceBlockId : targetBlockId,
|
||||
childBlockId: sourceIsParent ? targetBlockId : sourceBlockId,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the persisted pairs for the promoted SOURCE workflows with the live ones. Deleting
|
||||
* by the (stable) source-side workflow id first does two things: it nukes pairs for blocks
|
||||
* the source deleted since the last sync (e.g. a removed trigger), and it clears the old pair
|
||||
* for a workflow whose target was archived + re-created - so the map always reflects the live
|
||||
* lineage and a stale pair can never re-home a block onto an archived workflow's id. Block
|
||||
* identity is otherwise immutable, so on a steady sync this just deletes and re-inserts the
|
||||
* same pairs. `sourceIsParent` is true on pull/create, false on push.
|
||||
*/
|
||||
export async function reconcileForkBlockPairs(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
sourceIsParent: boolean,
|
||||
sourceWorkflowIds: string[],
|
||||
pairs: ForkBlockPair[]
|
||||
): Promise<void> {
|
||||
if (sourceWorkflowIds.length > 0) {
|
||||
const sourceWorkflowColumn = sourceIsParent
|
||||
? workspaceForkBlockMap.parentWorkflowId
|
||||
: workspaceForkBlockMap.childWorkflowId
|
||||
await executor
|
||||
.delete(workspaceForkBlockMap)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId),
|
||||
inArray(sourceWorkflowColumn, sourceWorkflowIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (pairs.length === 0) return
|
||||
const now = new Date()
|
||||
await executor
|
||||
.insert(workspaceForkBlockMap)
|
||||
.values(
|
||||
pairs.map((pair) => ({
|
||||
id: generateId(),
|
||||
childWorkspaceId,
|
||||
parentWorkflowId: pair.parentWorkflowId,
|
||||
parentBlockId: pair.parentBlockId,
|
||||
childWorkflowId: pair.childWorkflowId,
|
||||
childBlockId: pair.childBlockId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade'
|
||||
import type {
|
||||
ForkReference,
|
||||
ForkReferenceResolver,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
/** Executor that returns the queued result arrays in the order queries are issued. */
|
||||
function queuedExecutor(results: unknown[][]): DbOrTx {
|
||||
let index = 0
|
||||
const builder = {
|
||||
from: () => builder,
|
||||
innerJoin: () => builder,
|
||||
where: () => Promise.resolve(results[index++] ?? []),
|
||||
}
|
||||
return { select: () => builder } as unknown as DbOrTx
|
||||
}
|
||||
|
||||
function ref(kind: ForkReference['kind'], sourceId: string): ForkReference {
|
||||
return { kind, sourceId, subBlockKey: 'tools', required: false }
|
||||
}
|
||||
|
||||
const resolveNone: ForkReferenceResolver = () => null
|
||||
const resolveAll: ForkReferenceResolver = (_kind, sourceId) => sourceId
|
||||
|
||||
describe('detectForkCascadeReferences', () => {
|
||||
it('returns empty when there are no content references', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('credential', 'cred-1'), ref('table', 'tbl-1')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toEqual([])
|
||||
expect(result.unmapped).toEqual([])
|
||||
expect(result.mcpReauthServerIds).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces env keys from custom tool code as required unmapped env-var refs', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([[{ id: 't1', title: 'Weather', code: 'fetch(`{{API_KEY}}`)' }]]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('custom-tool', 't1')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toHaveLength(1)
|
||||
expect(result.references[0]).toMatchObject({
|
||||
kind: 'env-var',
|
||||
sourceId: 'API_KEY',
|
||||
required: true,
|
||||
})
|
||||
expect(result.unmapped).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('marks env-var cascade refs mapped when the resolver finds them in the target', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([[{ id: 't1', title: 'Weather', code: '{{API_KEY}}' }]]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('custom-tool', 't1')],
|
||||
resolve: resolveAll,
|
||||
})
|
||||
expect(result.references).toHaveLength(1)
|
||||
expect(result.unmapped).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts env keys from MCP url/headers and flags oauth servers for re-auth', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([
|
||||
[
|
||||
{
|
||||
id: 'mcp-1',
|
||||
name: 'Server',
|
||||
url: 'https://x/{{HOST_KEY}}',
|
||||
headers: { Authorization: '{{TOKEN}}' },
|
||||
authType: 'headers',
|
||||
},
|
||||
{ id: 'mcp-2', name: 'OAuth Server', url: 'https://y', headers: {}, authType: 'oauth' },
|
||||
],
|
||||
]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('mcp-server', 'mcp-1'), ref('mcp-server', 'mcp-2')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
const envIds = result.references
|
||||
.filter((r) => r.kind === 'env-var')
|
||||
.map((r) => r.sourceId)
|
||||
.sort()
|
||||
expect(envIds).toEqual(['HOST_KEY', 'TOKEN'])
|
||||
expect(result.mcpReauthServerIds).toEqual(['mcp-2'])
|
||||
})
|
||||
|
||||
it('flags literal MCP header values as inline secrets (not env)', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([
|
||||
[
|
||||
{
|
||||
id: 'mcp-1',
|
||||
name: 'Server',
|
||||
url: 'https://x',
|
||||
headers: { Authorization: 'sk-literal' },
|
||||
authType: 'headers',
|
||||
},
|
||||
],
|
||||
]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('mcp-server', 'mcp-1')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toHaveLength(0)
|
||||
expect(result.inlineSecretSources).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces KB connector credentials as required credential refs', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([
|
||||
[{ id: 'kc-1', knowledgeBaseId: 'kb-1', credentialId: 'cred-9', encryptedApiKey: null }],
|
||||
]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('knowledge-base', 'kb-1')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toHaveLength(1)
|
||||
expect(result.references[0]).toMatchObject({
|
||||
kind: 'credential',
|
||||
sourceId: 'cred-9',
|
||||
required: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('dedupes a shared env key referenced by two custom tools', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([
|
||||
[
|
||||
{ id: 't1', title: 'A', code: '{{SHARED}}' },
|
||||
{ id: 't2', title: 'B', code: '{{SHARED}}' },
|
||||
],
|
||||
]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('custom-tool', 't1'), ref('custom-tool', 't2')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toHaveLength(1)
|
||||
expect(result.references[0].sourceId).toBe('SHARED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { customTools, knowledgeBase, knowledgeConnector, mcpServers } from '@sim/db/schema'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
ENV_REF_PATTERN,
|
||||
type ForkReference,
|
||||
type ForkReferenceResolver,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
function extractEnvKeys(text: string): string[] {
|
||||
const keys = new Set<string>()
|
||||
for (const match of text.matchAll(ENV_REF_PATTERN)) {
|
||||
if (match[1]) keys.add(match[1])
|
||||
}
|
||||
return Array.from(keys)
|
||||
}
|
||||
|
||||
export interface ForkCascadeResult {
|
||||
/** Transitive env-var / credential references discovered inside referenced resources. */
|
||||
references: ForkReference[]
|
||||
unmapped: ForkReference[]
|
||||
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
|
||||
mcpReauthServerIds: string[]
|
||||
/** Human-readable descriptions of inline secrets that cannot be mapped (review-only). */
|
||||
inlineSecretSources: string[]
|
||||
}
|
||||
|
||||
const EMPTY: ForkCascadeResult = {
|
||||
references: [],
|
||||
unmapped: [],
|
||||
mcpReauthServerIds: [],
|
||||
inlineSecretSources: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the bodies of resources a workflow references (custom tools, MCP servers,
|
||||
* knowledge bases) and surface the secrets they carry transitively: `{{ENV}}`
|
||||
* keys inside custom tool code and MCP url/headers, and credential ids on KB
|
||||
* connectors. These become additional required env-var / credential references
|
||||
* (validated for existence in the target via `resolve`). OAuth MCP servers and
|
||||
* inline connector keys are surfaced separately for review since they cannot be
|
||||
* id-mapped. Reads only the source workspace's resources.
|
||||
*/
|
||||
export async function detectForkCascadeReferences(params: {
|
||||
executor: DbOrTx
|
||||
sourceWorkspaceId: string
|
||||
references: ForkReference[]
|
||||
resolve: ForkReferenceResolver
|
||||
}): Promise<ForkCascadeResult> {
|
||||
const { executor, sourceWorkspaceId, references, resolve } = params
|
||||
|
||||
const customToolIds = new Set<string>()
|
||||
const mcpServerIds = new Set<string>()
|
||||
const knowledgeBaseIds = new Set<string>()
|
||||
for (const reference of references) {
|
||||
if (reference.kind === 'custom-tool') customToolIds.add(reference.sourceId)
|
||||
else if (reference.kind === 'mcp-server') mcpServerIds.add(reference.sourceId)
|
||||
else if (reference.kind === 'knowledge-base') knowledgeBaseIds.add(reference.sourceId)
|
||||
}
|
||||
|
||||
if (customToolIds.size === 0 && mcpServerIds.size === 0 && knowledgeBaseIds.size === 0) {
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
const refs = new Map<string, ForkReference>()
|
||||
const unmapped = new Map<string, ForkReference>()
|
||||
const mcpReauthServerIds = new Set<string>()
|
||||
const inlineSecretSources: string[] = []
|
||||
|
||||
const recordEnv = (key: string, sourceLabel: string) => {
|
||||
const dedupeKey = `env-var:${key}`
|
||||
if (refs.has(dedupeKey)) return
|
||||
const reference: ForkReference = {
|
||||
kind: 'env-var',
|
||||
sourceId: key,
|
||||
subBlockKey: '(cascade)',
|
||||
blockName: sourceLabel,
|
||||
required: true,
|
||||
}
|
||||
refs.set(dedupeKey, reference)
|
||||
if (resolve('env-var', key) == null) unmapped.set(dedupeKey, reference)
|
||||
}
|
||||
|
||||
const recordCredential = (credentialId: string, sourceLabel: string) => {
|
||||
const dedupeKey = `credential:${credentialId}`
|
||||
if (refs.has(dedupeKey)) return
|
||||
const reference: ForkReference = {
|
||||
kind: 'credential',
|
||||
sourceId: credentialId,
|
||||
subBlockKey: '(cascade)',
|
||||
blockName: sourceLabel,
|
||||
required: true,
|
||||
}
|
||||
refs.set(dedupeKey, reference)
|
||||
if (resolve('credential', credentialId) == null) unmapped.set(dedupeKey, reference)
|
||||
}
|
||||
|
||||
if (customToolIds.size > 0) {
|
||||
const tools = await executor
|
||||
.select({ id: customTools.id, title: customTools.title, code: customTools.code })
|
||||
.from(customTools)
|
||||
.where(
|
||||
and(
|
||||
inArray(customTools.id, Array.from(customToolIds)),
|
||||
eq(customTools.workspaceId, sourceWorkspaceId)
|
||||
)
|
||||
)
|
||||
for (const tool of tools) {
|
||||
for (const key of extractEnvKeys(tool.code ?? '')) {
|
||||
recordEnv(key, `Custom tool: ${tool.title}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mcpServerIds.size > 0) {
|
||||
const servers = await executor
|
||||
.select({
|
||||
id: mcpServers.id,
|
||||
name: mcpServers.name,
|
||||
url: mcpServers.url,
|
||||
headers: mcpServers.headers,
|
||||
authType: mcpServers.authType,
|
||||
})
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
inArray(mcpServers.id, Array.from(mcpServerIds)),
|
||||
eq(mcpServers.workspaceId, sourceWorkspaceId)
|
||||
)
|
||||
)
|
||||
for (const server of servers) {
|
||||
const label = `MCP server: ${server.name}`
|
||||
if (server.url) {
|
||||
for (const key of extractEnvKeys(server.url)) recordEnv(key, label)
|
||||
}
|
||||
const headers = (server.headers ?? {}) as Record<string, unknown>
|
||||
for (const [headerName, headerValue] of Object.entries(headers)) {
|
||||
if (typeof headerValue !== 'string') continue
|
||||
const keys = extractEnvKeys(headerValue)
|
||||
if (keys.length > 0) {
|
||||
for (const key of keys) recordEnv(key, label)
|
||||
} else if (server.authType === 'headers' && headerValue) {
|
||||
inlineSecretSources.push(`${label} (header ${headerName})`)
|
||||
}
|
||||
}
|
||||
if (server.authType === 'oauth') mcpReauthServerIds.add(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (knowledgeBaseIds.size > 0) {
|
||||
// Join to knowledgeBase + filter by the source workspace (defense-in-depth, matching the
|
||||
// customTools/mcpServers reads above): a connector is only cascaded when its KB actually
|
||||
// belongs to the source workspace, so a stale/crafted KB id can't pull in a foreign connector.
|
||||
const connectors = await executor
|
||||
.select({
|
||||
id: knowledgeConnector.id,
|
||||
knowledgeBaseId: knowledgeConnector.knowledgeBaseId,
|
||||
credentialId: knowledgeConnector.credentialId,
|
||||
encryptedApiKey: knowledgeConnector.encryptedApiKey,
|
||||
})
|
||||
.from(knowledgeConnector)
|
||||
.innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(knowledgeConnector.knowledgeBaseId, Array.from(knowledgeBaseIds)),
|
||||
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
|
||||
isNull(knowledgeBase.deletedAt),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
for (const connector of connectors) {
|
||||
if (connector.credentialId) {
|
||||
recordCredential(connector.credentialId, `Knowledge base connector`)
|
||||
} else if (connector.encryptedApiKey) {
|
||||
inlineSecretSources.push(`Knowledge base connector ${connector.id} (API key)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
references: Array.from(refs.values()),
|
||||
unmapped: Array.from(unmapped.values()),
|
||||
mcpReauthServerIds: Array.from(mcpReauthServerIds),
|
||||
inlineSecretSources,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import {
|
||||
collectForkDependentReconfigs,
|
||||
collectForkResourceUsages,
|
||||
} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
|
||||
import {
|
||||
buildForkBlockIdResolver,
|
||||
deriveForkBlockId,
|
||||
EMPTY_FORK_BLOCK_MAP,
|
||||
} from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
|
||||
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
|
||||
|
||||
const sourceState = (
|
||||
blockType: string,
|
||||
subBlocks: Record<string, { value: unknown }>
|
||||
): WorkflowState =>
|
||||
({
|
||||
blocks: { 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks } },
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
}) as unknown as WorkflowState
|
||||
|
||||
const replaceItem = {
|
||||
sourceWorkflowId: 'wf-src',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
mode: 'replace' as const,
|
||||
}
|
||||
|
||||
// No persisted block map in these unit tests, so the resolver derives - matching the
|
||||
// `deriveForkBlockId(...)` ids the expectations assert.
|
||||
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
|
||||
|
||||
describe('collectForkDependentReconfigs', () => {
|
||||
it("emits the active operation's credential-dependent selector (condition-gated)", () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{ id: 'operation', title: 'Operation', type: 'dropdown' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
},
|
||||
// A different operation's variant -> excluded by its condition, not by emptiness.
|
||||
{
|
||||
id: 'otherFolder',
|
||||
title: 'Move To Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
condition: { field: 'operation', value: 'move' },
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('gmail', {
|
||||
credential: { value: 'cred-src' },
|
||||
operation: { value: 'read' },
|
||||
folder: { value: 'INBOX' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-src',
|
||||
parentContextKey: 'oauthCredential',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
|
||||
blockName: 'Block',
|
||||
subBlockKey: 'folder',
|
||||
selectorKey: 'gmail.labels',
|
||||
title: 'Label',
|
||||
currentValue: 'INBOX',
|
||||
required: true,
|
||||
consumesContextKeys: [],
|
||||
context: {},
|
||||
sourceValue: 'INBOX',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('skips an anchor whose canonical pair is in advanced (manual) mode - the value passes through', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{
|
||||
id: 'knowledgeBaseSelector',
|
||||
title: 'Knowledge Base',
|
||||
type: 'knowledge-base-selector',
|
||||
canonicalParamId: 'knowledgeBaseId',
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'manualKnowledgeBaseId',
|
||||
title: 'KB ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'knowledgeBaseId',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'documentSelector',
|
||||
title: 'Document',
|
||||
type: 'document-selector',
|
||||
selectorKey: 'knowledge.documents',
|
||||
dependsOn: ['knowledgeBaseSelector'],
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
// Advanced mode active: the dormant basic selector is empty; the active manual id holds the KB.
|
||||
const advancedState = {
|
||||
blocks: {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'knowledge',
|
||||
name: 'Block',
|
||||
data: { canonicalModes: { knowledgeBaseId: 'advanced' } },
|
||||
subBlocks: {
|
||||
knowledgeBaseSelector: { value: '' },
|
||||
manualKnowledgeBaseId: { value: 'kb-active' },
|
||||
documentSelector: { value: 'doc-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
} as unknown as WorkflowState
|
||||
const result = collectForkDependentReconfigs(
|
||||
[replaceItem],
|
||||
new Map([['wf-src', advancedState]]),
|
||||
resolve
|
||||
)
|
||||
// Advanced (manual) mode: the manual KB id is user-owned and passes through verbatim on
|
||||
// sync - it is never remapped, so its dependents are never cleared and there is nothing
|
||||
// to re-pick. No reconfig is offered.
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('emits a knowledge-base-dependent document selector', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{
|
||||
id: 'knowledgeBaseSelector',
|
||||
title: 'Knowledge Base',
|
||||
type: 'knowledge-base-selector',
|
||||
canonicalParamId: 'knowledgeBaseId',
|
||||
},
|
||||
{
|
||||
id: 'documentSelector',
|
||||
title: 'Document',
|
||||
type: 'document-selector',
|
||||
canonicalParamId: 'documentId',
|
||||
selectorKey: 'knowledge.documents',
|
||||
dependsOn: ['knowledgeBaseSelector'],
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('knowledge', {
|
||||
knowledgeBaseSelector: { value: 'kb-src' },
|
||||
documentSelector: { value: 'doc-src' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
parentKind: 'knowledge-base',
|
||||
parentSourceId: 'kb-src',
|
||||
parentContextKey: 'knowledgeBaseId',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
|
||||
blockName: 'Block',
|
||||
subBlockKey: 'documentSelector',
|
||||
selectorKey: 'knowledge.documents',
|
||||
title: 'Document',
|
||||
currentValue: 'doc-src',
|
||||
required: true,
|
||||
consumesContextKeys: [],
|
||||
context: {},
|
||||
sourceValue: 'doc-src',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('offers an active credential selector even when the source left it empty', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
)
|
||||
// The source has the credential but no label - the user must still be able to set one
|
||||
// during the swap (a prior sync may have cleared it), so the selector is offered.
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: '' } }),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ subBlockKey: 'folder', parentSourceId: 'cred-src' })
|
||||
})
|
||||
|
||||
it('still skips a selector whose parent credential is unset', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
['wf-src', sourceState('gmail', { credential: { value: '' }, folder: { value: 'INBOX' } })],
|
||||
])
|
||||
expect(collectForkDependentReconfigs([replaceItem], states, resolve)).toEqual([])
|
||||
})
|
||||
|
||||
it('skips create-mode targets', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
)
|
||||
const created = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: 'INBOX' } }),
|
||||
],
|
||||
])
|
||||
expect(
|
||||
collectForkDependentReconfigs(
|
||||
[{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt', mode: 'create' }],
|
||||
created,
|
||||
resolve
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('walks the transitive chain and tags the context key a re-pick provides', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'spreadsheetId',
|
||||
title: 'Spreadsheet',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'spreadsheetId',
|
||||
selectorKey: 'google.drive',
|
||||
dependsOn: ['credential'],
|
||||
},
|
||||
{
|
||||
id: 'sheetName',
|
||||
title: 'Sheet',
|
||||
type: 'sheet-selector',
|
||||
selectorKey: 'google.sheets',
|
||||
dependsOn: ['spreadsheetId'],
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('google_sheets', {
|
||||
credential: { value: 'cred-src' },
|
||||
spreadsheetId: { value: 'ss-src' },
|
||||
sheetName: { value: 'Sheet1' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
// Both the spreadsheet (direct) and its sheet (transitive) are offered, in order.
|
||||
expect(result.map((entry) => entry.subBlockKey)).toEqual(['spreadsheetId', 'sheetName'])
|
||||
const spreadsheet = result.find((entry) => entry.subBlockKey === 'spreadsheetId')
|
||||
expect(spreadsheet?.parentKind).toBe('credential')
|
||||
expect(spreadsheet?.providesContextKey).toBe('spreadsheetId')
|
||||
const sheet = result.find((entry) => entry.subBlockKey === 'sheetName')
|
||||
expect(sheet?.parentKind).toBe('credential')
|
||||
expect(sheet?.required).toBe(true)
|
||||
// The sheet consumes the spreadsheet's key, so the modal gates it on that re-pick.
|
||||
expect(sheet?.consumesContextKeys).toEqual(['spreadsheetId'])
|
||||
// The source spreadsheet rides in context; the modal overlays the re-picked one.
|
||||
expect(sheet?.context.spreadsheetId).toBe('ss-src')
|
||||
})
|
||||
|
||||
it('emits a credential-dependent selector nested inside a tool-input tool', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('agent', {
|
||||
tools: {
|
||||
value: [
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail 1',
|
||||
params: { credential: 'cred-src', folder: 'INBOX' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-src',
|
||||
parentContextKey: 'oauthCredential',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
|
||||
blockName: 'Block',
|
||||
subBlockKey: 'tools[0].folder',
|
||||
selectorKey: 'gmail.labels',
|
||||
title: 'Label',
|
||||
toolName: 'Gmail 1',
|
||||
currentValue: 'INBOX',
|
||||
required: true,
|
||||
consumesContextKeys: [],
|
||||
context: {},
|
||||
sourceValue: 'INBOX',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('honors a nested tool-scoped advanced override (manual mode passes through - no re-pick)', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Credential',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'credential',
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Credential ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'credential',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
// Agent block with a nested gmail tool; the dormant basic credential holds a stale id while the
|
||||
// tool-scoped `0:credential` override (when present) marks advanced as active.
|
||||
const agentState = (canonicalModes?: Record<string, 'basic' | 'advanced'>) =>
|
||||
({
|
||||
blocks: {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'agent',
|
||||
name: 'Block',
|
||||
data: canonicalModes ? { canonicalModes } : {},
|
||||
subBlocks: {
|
||||
tools: {
|
||||
value: [
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail 1',
|
||||
params: {
|
||||
credential: 'cred-stale',
|
||||
manualCredential: 'cred-active',
|
||||
folder: 'INBOX',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
}) as unknown as WorkflowState
|
||||
|
||||
// Scoped override present -> advanced (manual) mode: the manual credential passes through
|
||||
// verbatim on sync, so its dependents are never cleared and no re-pick is offered.
|
||||
const withOverride = collectForkDependentReconfigs(
|
||||
[replaceItem],
|
||||
new Map([['wf-src', agentState({ '0:credential': 'advanced' })]]),
|
||||
resolve
|
||||
)
|
||||
expect(withOverride).toEqual([])
|
||||
|
||||
// Control: no override -> the value heuristic keeps the non-empty basic (unchanged behavior).
|
||||
const heuristic = collectForkDependentReconfigs(
|
||||
[replaceItem],
|
||||
new Map([['wf-src', agentState()]]),
|
||||
resolve
|
||||
)
|
||||
expect(heuristic).toHaveLength(1)
|
||||
expect(heuristic[0]).toMatchObject({
|
||||
parentSourceId: 'cred-stale',
|
||||
subBlockKey: 'tools[0].folder',
|
||||
})
|
||||
})
|
||||
|
||||
it('regression: two same-type nested tools resolve their canonical-mode override independently', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Credential',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'credential',
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Credential ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'credential',
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
{
|
||||
blocks: {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'agent',
|
||||
name: 'Block',
|
||||
// Tool #0 is scoped to advanced (manual mode passes through - no re-pick, per the
|
||||
// test above); tool #1 is scoped to basic (re-pick offered on its own value). Both
|
||||
// are the SAME tool type ("gmail") and the SAME canonicalId ("credential"), so only
|
||||
// the per-instance index can tell them apart - before the fix both shared the single
|
||||
// `gmail:credential` key, which would have made tool #1 advanced-active too (and
|
||||
// therefore ALSO skipped, dropping the result to zero entries instead of one).
|
||||
data: { canonicalModes: { '0:credential': 'advanced', '1:credential': 'basic' } },
|
||||
subBlocks: {
|
||||
tools: {
|
||||
value: [
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail 1',
|
||||
params: {
|
||||
credential: 'cred-0-stale',
|
||||
manualCredential: 'cred-0-active',
|
||||
folder: 'INBOX',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail 2',
|
||||
params: {
|
||||
credential: 'cred-1-basic',
|
||||
manualCredential: 'cred-1-stale',
|
||||
folder: 'SENT',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
} as unknown as WorkflowState,
|
||||
],
|
||||
])
|
||||
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
parentSourceId: 'cred-1-basic',
|
||||
subBlockKey: 'tools[1].folder',
|
||||
})
|
||||
})
|
||||
|
||||
it('offers a nested tool selector even when the source left it empty', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('agent', {
|
||||
tools: {
|
||||
value: [
|
||||
{ type: 'gmail', title: 'Gmail 1', params: { credential: 'cred-src', folder: '' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
subBlockKey: 'tools[0].folder',
|
||||
title: 'Label',
|
||||
toolName: 'Gmail 1',
|
||||
})
|
||||
})
|
||||
|
||||
it('evaluates a nested tool selector condition against the tool-level operation', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
// Active only under read - and `operation` lives at the tool level, not params.
|
||||
condition: { field: 'operation', value: 'read_gmail' },
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const reading = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('agent', {
|
||||
tools: {
|
||||
value: [
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail',
|
||||
operation: 'read_gmail',
|
||||
params: { credential: 'cred-src', folder: 'INBOX' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
])
|
||||
expect(collectForkDependentReconfigs([replaceItem], reading, resolve)).toHaveLength(1)
|
||||
|
||||
// Same tool under a different operation -> the read-only label is gated off.
|
||||
const sending = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('agent', {
|
||||
tools: {
|
||||
value: [
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail',
|
||||
operation: 'send_gmail',
|
||||
params: { credential: 'cred-src', folder: 'INBOX' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
])
|
||||
expect(collectForkDependentReconfigs([replaceItem], sending, resolve)).toEqual([])
|
||||
})
|
||||
|
||||
it('anchors on a table selector for its column dependents', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{
|
||||
id: 'tableSelector',
|
||||
title: 'Table',
|
||||
type: 'table-selector',
|
||||
canonicalParamId: 'tableId',
|
||||
},
|
||||
{
|
||||
id: 'conflictColumnSelector',
|
||||
title: 'Column',
|
||||
type: 'column-selector',
|
||||
canonicalParamId: 'conflictColumn',
|
||||
selectorKey: 'table.columns',
|
||||
dependsOn: ['tableSelector'],
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('table', {
|
||||
tableSelector: { value: 'tbl-src' },
|
||||
conflictColumnSelector: { value: 'col1' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
parentKind: 'table',
|
||||
parentSourceId: 'tbl-src',
|
||||
parentContextKey: 'tableId',
|
||||
subBlockKey: 'conflictColumnSelector',
|
||||
selectorKey: 'table.columns',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectForkResourceUsages', () => {
|
||||
const usageItem = (
|
||||
sourceWorkflowId: string,
|
||||
targetWorkflowId: string,
|
||||
name: string,
|
||||
mode: 'create' | 'replace' = 'replace'
|
||||
) => ({ sourceWorkflowId, targetWorkflowId, mode, sourceMeta: { name } })
|
||||
|
||||
// The reference scan reads each subblock entry's own `type`, so credential usages need
|
||||
// typed entries (unlike the dependent collector, which keys off the block config).
|
||||
const credentialState = (credentialId: string): WorkflowState =>
|
||||
({
|
||||
blocks: {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'gmail',
|
||||
name: 'Block',
|
||||
subBlocks: { credential: { id: 'credential', type: 'oauth-input', value: credentialId } },
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
}) as unknown as WorkflowState
|
||||
|
||||
it('lists each replace workflow a resource is used in, with its (target) name', () => {
|
||||
const states = new Map<string, WorkflowState>([
|
||||
['wf-a', credentialState('cred-src')],
|
||||
['wf-b', credentialState('cred-src')],
|
||||
])
|
||||
const result = collectForkResourceUsages(
|
||||
[usageItem('wf-a', 'wf-tgt-a', 'Workflow A'), usageItem('wf-b', 'wf-tgt-b', 'Workflow B')],
|
||||
states
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-src',
|
||||
workflows: [
|
||||
{ workflowId: 'wf-tgt-a', workflowName: 'Workflow A' },
|
||||
{ workflowId: 'wf-tgt-b', workflowName: 'Workflow B' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('includes create-mode targets (never-synced workflows count toward the next sync)', () => {
|
||||
const states = new Map<string, WorkflowState>([['wf-a', credentialState('cred-src')]])
|
||||
expect(
|
||||
collectForkResourceUsages([usageItem('wf-a', 'wf-tgt-a', 'A', 'create')], states)
|
||||
).toEqual([
|
||||
{
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-src',
|
||||
workflows: [{ workflowId: 'wf-tgt-a', workflowName: 'A' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,383 @@
|
||||
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
|
||||
import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
|
||||
import {
|
||||
buildSelectorContextFromBlock,
|
||||
SELECTOR_CONTEXT_FIELDS,
|
||||
} from '@/lib/workflows/subblocks/context'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildSubBlockValues,
|
||||
type CanonicalModeOverrides,
|
||||
evaluateSubBlockCondition,
|
||||
isNonEmptyValue,
|
||||
scopeCanonicalModesForTool,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
|
||||
import {
|
||||
createCanonicalModeGates,
|
||||
isSubBlockRequired,
|
||||
scanWorkflowReferences,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const isSelectorContextKey = (
|
||||
key: string
|
||||
): key is Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0] =>
|
||||
SELECTOR_CONTEXT_FIELDS.has(key as Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0])
|
||||
|
||||
interface ReconfigItem {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
mode: 'create' | 'replace'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent anchor types a dependent selector can hang off, with the SelectorContext key
|
||||
* the new parent value is supplied under. A parent is a remappable resource (rewritten
|
||||
* source->target on sync) whose target swap clears its dependents. MCP servers are
|
||||
* intentionally excluded: their tool dependent has no `selectorKey` and a separate
|
||||
* (non-`useSelectorOptions`) stack, so it falls back to the needs-config surfacing.
|
||||
*/
|
||||
const PARENT_ANCHORS: ReadonlyArray<{
|
||||
subBlockType: string
|
||||
parentKind: ForkDependentReconfig['parentKind']
|
||||
parentContextKey: string
|
||||
}> = [
|
||||
{ subBlockType: 'oauth-input', parentKind: 'credential', parentContextKey: 'oauthCredential' },
|
||||
{
|
||||
subBlockType: 'knowledge-base-selector',
|
||||
parentKind: 'knowledge-base',
|
||||
parentContextKey: 'knowledgeBaseId',
|
||||
},
|
||||
{ subBlockType: 'table-selector', parentKind: 'table', parentContextKey: 'tableId' },
|
||||
]
|
||||
|
||||
interface EmitAnchoredParams {
|
||||
/** The block (top-level) or tool config whose subblocks are scanned for anchors. */
|
||||
config: NonNullable<ReturnType<typeof getBlock>>
|
||||
/** Flat id -> value map for that config (top-level subblock values, or a tool's params). */
|
||||
values: Record<string, unknown>
|
||||
/** Block/tool type + its subblock shape, for building the source selector context. */
|
||||
contextBlockType: string
|
||||
contextSubBlocks: Record<string, { value?: unknown }>
|
||||
blockName: string
|
||||
targetWorkflowId: string
|
||||
/** Canonical-mode overrides for resolving the active parent member (undefined -> value heuristic). */
|
||||
canonicalModes?: CanonicalModeOverrides
|
||||
/** Memoized so the deterministic target block id is derived at most once per block. */
|
||||
resolveTargetBlockId: () => string
|
||||
/** Map a dependent's config id to its wire `subBlockKey` (identity, or nested `tools[i].id`). */
|
||||
makeSubBlockKey: (dependentId: string) => string
|
||||
makeTitle: (dependent: SubBlockConfig) => string
|
||||
/** Nested `tool-input` tool display name; omitted for top-level block subblocks. */
|
||||
toolName?: string
|
||||
/**
|
||||
* Emit `providesContextKey`/`consumesContextKeys` so the modal can chain in-block
|
||||
* re-picks. Top-level chains; nested tool params don't (a tool's chain would need
|
||||
* per-tool context scoping - out of scope - and the common nested case is a single
|
||||
* credential-anchored field).
|
||||
*/
|
||||
chaining: boolean
|
||||
out: ForkDependentReconfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one config's credential/KB/table-anchored selector dependents that the source had
|
||||
* configured. Shared by the top-level subblock scan and the nested `tool-input` tool scan.
|
||||
* Walks the FULL transitive dependent chain (parent -> child -> grandchild) per anchor and
|
||||
* dedups fields reachable via multiple anchors/paths.
|
||||
*/
|
||||
function emitAnchoredDependents(params: EmitAnchoredParams): void {
|
||||
const {
|
||||
config,
|
||||
values,
|
||||
contextBlockType,
|
||||
contextSubBlocks,
|
||||
blockName,
|
||||
targetWorkflowId,
|
||||
canonicalModes,
|
||||
resolveTargetBlockId,
|
||||
makeSubBlockKey,
|
||||
makeTitle,
|
||||
toolName,
|
||||
chaining,
|
||||
out,
|
||||
} = params
|
||||
const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks)
|
||||
const canonicalIndex = buildCanonicalIndex(config.subBlocks)
|
||||
const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes)
|
||||
const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg]))
|
||||
// A field could hang off two anchors (or be reachable via two paths); emit it once.
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const anchor of PARENT_ANCHORS) {
|
||||
for (const anchorCfg of config.subBlocks) {
|
||||
if (anchorCfg.type !== anchor.subBlockType || !anchorCfg.id) continue
|
||||
// An anchor whose canonical pair is in ADVANCED (manual) mode is skipped entirely: the
|
||||
// active value is the user-owned manual member's, which is verbatim by policy - a sync
|
||||
// never remaps it, so its dependents are never cleared and there is nothing to re-pick.
|
||||
if (gates.isAdvancedActiveGroup(anchorCfg.id)) continue
|
||||
// Basic mode: the anchor selector's own value. Nested tools can store the pick under the
|
||||
// pair's `canonicalParamId` instead (the tool-input UI writes the canonical key), so fall
|
||||
// back to it - but only when that key is not itself a subblock id (when it is, the key
|
||||
// is the manual member's own field and reading it would leak a manual value).
|
||||
let rawValue = values[anchorCfg.id]
|
||||
if (
|
||||
!isNonEmptyValue(rawValue) &&
|
||||
anchorCfg.canonicalParamId &&
|
||||
!configById.has(anchorCfg.canonicalParamId)
|
||||
) {
|
||||
rawValue = values[anchorCfg.canonicalParamId]
|
||||
}
|
||||
const parentSourceId = typeof rawValue === 'string' ? rawValue : ''
|
||||
if (!parentSourceId) continue
|
||||
// Multi-value parents (comma-joined) can't match a single mapping entry; skip
|
||||
// (the field falls back to needs-config) rather than mis-bind to one of several.
|
||||
if (parentSourceId.includes(',')) continue
|
||||
|
||||
// Context the dependents need (spreadsheetId, ...) minus the parent key the modal supplies.
|
||||
const context: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(fullContext)) {
|
||||
if (key === anchor.parentContextKey) continue
|
||||
if (typeof value === 'string' && value) context[key] = value
|
||||
}
|
||||
|
||||
for (const clear of getWorkflowSearchDependentClears(config.subBlocks, anchorCfg.id)) {
|
||||
const dependent = configById.get(clear.subBlockId)
|
||||
if (!dependent?.id || !dependent.selectorKey) continue
|
||||
// Skip fields gated off by their `condition` - a selector under a now-inactive
|
||||
// operation (e.g. a move-only label while the block reads) isn't in play. We do
|
||||
// NOT require a source value: an active selector the source left empty is still
|
||||
// offered, so the user can set a label/sheet during the swap even when the source
|
||||
// (or a prior sync) cleared it - the whole point of the in-place re-pick.
|
||||
if (dependent.condition && !evaluateSubBlockCondition(dependent.condition, values)) continue
|
||||
// Skip a DORMANT canonical member: when the dependent's own pair is in advanced
|
||||
// (manual) mode, the selector is not the live field - the manual member is, and it
|
||||
// is verbatim by policy (never cleared by a remap), so there's nothing to re-pick.
|
||||
if (gates.isDormantMember(dependent.id)) continue
|
||||
// The SelectorContext key this field supplies to its own descendants, so the
|
||||
// modal can chain re-picks (re-picked spreadsheet feeds the sheet selector).
|
||||
const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[dependent.id] ?? dependent.id
|
||||
// Dedup by canonical key so a basic/advanced pair (or two paths to the same field)
|
||||
// is offered exactly once.
|
||||
if (seen.has(canonicalKey)) continue
|
||||
seen.add(canonicalKey)
|
||||
const providesContextKey =
|
||||
chaining && isSelectorContextKey(canonicalKey) ? canonicalKey : undefined
|
||||
// The SelectorContext keys this field needs from in-block siblings (e.g. a sheet
|
||||
// needs the spreadsheet), excluding the anchor key the modal already supplies, so
|
||||
// the modal can keep a child disabled until its re-picked parent is chosen.
|
||||
const consumesContextKeys = chaining
|
||||
? [
|
||||
...new Set(
|
||||
getDependsOnFields(dependent.dependsOn)
|
||||
.map((parent) => canonicalIndex.canonicalIdBySubBlockId[parent] ?? parent)
|
||||
.filter((key) => key !== anchor.parentContextKey && isSelectorContextKey(key))
|
||||
),
|
||||
]
|
||||
: []
|
||||
// Carry the selector's static `mimeType` filter (Drive/Sheets pickers) so the
|
||||
// modal selector loads the same filtered list the editor would, not all files.
|
||||
const dependentContext =
|
||||
typeof dependent.mimeType === 'string' && dependent.mimeType
|
||||
? { ...context, mimeType: dependent.mimeType }
|
||||
: context
|
||||
// Nested tools can store the pick under the pair's `canonicalParamId` (the tool-input
|
||||
// UI writes the canonical key); fall back to it when the key isn't a subblock's own id.
|
||||
const rawDependentValue =
|
||||
values[dependent.id] ??
|
||||
(dependent.canonicalParamId && !configById.has(dependent.canonicalParamId)
|
||||
? values[dependent.canonicalParamId]
|
||||
: undefined)
|
||||
const rawSourceValue = typeof rawDependentValue === 'string' ? rawDependentValue : ''
|
||||
out.push({
|
||||
parentKind: anchor.parentKind,
|
||||
parentSourceId,
|
||||
parentContextKey: anchor.parentContextKey,
|
||||
targetWorkflowId,
|
||||
targetBlockId: resolveTargetBlockId(),
|
||||
blockName,
|
||||
subBlockKey: makeSubBlockKey(dependent.id),
|
||||
selectorKey: dependent.selectorKey,
|
||||
title: makeTitle(dependent),
|
||||
...(toolName ? { toolName } : {}),
|
||||
// Source value, so the always-on listing pre-fills a stable parent's selector.
|
||||
// The diff route overlays the stored/target-draft value onto `currentValue`;
|
||||
// `sourceValue` stays the raw source reference (the copy-resolved parent's seed).
|
||||
currentValue: rawSourceValue,
|
||||
required: isSubBlockRequired(dependent.required, values),
|
||||
providesContextKey,
|
||||
consumesContextKeys,
|
||||
context: dependentContext,
|
||||
sourceValue: rawSourceValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the source's deployed workflows for configured selector fields that `dependsOn`
|
||||
* a remappable parent (a credential, knowledge base, or table) - the fields a sync clears
|
||||
* whenever that parent's target changes. Covers top-level block subblocks AND selectors
|
||||
* nested inside `tool-input` tools (Agent/tool blocks), so a Gmail tool's label inside an
|
||||
* Agent block is offered for re-pick too. Each entry carries the deterministic target
|
||||
* block id, the parent it hangs off (so the modal can bind it to the newly-chosen target),
|
||||
* and the source-derived selector context. Every selector active for the source's current
|
||||
* operation is emitted - including ones the source left empty - so the user can set a
|
||||
* value in place during the swap even when the source (or a prior sync) had none; only
|
||||
* selectors gated off by their `condition` (a different operation's variant) are skipped.
|
||||
* Scans one target `mode` per call: `replace` for targets that exist (re-pick against the
|
||||
* swapped parent), `create` for never-synced workflows (pre-configure what the first sync
|
||||
* writes - the diff route emits both).
|
||||
*
|
||||
* `resolveTargetBlockId` MUST be the same resolver `copyWorkflowStateIntoTarget` uses for
|
||||
* this promote (see {@link buildForkBlockIdResolver}); otherwise the modal would key a
|
||||
* re-pick by a derived id while the sync writes the block under its persisted counterpart,
|
||||
* and the override would silently miss.
|
||||
*/
|
||||
export function collectForkDependentReconfigs(
|
||||
items: ReconfigItem[],
|
||||
sourceStates: Map<string, WorkflowState>,
|
||||
resolveTargetBlockId: ForkBlockIdResolver,
|
||||
/**
|
||||
* Which target mode to scan. Defaults to `replace` (the reconfigure UI, where the user re-picks
|
||||
* a dependent against a swapped parent). The pre-sync cleared-ref list passes `create` to surface
|
||||
* dependents a new target inherits that a remapped parent will clear (it can't be re-picked yet).
|
||||
*/
|
||||
mode: 'create' | 'replace' = 'replace'
|
||||
): ForkDependentReconfig[] {
|
||||
const out: ForkDependentReconfig[] = []
|
||||
for (const item of items) {
|
||||
if (item.mode !== mode) continue
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
|
||||
const config = getBlock(block.type)
|
||||
if (!config) continue
|
||||
const subBlocks = (block.subBlocks ?? {}) as Record<string, { value?: unknown }>
|
||||
const sourceValues = buildSubBlockValues(subBlocks)
|
||||
let cachedTargetBlockId: string | null = null
|
||||
const resolveBlockId = () =>
|
||||
(cachedTargetBlockId ??= resolveTargetBlockId(item.targetWorkflowId, sourceBlockId))
|
||||
|
||||
// Top-level credential/KB/table-anchored selectors. Block-level canonicalModes pick the
|
||||
// active parent member; nested tools below pass their tool-scoped overrides (via
|
||||
// scopeCanonicalModesForTool), falling back to the value heuristic only when none is set.
|
||||
emitAnchoredDependents({
|
||||
config,
|
||||
values: sourceValues,
|
||||
contextBlockType: block.type,
|
||||
contextSubBlocks: subBlocks,
|
||||
blockName: block.name,
|
||||
targetWorkflowId: item.targetWorkflowId,
|
||||
canonicalModes: block.data?.canonicalModes,
|
||||
resolveTargetBlockId: resolveBlockId,
|
||||
makeSubBlockKey: (id) => id,
|
||||
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
|
||||
chaining: true,
|
||||
out,
|
||||
})
|
||||
|
||||
// Nested `tool-input` tools: each selected tool's own credential-anchored selectors,
|
||||
// keyed `toolInput[index].paramId` (matching the needs-config key). Field `title` stays
|
||||
// plain; `toolName` carries the tool so the UI can show block → tool → field tiers.
|
||||
for (const cfg of config.subBlocks) {
|
||||
if (cfg.type !== 'tool-input' || !cfg.id) continue
|
||||
const { array: tools } = coerceObjectArray(subBlocks[cfg.id]?.value)
|
||||
if (!tools) continue
|
||||
for (let index = 0; index < tools.length; index++) {
|
||||
const tool = tools[index]
|
||||
if (!isRecord(tool) || typeof tool.type !== 'string') continue
|
||||
const toolConfig = getBlock(tool.type)
|
||||
if (!toolConfig) continue
|
||||
const toolParams = isRecord(tool.params) ? tool.params : {}
|
||||
// A tool's `operation` is stored at the tool level, not in params, but subblock
|
||||
// conditions reference it (e.g. a Gmail label only under `read_gmail`). Merge it
|
||||
// in so condition-gating matches the editor's `{ operation, ...params }`.
|
||||
const toolValues =
|
||||
typeof tool.operation === 'string'
|
||||
? { operation: tool.operation, ...toolParams }
|
||||
: toolParams
|
||||
const toolContextSubBlocks: Record<string, { value?: unknown }> = {}
|
||||
for (const [key, value] of Object.entries(toolValues)) {
|
||||
toolContextSubBlocks[key] = { value }
|
||||
}
|
||||
const toolLabel =
|
||||
typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name
|
||||
const toolInputKey = cfg.id
|
||||
const toolIndex = index
|
||||
emitAnchoredDependents({
|
||||
config: toolConfig,
|
||||
values: toolValues,
|
||||
contextBlockType: tool.type,
|
||||
contextSubBlocks: toolContextSubBlocks,
|
||||
blockName: block.name,
|
||||
targetWorkflowId: item.targetWorkflowId,
|
||||
canonicalModes: scopeCanonicalModesForTool(
|
||||
block.data?.canonicalModes,
|
||||
toolIndex,
|
||||
tool.type
|
||||
),
|
||||
resolveTargetBlockId: resolveBlockId,
|
||||
makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`,
|
||||
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
|
||||
toolName: toolLabel,
|
||||
chaining: false,
|
||||
out,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface ResourceUsageItem {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
mode: 'create' | 'replace'
|
||||
/** Source workflow name, shown as the (renamed-aware) target name in the listing. */
|
||||
sourceMeta: { name: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every workflow each mapped resource (any kind) is used in - the spine of the always-on
|
||||
* reconfigure listing under a mapping entry. Scans each source workflow's references
|
||||
* (deduped per workflow, so a resource used by several blocks is one workflow usage) and
|
||||
* groups them by `(kind, sourceId)`. Unlike {@link collectForkDependentReconfigs} this is
|
||||
* NOT anchor-limited: it includes resources with no configurable dependent (env vars, files,
|
||||
* a Gmail block with no active label) so the modal can still list - greyed - the workflows
|
||||
* they appear in. Covers EVERY deployed source workflow - replace targets and creates
|
||||
* (never-synced workflows) alike - so the listing accounts for the full next sync.
|
||||
*/
|
||||
export function collectForkResourceUsages(
|
||||
items: ResourceUsageItem[],
|
||||
sourceStates: Map<string, WorkflowState>
|
||||
): ForkResourceUsage[] {
|
||||
const byResource = new Map<string, ForkResourceUsage>()
|
||||
for (const item of items) {
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
// scanWorkflowReferences already dedups by `${kind}:${sourceId}` across the workflow,
|
||||
// so each resource appears once per workflow here.
|
||||
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
|
||||
const key = `${reference.kind}\u0000${reference.sourceId}`
|
||||
let usage = byResource.get(key)
|
||||
if (!usage) {
|
||||
usage = { parentKind: reference.kind, parentSourceId: reference.sourceId, workflows: [] }
|
||||
byResource.set(key, usage)
|
||||
}
|
||||
usage.workflows.push({
|
||||
workflowId: item.targetWorkflowId,
|
||||
workflowName: item.sourceMeta.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(byResource.values())
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
type ForkDependentValue,
|
||||
forkDependentValueKey,
|
||||
loadForkDependentValues,
|
||||
reconcileForkDependentValues,
|
||||
translateForkDependentValues,
|
||||
} from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
|
||||
import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
describe('forkDependentValueKey', () => {
|
||||
it('builds a stable triple key', () => {
|
||||
expect(forkDependentValueKey('wf', 'blk', 'folder')).toBe('wf\u0000blk\u0000folder')
|
||||
})
|
||||
|
||||
it("doesn't collide when an id contains a printable separator", () => {
|
||||
// 'a:b' + 'c' must differ from 'a' + 'b:c' - the NUL separator guarantees it.
|
||||
expect(forkDependentValueKey('a:b', 'c', 'd')).not.toBe(forkDependentValueKey('a', 'b:c', 'd'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadForkDependentValues', () => {
|
||||
it('selects the edge rows', async () => {
|
||||
const where = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ targetWorkflowId: 'wf', targetBlockId: 'b', subBlockKey: 'folder', value: 'INBOX' },
|
||||
])
|
||||
const from = vi.fn(() => ({ where }))
|
||||
const executor = { select: vi.fn(() => ({ from })) }
|
||||
const rows = await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1')
|
||||
expect(executor.select).toHaveBeenCalledTimes(1)
|
||||
expect(rows).toEqual([
|
||||
{ targetWorkflowId: 'wf', targetBlockId: 'b', subBlockKey: 'folder', value: 'INBOX' },
|
||||
])
|
||||
})
|
||||
|
||||
it('scopes the read to the given target workflows', async () => {
|
||||
const where = vi.fn().mockResolvedValue([])
|
||||
const from = vi.fn(() => ({ where }))
|
||||
const executor = { select: vi.fn(() => ({ from })) }
|
||||
await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1', ['wf-1', 'wf-2'])
|
||||
expect(executor.select).toHaveBeenCalledTimes(1)
|
||||
expect(where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('short-circuits an empty target filter without querying', async () => {
|
||||
const executor = { select: vi.fn() }
|
||||
const rows = await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1', [])
|
||||
expect(executor.select).not.toHaveBeenCalled()
|
||||
expect(rows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('translateForkDependentValues', () => {
|
||||
const value = (overrides: Partial<ForkDependentValue> = {}): ForkDependentValue => ({
|
||||
targetWorkflowId: 'wf-1',
|
||||
targetBlockId: 'blk-1',
|
||||
subBlockKey: 'documentSelector',
|
||||
value: 'doc-src',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/** Resolver mapping only the copied/mapped source document ids, like promote's post-copy one. */
|
||||
const resolver: ForkReferenceResolver = (kind, sourceId) =>
|
||||
kind === 'knowledge-document' && sourceId === 'doc-src' ? 'doc-copy' : null
|
||||
|
||||
it('rewrites a SOURCE document id to its copied counterpart (the apply must never write a source id)', () => {
|
||||
expect(translateForkDependentValues([value()], resolver)).toEqual([
|
||||
value({ value: 'doc-copy' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps values the resolver does not know verbatim (target doc ids, labels, column ids)', () => {
|
||||
const targetDoc = value({ value: 'doc-tgt-existing' })
|
||||
const label = value({ subBlockKey: 'folder', value: 'INBOX' })
|
||||
expect(translateForkDependentValues([targetDoc, label], resolver)).toEqual([targetDoc, label])
|
||||
})
|
||||
|
||||
it('keeps empty (cleared) values untouched without consulting the resolver', () => {
|
||||
const resolve = vi.fn(() => 'never')
|
||||
const cleared = value({ value: '' })
|
||||
expect(translateForkDependentValues([cleared], resolve)).toEqual([cleared])
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consults only the knowledge-document kind (documents are the one copied dependent value)', () => {
|
||||
const resolve = vi.fn(() => null)
|
||||
translateForkDependentValues([value()], resolve)
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
expect(resolve).toHaveBeenCalledWith('knowledge-document', 'doc-src')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileForkDependentValues', () => {
|
||||
function fakeExecutor() {
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined)
|
||||
const insertValues = vi.fn().mockResolvedValue(undefined)
|
||||
const executor = {
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
}
|
||||
return { executor: executor as unknown as DbOrTx, deleteWhere, insertValues }
|
||||
}
|
||||
|
||||
it('deletes the given workflows then inserts only non-empty values', async () => {
|
||||
const { executor, deleteWhere, insertValues } = fakeExecutor()
|
||||
await reconcileForkDependentValues(
|
||||
executor,
|
||||
'ws-1',
|
||||
['wf-1'],
|
||||
[
|
||||
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'INBOX' },
|
||||
{ targetWorkflowId: 'wf-1', targetBlockId: 'b2', subBlockKey: 'folder', value: '' },
|
||||
]
|
||||
)
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1)
|
||||
expect(insertValues).toHaveBeenCalledTimes(1)
|
||||
const rows = insertValues.mock.calls[0][0] as Array<Record<string, unknown>>
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
childWorkspaceId: 'ws-1',
|
||||
targetWorkflowId: 'wf-1',
|
||||
targetBlockId: 'b1',
|
||||
subBlockKey: 'folder',
|
||||
value: 'INBOX',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips the delete when no workflows are given, and skips insert when all values are empty', async () => {
|
||||
const { executor, deleteWhere, insertValues } = fakeExecutor()
|
||||
await reconcileForkDependentValues(
|
||||
executor,
|
||||
'ws-1',
|
||||
[],
|
||||
[{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: '' }]
|
||||
)
|
||||
expect(deleteWhere).not.toHaveBeenCalled()
|
||||
expect(insertValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a workflow (delete, no insert) when its full set is now empty', async () => {
|
||||
const { executor, deleteWhere, insertValues } = fakeExecutor()
|
||||
await reconcileForkDependentValues(executor, 'ws-1', ['wf-1'], [])
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1)
|
||||
expect(insertValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dedupes duplicate field entries (last value wins) so a retried payload cannot trip the unique index', async () => {
|
||||
const { executor, insertValues } = fakeExecutor()
|
||||
await reconcileForkDependentValues(
|
||||
executor,
|
||||
'ws-1',
|
||||
['wf-1'],
|
||||
[
|
||||
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'INBOX' },
|
||||
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'SENT' },
|
||||
]
|
||||
)
|
||||
expect(insertValues).toHaveBeenCalledTimes(1)
|
||||
const rows = insertValues.mock.calls[0][0] as Array<Record<string, unknown>>
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
targetWorkflowId: 'wf-1',
|
||||
targetBlockId: 'b1',
|
||||
subBlockKey: 'folder',
|
||||
value: 'SENT',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { workspaceForkDependentValue } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
/** One stored dependent-field value for an edge. */
|
||||
export interface ForkDependentValue {
|
||||
targetWorkflowId: string
|
||||
targetBlockId: string
|
||||
subBlockKey: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Stable key for a stored value (target workflow + block + subblock). */
|
||||
export function forkDependentValueKey(
|
||||
targetWorkflowId: string,
|
||||
targetBlockId: string,
|
||||
subBlockKey: string
|
||||
): string {
|
||||
// NUL separators so ids/keys containing ':' can't be confused for a different triple.
|
||||
return `${targetWorkflowId}\u0000${targetBlockId}\u0000${subBlockKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an edge's stored dependent values - the single source of truth for what each dependent
|
||||
* selector (Gmail label, KB document, sheet tab) is set to. Consumed two ways: the diff
|
||||
* overlays them as the modal's pre-filled value, and a promote applies them verbatim. Pass
|
||||
* `targetWorkflowIds` to scope the read to a plan's replace targets (matching the
|
||||
* `(childWorkspaceId, targetWorkflowId)` index) instead of loading the whole edge; an empty
|
||||
* array short-circuits to no rows.
|
||||
*/
|
||||
export async function loadForkDependentValues(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
targetWorkflowIds?: string[]
|
||||
): Promise<ForkDependentValue[]> {
|
||||
if (targetWorkflowIds && targetWorkflowIds.length === 0) return []
|
||||
const where = targetWorkflowIds
|
||||
? and(
|
||||
eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId),
|
||||
inArray(workspaceForkDependentValue.targetWorkflowId, targetWorkflowIds)
|
||||
)
|
||||
: eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId)
|
||||
return executor
|
||||
.select({
|
||||
targetWorkflowId: workspaceForkDependentValue.targetWorkflowId,
|
||||
targetBlockId: workspaceForkDependentValue.targetBlockId,
|
||||
subBlockKey: workspaceForkDependentValue.subBlockKey,
|
||||
value: workspaceForkDependentValue.value,
|
||||
})
|
||||
.from(workspaceForkDependentValue)
|
||||
.where(where)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate dependent values through the promote resolver before they are applied to the
|
||||
* written state and persisted: a value that is a SOURCE knowledge-document id (a pick under a
|
||||
* copy-resolved KB) becomes its copied/mapped counterpart id, so the
|
||||
* dependent-value apply - which runs AFTER the reference remap and wins for its subblock -
|
||||
* never writes a source-workspace document id into the target, and the store stays coherent
|
||||
* for the next sync's (then-mapped) display. Only ids the resolver actually knows are
|
||||
* rewritten: a target document id, a Gmail label, a column id, or any other opaque value
|
||||
* misses the map and is kept verbatim. Documents are the one dependent-selector value that is
|
||||
* itself a copied resource id, so `knowledge-document` is the only kind consulted. Pure.
|
||||
*/
|
||||
export function translateForkDependentValues(
|
||||
values: ForkDependentValue[],
|
||||
resolve: ForkReferenceResolver
|
||||
): ForkDependentValue[] {
|
||||
return values.map((entry) => {
|
||||
if (entry.value === '') return entry
|
||||
const translated = resolve('knowledge-document', entry.value)
|
||||
return translated != null && translated !== entry.value
|
||||
? { ...entry, value: translated }
|
||||
: entry
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the stored dependent values for the given target workflows with `values` (the full
|
||||
* set the modal sent). Deletes those workflows' rows first, then inserts the non-empty values,
|
||||
* so the store always equals exactly what the user configured - cleared fields drop out, and
|
||||
* blocks/fields that no longer exist are pruned. Empty values aren't stored (an empty store
|
||||
* entry and a missing one mean the same thing: unset).
|
||||
*/
|
||||
export async function reconcileForkDependentValues(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
targetWorkflowIds: string[],
|
||||
values: ForkDependentValue[]
|
||||
): Promise<void> {
|
||||
if (targetWorkflowIds.length > 0) {
|
||||
await executor
|
||||
.delete(workspaceForkDependentValue)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId),
|
||||
inArray(workspaceForkDependentValue.targetWorkflowId, targetWorkflowIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
// Dedupe by the stored (workflow, block, subblock) triple (last value wins) before building
|
||||
// insert rows, so a duplicated/retried payload entry can't trip the `..._field_unique` index
|
||||
// and abort the whole sync transaction. Empty values aren't stored.
|
||||
const deduped = new Map<string, ForkDependentValue>()
|
||||
for (const entry of values) {
|
||||
if (entry.value === '') continue
|
||||
deduped.set(
|
||||
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
|
||||
entry
|
||||
)
|
||||
}
|
||||
const rows = Array.from(deduped.values()).map((entry) => ({
|
||||
id: generateId(),
|
||||
childWorkspaceId,
|
||||
targetWorkflowId: entry.targetWorkflowId,
|
||||
targetBlockId: entry.targetBlockId,
|
||||
subBlockKey: entry.subBlockKey,
|
||||
value: entry.value,
|
||||
}))
|
||||
if (rows.length === 0) return
|
||||
await executor.insert(workspaceForkDependentValue).values(rows)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
const { mockFilterExisting, mockGetCredentialProviders, mockGetEnvKeys } = vi.hoisted(() => ({
|
||||
mockFilterExisting: vi.fn(),
|
||||
mockGetCredentialProviders: vi.fn(),
|
||||
mockGetEnvKeys: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({
|
||||
listForkResourceCandidates: vi.fn(),
|
||||
classifyCredentialResourceType: vi.fn(),
|
||||
getWorkspaceEnvKeys: mockGetEnvKeys,
|
||||
filterExistingForkTargets: mockFilterExisting,
|
||||
getCredentialProvidersByIds: mockGetCredentialProviders,
|
||||
CANDIDATE_LIMIT: 1000,
|
||||
}))
|
||||
|
||||
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import {
|
||||
findDuplicateTargetEntry,
|
||||
suggestTarget,
|
||||
validateForkMappingTargets,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-service'
|
||||
import type { ForkResourceCandidate } from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
|
||||
type ExistingByKind = Partial<Record<ForkRemapKind, Set<string>>>
|
||||
|
||||
describe('validateForkMappingTargets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFilterExisting.mockResolvedValue({} as ExistingByKind)
|
||||
mockGetEnvKeys.mockResolvedValue(new Set<string>())
|
||||
mockGetCredentialProviders.mockResolvedValue(new Map<string, string | null>())
|
||||
})
|
||||
|
||||
it('rejects a workflow-type entry with a target (identity is system-managed)', async () => {
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'workflow', sourceId: 'wf-src', targetId: 'wf-tgt' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('short-circuits without querying when no entry has a target', async () => {
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: null },
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
expect(mockFilterExisting).not.toHaveBeenCalled()
|
||||
expect(mockGetEnvKeys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts an env-var whose target key exists in the target workspace', async () => {
|
||||
mockGetEnvKeys.mockResolvedValue(new Set(['API_KEY']))
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'API_KEY' },
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an env-var whose target key is not in the target workspace', async () => {
|
||||
mockGetEnvKeys.mockResolvedValue(new Set())
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'missing' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('accepts a target validated by exact id even when picker lists are capped', async () => {
|
||||
// filterExistingForkTargets checks by exact id (cap-free), so a target that would
|
||||
// sit past the candidate cap still validates.
|
||||
mockFilterExisting.mockResolvedValue({ table: new Set(['table-1001']) })
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'table', sourceId: 'table-src', targetId: 'table-1001' },
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a target that does not exist in the target workspace', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ table: new Set<string>() })
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'table', sourceId: 'table-src', targetId: 'table-gone' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('accepts a file target whose storage key exists in the target workspace', async () => {
|
||||
// Files are mappable like any other content kind; the target is the storage key, and
|
||||
// filterExistingForkTargets resolves file existence by key in the target workspace.
|
||||
mockFilterExisting.mockResolvedValue({ file: new Set(['workspace/DST/report.pdf']) })
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{
|
||||
resourceType: 'file',
|
||||
sourceId: 'workspace/SRC/report.pdf',
|
||||
targetId: 'workspace/DST/report.pdf',
|
||||
},
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a file target whose storage key is missing in the target workspace', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ file: new Set<string>() })
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{
|
||||
resourceType: 'file',
|
||||
sourceId: 'workspace/SRC/report.pdf',
|
||||
targetId: 'workspace/DST/gone.pdf',
|
||||
},
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('rejects a credential whose target provider differs from the source provider', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
|
||||
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
|
||||
workspaceId === 'ws-source'
|
||||
? new Map([['cred-src', 'google-email']])
|
||||
: new Map([['cred-tgt', 'google-calendar']])
|
||||
)
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'oauth_credential', sourceId: 'cred-src', targetId: 'cred-tgt' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('accepts a credential whose target provider matches the source provider', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
|
||||
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
|
||||
workspaceId === 'ws-source'
|
||||
? new Map([['cred-src', 'google-email']])
|
||||
: new Map([['cred-tgt', 'google-email']])
|
||||
)
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'oauth_credential', sourceId: 'cred-src', targetId: 'cred-tgt' },
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a credential whose source is not a credential in the source workspace', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
|
||||
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
|
||||
workspaceId === 'ws-source'
|
||||
? new Map<string, string | null>() // cred-foreign is not in the source
|
||||
: new Map([['cred-tgt', 'google-email']])
|
||||
)
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'oauth_credential', sourceId: 'cred-foreign', targetId: 'cred-tgt' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDuplicateTargetEntry', () => {
|
||||
it('returns null when every target is used by at most one source', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 't1' },
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 't2' },
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('flags two distinct sources mapped to the same target', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'shared' },
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 'shared' },
|
||||
])
|
||||
).toEqual({ resourceType: 'oauth_credential', targetId: 'shared' })
|
||||
})
|
||||
|
||||
it('ignores cleared (null target) entries', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: null },
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: null },
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not flag the same source+target repeated', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
|
||||
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not conflate the same target id across resource types', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'same' },
|
||||
{ resourceType: 'table', sourceId: 'c2', targetId: 'same' },
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestTarget', () => {
|
||||
const cand = (id: string, label: string, providerId?: string): ForkResourceCandidate => ({
|
||||
id,
|
||||
label,
|
||||
providerId,
|
||||
})
|
||||
|
||||
it('disambiguates same-name credentials by matching the source provider', () => {
|
||||
const target = suggestTarget('credential', 'Work', 'google-email', [
|
||||
cand('c1', 'Work', 'google-calendar'),
|
||||
cand('c2', 'Work', 'google-email'),
|
||||
])
|
||||
expect(target).toBe('c2')
|
||||
})
|
||||
|
||||
it('suggests a unique name match for a non-credential kind', () => {
|
||||
expect(
|
||||
suggestTarget('table', 'Orders', undefined, [cand('t1', 'Orders'), cand('t2', 'Other')])
|
||||
).toBe('t1')
|
||||
})
|
||||
|
||||
it('returns null when the name is ambiguous (two same-name candidates)', () => {
|
||||
expect(
|
||||
suggestTarget('table', 'Dup', undefined, [cand('t1', 'Dup'), cand('t2', 'Dup')])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no candidate name matches', () => {
|
||||
expect(suggestTarget('table', 'Orders', undefined, [cand('t1', 'Other')])).toBeNull()
|
||||
})
|
||||
|
||||
it('matches the name case- and whitespace-insensitively', () => {
|
||||
expect(suggestTarget('table', ' Orders ', undefined, [cand('t1', 'orders')])).toBe('t1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,436 @@
|
||||
import { db } from '@sim/db'
|
||||
import type { ForkMappableResourceType, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
listDeployedWorkflows,
|
||||
readDeployedState,
|
||||
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
|
||||
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade'
|
||||
import {
|
||||
buildForkResolver,
|
||||
deleteEdgeMappingsByChildResources,
|
||||
type ForkResourceType,
|
||||
getEdgeMappingRows,
|
||||
nonCredentialForkKindToResourceType,
|
||||
resourceTypeToForkKind,
|
||||
upsertEdgeMappings,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import {
|
||||
CANDIDATE_LIMIT,
|
||||
classifyCredentialResourceType,
|
||||
type ForkResourceCandidate,
|
||||
filterExistingForkTargets,
|
||||
getCredentialProvidersByIds,
|
||||
getWorkspaceEnvKeys,
|
||||
listForkResourceCandidates,
|
||||
} from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
|
||||
import {
|
||||
type ForkReference,
|
||||
type ForkRemapKind,
|
||||
scanWorkflowReferences,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
interface ForkMappingViewParams {
|
||||
edge: ForkEdge
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
}
|
||||
|
||||
export function suggestTarget(
|
||||
kind: ForkRemapKind,
|
||||
sourceLabel: string,
|
||||
sourceProviderId: string | undefined,
|
||||
candidates: ForkResourceCandidate[]
|
||||
): string | null {
|
||||
const normalized = sourceLabel.trim().toLowerCase()
|
||||
const byLabel = candidates.filter((c) => c.label.trim().toLowerCase() === normalized)
|
||||
if (kind === 'credential' && sourceProviderId) {
|
||||
const match = byLabel.find((c) => c.providerId === sourceProviderId)
|
||||
if (match) return match.id
|
||||
}
|
||||
if (byLabel.length === 1) return byLabel[0].id
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the direction-oriented mapping view: every detected source reference with
|
||||
* its current target (persisted or env identity), an auto-suggested target by
|
||||
* name/provider, and the list of target candidates the UI can choose from.
|
||||
*/
|
||||
export async function getForkMappingView(
|
||||
params: ForkMappingViewParams
|
||||
): Promise<{ entries: ForkMappingEntry[] }> {
|
||||
const { edge, sourceWorkspaceId, targetWorkspaceId } = params
|
||||
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
|
||||
|
||||
const [mappingRows, targetEnvKeys, sourceEnvKeys, sourceCandidates, targetCandidates] =
|
||||
await Promise.all([
|
||||
getEdgeMappingRows(db, edge.childWorkspaceId),
|
||||
getWorkspaceEnvKeys(db, targetWorkspaceId),
|
||||
getWorkspaceEnvKeys(db, sourceWorkspaceId),
|
||||
listForkResourceCandidates(db, sourceWorkspaceId),
|
||||
listForkResourceCandidates(db, targetWorkspaceId),
|
||||
])
|
||||
|
||||
const resolver = buildForkResolver(mappingRows, { sourceIsParent, targetEnvKeys, sourceEnvKeys })
|
||||
|
||||
const resourceTypeBySourceId = new Map<string, ForkMappableResourceType>()
|
||||
for (const row of mappingRows) {
|
||||
// Workflow + workflow-publishing-server identity rows are system-managed and document rows
|
||||
// ride their parent KB - none is user-mappable. Skip them so a scanned reference can never
|
||||
// be labeled with a non-mappable type and the view stays within the mappable-type contract.
|
||||
if (
|
||||
row.resourceType === 'workflow' ||
|
||||
row.resourceType === 'workflow_mcp_server' ||
|
||||
row.resourceType === 'knowledge_document'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const key = sourceIsParent ? row.parentResourceId : row.childResourceId
|
||||
if (key) resourceTypeBySourceId.set(key, row.resourceType)
|
||||
}
|
||||
|
||||
// Scan one deployed workflow state at a time and merge deduped references, so
|
||||
// peak memory stays at a single workflow state rather than all of them at once.
|
||||
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
|
||||
const referenceByKey = new Map<string, ForkReference>()
|
||||
for (const wf of deployedWorkflows) {
|
||||
const state = await readDeployedState(wf.id, sourceWorkspaceId)
|
||||
if (!state) continue
|
||||
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
|
||||
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
|
||||
}
|
||||
}
|
||||
|
||||
const cascade = await detectForkCascadeReferences({
|
||||
executor: db,
|
||||
sourceWorkspaceId,
|
||||
references: Array.from(referenceByKey.values()),
|
||||
resolve: () => null,
|
||||
})
|
||||
for (const reference of cascade.references) {
|
||||
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
|
||||
}
|
||||
const references: ForkReference[] = Array.from(referenceByKey.values())
|
||||
|
||||
// First pass: resolve each reference's stored target + the data to build its entry,
|
||||
// collecting stored target ids so existence is checked by exact id (cap-free) - a
|
||||
// valid mapping to a target past the display cap must be RETAINED, not shown unmapped.
|
||||
interface PendingEntry {
|
||||
reference: ForkReference
|
||||
resourceType: ForkMappableResourceType
|
||||
sourceLabel: string
|
||||
sourceProviderId: string | undefined
|
||||
candidates: ForkResourceCandidate[]
|
||||
storedTargetId: string | null
|
||||
}
|
||||
const pending: PendingEntry[] = []
|
||||
const storedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
|
||||
for (const reference of references) {
|
||||
// Only SOURCE workspace secrets are mappable; a `{{KEY}}` that isn't a source
|
||||
// workspace env var is a personal (user-scoped) secret - leave it as-is.
|
||||
if (reference.kind === 'env-var' && !sourceEnvKeys.has(reference.sourceId)) continue
|
||||
// Knowledge documents are not a standalone mappable kind: a document is a dependent field
|
||||
// of its knowledge base (the `document-selector` dependsOn the KB selector), re-picked in
|
||||
// that KB's reconfigure flow and auto-remapped when the KB is copied. So a document never
|
||||
// gets its own mapping entry - it follows its parent KB's target.
|
||||
if (reference.kind === 'knowledge-document') continue
|
||||
let resourceType = resourceTypeBySourceId.get(reference.sourceId)
|
||||
if (!resourceType) {
|
||||
resourceType =
|
||||
reference.kind === 'credential'
|
||||
? await classifyCredentialResourceType(db, reference.sourceId, sourceWorkspaceId)
|
||||
: nonCredentialForkKindToResourceType(reference.kind)
|
||||
}
|
||||
|
||||
const sourceCandidate = sourceCandidates[reference.kind].find(
|
||||
(c) => c.id === reference.sourceId
|
||||
)
|
||||
const sourceLabel = sourceCandidate?.label ?? reference.sourceId
|
||||
const sourceProviderId = sourceCandidate?.providerId
|
||||
// A credential reference only maps to a target credential of the SAME OAuth
|
||||
// provider - a Gmail (google-email) reference must never offer a Google Calendar
|
||||
// credential. Non-credential kinds carry no provider, so their full list stands.
|
||||
const candidates =
|
||||
reference.kind === 'credential' && sourceProviderId
|
||||
? targetCandidates[reference.kind].filter(
|
||||
(candidate) => candidate.providerId === sourceProviderId
|
||||
)
|
||||
: targetCandidates[reference.kind]
|
||||
const storedTargetId = resolver(reference.kind, reference.sourceId) ?? null
|
||||
if (storedTargetId && reference.kind !== 'env-var') {
|
||||
;(storedTargetIdsByKind[reference.kind] ??= new Set()).add(storedTargetId)
|
||||
}
|
||||
pending.push({
|
||||
reference,
|
||||
resourceType,
|
||||
sourceLabel,
|
||||
sourceProviderId,
|
||||
candidates,
|
||||
storedTargetId,
|
||||
})
|
||||
}
|
||||
|
||||
// Cap-free existence of every stored target (env vars validated against env keys).
|
||||
const existingStoredTargets = await filterExistingForkTargets(
|
||||
db,
|
||||
targetWorkspaceId,
|
||||
storedTargetIdsByKind
|
||||
)
|
||||
|
||||
const entries: ForkMappingEntry[] = []
|
||||
for (const p of pending) {
|
||||
const targetExists =
|
||||
p.storedTargetId != null &&
|
||||
(p.reference.kind === 'env-var'
|
||||
? targetEnvKeys.has(p.storedTargetId)
|
||||
: (existingStoredTargets[p.reference.kind]?.has(p.storedTargetId) ?? false))
|
||||
const currentTargetId = targetExists ? p.storedTargetId : null
|
||||
|
||||
// If the retained current target isn't in the (capped) candidate list, append it
|
||||
// so the picker can still display the current selection.
|
||||
let candidates = p.candidates
|
||||
if (currentTargetId && !candidates.some((candidate) => candidate.id === currentTargetId)) {
|
||||
candidates = [...candidates, { id: currentTargetId, label: currentTargetId }]
|
||||
}
|
||||
|
||||
const targetId =
|
||||
currentTargetId ??
|
||||
suggestTarget(p.reference.kind, p.sourceLabel, p.sourceProviderId, candidates)
|
||||
// True when `targetId` is an unconfirmed name/provider suggestion (no persisted
|
||||
// mapping). The modal treats a suggestion as a pending change so it shows the
|
||||
// pre-sync reconfigure rather than letting an accepted suggestion silently clear
|
||||
// dependents and surface them only after the sync.
|
||||
const suggested = currentTargetId == null && targetId != null
|
||||
|
||||
entries.push({
|
||||
kind: p.reference.kind,
|
||||
resourceType: p.resourceType,
|
||||
sourceId: p.reference.sourceId,
|
||||
sourceLabel: p.sourceLabel,
|
||||
targetId,
|
||||
suggested,
|
||||
// Every entry here is a reference a synced workflow actually carries, and a sync is
|
||||
// blocked while ANY reference would clear - so every entry is required. Copyable kinds
|
||||
// (table / KB / file / custom tool / skill) also satisfy the gate by being selected for
|
||||
// copy; map-only kinds (credential / env-var / MCP server) and source-deleted resources
|
||||
// (no copy candidate) must be mapped.
|
||||
required: true,
|
||||
candidates,
|
||||
// The full (unfiltered) target list for this kind hit the cap, so the picker is
|
||||
// showing a partial list - the UI tells the user to refine.
|
||||
candidatesTruncated: targetCandidates[p.reference.kind].length >= CANDIDATE_LIMIT,
|
||||
})
|
||||
}
|
||||
|
||||
return { entries }
|
||||
}
|
||||
|
||||
export interface ApplyForkMappingEntry {
|
||||
resourceType: ForkResourceType
|
||||
sourceId: string
|
||||
targetId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The first target two distinct sources are mapped to (same resourceType + targetId,
|
||||
* different sourceId), or null when every target is used by at most one source. Cleared
|
||||
* entries (null target) are ignored. Used by the PUSH path only: a push row is unique on
|
||||
* the parent (target) side, so such a pair collides on that unique index and one mapping
|
||||
* would be silently dropped - the caller rejects it instead. Pull is the inverse (many
|
||||
* parent sources may share one child target, which the resolver handles), so pull does not
|
||||
* use this guard.
|
||||
*/
|
||||
export function findDuplicateTargetEntry(
|
||||
entries: ApplyForkMappingEntry[]
|
||||
): { resourceType: ForkResourceType; targetId: string } | null {
|
||||
const sourcesByTarget = new Map<string, Set<string>>()
|
||||
for (const entry of entries) {
|
||||
if (entry.targetId == null) continue
|
||||
// Null-byte separator so a targetId containing ':' can't be confused with a
|
||||
// different (resourceType, targetId) pair.
|
||||
const key = `${entry.resourceType}\u0000${entry.targetId}`
|
||||
const sources = sourcesByTarget.get(key)
|
||||
if (!sources) {
|
||||
sourcesByTarget.set(key, new Set([entry.sourceId]))
|
||||
continue
|
||||
}
|
||||
sources.add(entry.sourceId)
|
||||
if (sources.size > 1) return { resourceType: entry.resourceType, targetId: entry.targetId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist mapping edits for a direction. Pull maps a parent source to a child
|
||||
* target; push maps a child source to a parent target (clearing a push mapping
|
||||
* deletes the row).
|
||||
*/
|
||||
export async function applyForkMappingEntries(
|
||||
tx: DbOrTx,
|
||||
edge: ForkEdge,
|
||||
userId: string,
|
||||
direction: 'push' | 'pull',
|
||||
entries: ApplyForkMappingEntry[]
|
||||
): Promise<number> {
|
||||
if (entries.length === 0) return 0
|
||||
if (direction === 'pull') {
|
||||
// Pull maps a parent source to a child target - one batched upsert.
|
||||
await upsertEdgeMappings(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
userId,
|
||||
entries.map((entry) => ({
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.sourceId,
|
||||
childResourceId: entry.targetId,
|
||||
}))
|
||||
)
|
||||
return entries.length
|
||||
}
|
||||
// Push rows are unique on the parent (target) side, so two distinct sources mapped to
|
||||
// the same target would collide on that index and one would be silently dropped (its
|
||||
// reference then resolves unmapped). Reject loudly - on push each parent target can back
|
||||
// only one source. (Pull is the inverse: many parent sources may share one child target,
|
||||
// which the resolver handles, so pull skips this guard. The modal also disables an
|
||||
// already-taken target on push so users never reach this error normally.)
|
||||
const collision = findDuplicateTargetEntry(entries)
|
||||
if (collision) {
|
||||
const kind = resourceTypeToForkKind(collision.resourceType) ?? collision.resourceType
|
||||
throw new ForkError(
|
||||
`Two sources are mapped to the same ${kind} target. Each target can be mapped from only one source.`,
|
||||
400
|
||||
)
|
||||
}
|
||||
// Push rows are keyed by the child (source) side, but the table's unique key is on
|
||||
// the parent side - so clear any existing row for each source first (one grouped
|
||||
// delete), otherwise changing a push target leaves the old (parent, source) row
|
||||
// behind and resolution becomes ambiguous. Then upsert the new (target, source)
|
||||
// rows in one batch; a null target is a cleared mapping (delete only, no reinsert).
|
||||
await deleteEdgeMappingsByChildResources(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
entries.map((entry) => ({ resourceType: entry.resourceType, childResourceId: entry.sourceId }))
|
||||
)
|
||||
await upsertEdgeMappings(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
userId,
|
||||
entries
|
||||
.filter((entry) => entry.targetId != null)
|
||||
.map((entry) => ({
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.targetId as string,
|
||||
childResourceId: entry.sourceId,
|
||||
}))
|
||||
)
|
||||
return entries.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject mapping entries whose chosen target does not belong to the target
|
||||
* workspace, so a caller cannot point a remapped reference (or credential-access
|
||||
* propagation) at a resource in a workspace they do not administer. Entries whose
|
||||
* resource type is not user-mappable (only `workflow`, whose identity is
|
||||
* system-managed) are rejected outright. Credential targets must additionally share
|
||||
* the source credential's OAuth provider, so a Gmail reference can never be pointed
|
||||
* at a Google Calendar credential (the UI enforces this; this is the write-side
|
||||
* boundary that catches direct API calls and stale rows).
|
||||
*/
|
||||
export async function validateForkMappingTargets(
|
||||
sourceWorkspaceId: string,
|
||||
targetWorkspaceId: string,
|
||||
entries: ApplyForkMappingEntry[]
|
||||
): Promise<void> {
|
||||
const withTarget = entries.filter((entry) => entry.targetId != null)
|
||||
if (withTarget.length === 0) return
|
||||
|
||||
// Collect the exact target ids per kind so existence is checked by id, NOT against
|
||||
// the display-capped candidate list - a valid target that simply sits past the cap
|
||||
// must never be rejected on save.
|
||||
const targetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
let hasEnvVar = false
|
||||
for (const entry of withTarget) {
|
||||
const kind = resourceTypeToForkKind(entry.resourceType)
|
||||
if (!kind) {
|
||||
// `workflow` is the only null-kind type, and its identity is system-managed by
|
||||
// fork/promote/rollback. A non-null target for it here is an invalid (or
|
||||
// crafted) entry the editor must never persist.
|
||||
throw new ForkError(
|
||||
`Resource type "${entry.resourceType}" cannot be mapped via the mapping editor`,
|
||||
400
|
||||
)
|
||||
}
|
||||
if (kind === 'env-var') {
|
||||
hasEnvVar = true
|
||||
continue
|
||||
}
|
||||
;(targetIdsByKind[kind] ??= new Set()).add(entry.targetId as string)
|
||||
}
|
||||
|
||||
const credentialEntries = withTarget.filter(
|
||||
(entry) => resourceTypeToForkKind(entry.resourceType) === 'credential'
|
||||
)
|
||||
|
||||
const [existingTargets, targetEnvKeys, sourceProviders, targetProviders] = await Promise.all([
|
||||
filterExistingForkTargets(db, targetWorkspaceId, targetIdsByKind),
|
||||
hasEnvVar ? getWorkspaceEnvKeys(db, targetWorkspaceId) : Promise.resolve(new Set<string>()),
|
||||
getCredentialProvidersByIds(
|
||||
db,
|
||||
sourceWorkspaceId,
|
||||
credentialEntries.map((entry) => entry.sourceId)
|
||||
),
|
||||
getCredentialProvidersByIds(
|
||||
db,
|
||||
targetWorkspaceId,
|
||||
credentialEntries.map((entry) => entry.targetId as string)
|
||||
),
|
||||
])
|
||||
|
||||
for (const entry of withTarget) {
|
||||
const kind = resourceTypeToForkKind(entry.resourceType)
|
||||
if (!kind) continue
|
||||
const targetId = entry.targetId as string
|
||||
|
||||
if (kind === 'env-var') {
|
||||
if (!targetEnvKeys.has(targetId)) {
|
||||
throw new ForkError(
|
||||
`Mapping target "${targetId}" is not an environment variable in the target workspace`,
|
||||
400
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existingTargets[kind]?.has(targetId)) {
|
||||
throw new ForkError(
|
||||
`Mapping target "${targetId}" is not a valid ${kind} in the target workspace`,
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'credential') {
|
||||
// The source must be a real credential in the source workspace. A foreign id
|
||||
// (not present) would skip the provider check and let a crafted mapping drive
|
||||
// cross-workspace credential-access propagation on promote.
|
||||
if (!sourceProviders.has(entry.sourceId)) {
|
||||
throw new ForkError(
|
||||
`Source credential "${entry.sourceId}" is not a credential in the source workspace`,
|
||||
400
|
||||
)
|
||||
}
|
||||
const sourceProviderId = sourceProviders.get(entry.sourceId)
|
||||
const targetProviderId = targetProviders.get(targetId) ?? null
|
||||
if (sourceProviderId && targetProviderId !== sourceProviderId) {
|
||||
throw new ForkError(
|
||||
`Mapping target "${targetId}" must use the same provider as the source credential`,
|
||||
400
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildForkResolver,
|
||||
type ForkMappingRow,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
|
||||
const credentialRow: ForkMappingRow = {
|
||||
id: 'm1',
|
||||
childWorkspaceId: 'ws-child',
|
||||
resourceType: 'oauth_credential',
|
||||
parentResourceId: 'cred-parent',
|
||||
childResourceId: 'cred-child',
|
||||
}
|
||||
|
||||
describe('buildForkResolver', () => {
|
||||
it('resolves source->target for a pull (source is parent)', () => {
|
||||
const resolve = buildForkResolver([credentialRow], { sourceIsParent: true })
|
||||
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
|
||||
})
|
||||
|
||||
it('resolves source->target for a push (source is child)', () => {
|
||||
const resolve = buildForkResolver([credentialRow], { sourceIsParent: false })
|
||||
expect(resolve('credential', 'cred-child')).toBe('cred-parent')
|
||||
})
|
||||
|
||||
it('skips unmapped rows (null childResourceId)', () => {
|
||||
const resolve = buildForkResolver([{ ...credentialRow, childResourceId: null }], {
|
||||
sourceIsParent: true,
|
||||
})
|
||||
expect(resolve('credential', 'cred-parent')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a mapped target that no longer exists in the target workspace', () => {
|
||||
const resolve = buildForkResolver([credentialRow], {
|
||||
sourceIsParent: true,
|
||||
// target cred-child was deleted after the mapping was saved
|
||||
validTargetIdsByKind: { credential: new Set<string>() },
|
||||
})
|
||||
expect(resolve('credential', 'cred-parent')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a mapped target that still exists in the target workspace', () => {
|
||||
const resolve = buildForkResolver([credentialRow], {
|
||||
sourceIsParent: true,
|
||||
validTargetIdsByKind: { credential: new Set(['cred-child']) },
|
||||
})
|
||||
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
|
||||
})
|
||||
|
||||
it('does not existence-check kinds absent from validTargetIdsByKind', () => {
|
||||
const resolve = buildForkResolver([credentialRow], {
|
||||
sourceIsParent: true,
|
||||
validTargetIdsByKind: { table: new Set<string>() },
|
||||
})
|
||||
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
|
||||
})
|
||||
|
||||
it('falls back to identity for a workspace env key present in the target', () => {
|
||||
const resolve = buildForkResolver([], {
|
||||
sourceIsParent: true,
|
||||
sourceEnvKeys: new Set(['API_KEY']),
|
||||
targetEnvKeys: new Set(['API_KEY']),
|
||||
})
|
||||
expect(resolve('env-var', 'API_KEY')).toBe('API_KEY')
|
||||
})
|
||||
|
||||
it('leaves a personal (non-source-workspace) env key as-is', () => {
|
||||
const resolve = buildForkResolver([], {
|
||||
sourceIsParent: true,
|
||||
sourceEnvKeys: new Set(['WORKSPACE_KEY']),
|
||||
targetEnvKeys: new Set(),
|
||||
})
|
||||
expect(resolve('env-var', 'PERSONAL_KEY')).toBe('PERSONAL_KEY')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import { workspaceForkResourceMap } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, asc, eq, inArray, or, sql } from 'drizzle-orm'
|
||||
import type { z } from 'zod'
|
||||
import type { forkResourceTypeSchema } from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type {
|
||||
ForkReferenceResolver,
|
||||
ForkRemapKind,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
/** Mapping rows per insert; each row binds ~8 params, keeping well under PG's limit. */
|
||||
const MAPPING_INSERT_CHUNK = 1000
|
||||
|
||||
/** Derived from the wire contract so the DB enum, Zod schema, and TS type stay in lockstep. */
|
||||
export type ForkResourceType = z.infer<typeof forkResourceTypeSchema>
|
||||
|
||||
export interface ForkMappingRow {
|
||||
id: string
|
||||
childWorkspaceId: string
|
||||
resourceType: ForkResourceType
|
||||
parentResourceId: string
|
||||
childResourceId: string | null
|
||||
}
|
||||
|
||||
export interface ForkMappingUpsert {
|
||||
resourceType: ForkResourceType
|
||||
parentResourceId: string
|
||||
childResourceId: string | null
|
||||
}
|
||||
|
||||
const RESOURCE_TYPE_TO_FORK_KIND: Record<ForkResourceType, ForkRemapKind | null> = {
|
||||
workflow: null,
|
||||
oauth_credential: 'credential',
|
||||
service_account_credential: 'credential',
|
||||
env_var: 'env-var',
|
||||
table: 'table',
|
||||
knowledge_base: 'knowledge-base',
|
||||
knowledge_document: 'knowledge-document',
|
||||
file: 'file',
|
||||
mcp_server: 'mcp-server',
|
||||
// Identity-only, like `workflow`: nothing in a subblock references a workflow-publishing
|
||||
// server, so these rows never participate in reference remapping.
|
||||
workflow_mcp_server: null,
|
||||
custom_tool: 'custom-tool',
|
||||
skill: 'skill',
|
||||
}
|
||||
|
||||
/** The remapper kind a stored resource type participates in, or null when it does not remap. */
|
||||
export function resourceTypeToForkKind(resourceType: ForkResourceType): ForkRemapKind | null {
|
||||
return RESOURCE_TYPE_TO_FORK_KIND[resourceType]
|
||||
}
|
||||
|
||||
// `as const satisfies` (not a `Record<K, V>` annotation) so each key keeps its precise literal
|
||||
// value type - the generic accessor below then narrows its return per input kind (a uniform
|
||||
// Record value type would collapse every key to the full value union).
|
||||
const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE = {
|
||||
'env-var': 'env_var',
|
||||
table: 'table',
|
||||
'knowledge-base': 'knowledge_base',
|
||||
'knowledge-document': 'knowledge_document',
|
||||
file: 'file',
|
||||
'mcp-server': 'mcp_server',
|
||||
'custom-tool': 'custom_tool',
|
||||
skill: 'skill',
|
||||
} as const satisfies Record<
|
||||
Exclude<ForkRemapKind, 'credential'>,
|
||||
Exclude<ForkResourceType, 'workflow'>
|
||||
>
|
||||
|
||||
/**
|
||||
* Stored resource type for a non-credential remap kind. Credentials are resolved
|
||||
* separately via `classifyCredentialResourceType` since the type (oauth vs
|
||||
* service account) depends on the credential row.
|
||||
*/
|
||||
export function nonCredentialForkKindToResourceType<K extends Exclude<ForkRemapKind, 'credential'>>(
|
||||
kind: K
|
||||
): (typeof NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE)[K] {
|
||||
return NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE[kind]
|
||||
}
|
||||
|
||||
export async function getEdgeMappingRows(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string
|
||||
): Promise<ForkMappingRow[]> {
|
||||
const rows = await executor
|
||||
.select({
|
||||
id: workspaceForkResourceMap.id,
|
||||
childWorkspaceId: workspaceForkResourceMap.childWorkspaceId,
|
||||
resourceType: workspaceForkResourceMap.resourceType,
|
||||
parentResourceId: workspaceForkResourceMap.parentResourceId,
|
||||
childResourceId: workspaceForkResourceMap.childResourceId,
|
||||
})
|
||||
.from(workspaceForkResourceMap)
|
||||
.where(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId))
|
||||
// Deterministic order so resolver/identity construction is stable if duplicates
|
||||
// ever exist (the push edit + rollback cleanup prevent them, this is defense).
|
||||
.orderBy(asc(workspaceForkResourceMap.createdAt), asc(workspaceForkResourceMap.id))
|
||||
return rows as ForkMappingRow[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workflow-identity mapping rows by the ids on one side (parent or child).
|
||||
* Used by rollback to dissolve the identity rows a promote created, so a later
|
||||
* re-promote of the same source converges instead of leaking a second row.
|
||||
*/
|
||||
export async function deleteWorkflowIdentityByIds(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
side: 'parent' | 'child',
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return
|
||||
const sideColumn =
|
||||
side === 'parent'
|
||||
? workspaceForkResourceMap.parentResourceId
|
||||
: workspaceForkResourceMap.childResourceId
|
||||
await tx
|
||||
.delete(workspaceForkResourceMap)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId),
|
||||
eq(workspaceForkResourceMap.resourceType, 'workflow'),
|
||||
inArray(sideColumn, ids)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert mapping rows that don't already exist (used at fork time to seed every
|
||||
* detected reference as unmapped). Existing rows are left untouched.
|
||||
*/
|
||||
export async function seedEdgeMappings(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
userId: string,
|
||||
entries: ForkMappingUpsert[]
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return
|
||||
const now = new Date()
|
||||
// Chunked so a fork copying many resources stays well under the Postgres bind
|
||||
// parameter limit (each row binds ~8 params).
|
||||
for (let i = 0; i < entries.length; i += MAPPING_INSERT_CHUNK) {
|
||||
const batch = entries.slice(i, i + MAPPING_INSERT_CHUNK)
|
||||
await tx
|
||||
.insert(workspaceForkResourceMap)
|
||||
.values(
|
||||
batch.map((entry) => ({
|
||||
id: generateId(),
|
||||
childWorkspaceId,
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.parentResourceId,
|
||||
childResourceId: entry.childResourceId,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
workspaceForkResourceMap.childWorkspaceId,
|
||||
workspaceForkResourceMap.resourceType,
|
||||
workspaceForkResourceMap.parentResourceId,
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update mapping rows in batched, chunked multi-row upserts, setting
|
||||
* `childResourceId` (the chosen target) from the incoming row. Used when a user
|
||||
* saves a mapping and to persist promote identity rows - one query per chunk
|
||||
* instead of one per row, so a large save stays a short transaction.
|
||||
*
|
||||
* Entries are deduped by the conflict key (resourceType, parentResourceId), keeping
|
||||
* the last (matching the prior per-row last-write-wins) so a batch can never trip
|
||||
* Postgres's "ON CONFLICT DO UPDATE cannot affect row a second time".
|
||||
*/
|
||||
export async function upsertEdgeMappings(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
userId: string,
|
||||
entries: ForkMappingUpsert[]
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return
|
||||
const now = new Date()
|
||||
const byConflictKey = new Map<string, ForkMappingUpsert>()
|
||||
for (const entry of entries) {
|
||||
byConflictKey.set(`${entry.resourceType}:${entry.parentResourceId}`, entry)
|
||||
}
|
||||
const deduped = Array.from(byConflictKey.values())
|
||||
for (let i = 0; i < deduped.length; i += MAPPING_INSERT_CHUNK) {
|
||||
const batch = deduped.slice(i, i + MAPPING_INSERT_CHUNK)
|
||||
await tx
|
||||
.insert(workspaceForkResourceMap)
|
||||
.values(
|
||||
batch.map((entry) => ({
|
||||
id: generateId(),
|
||||
childWorkspaceId,
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.parentResourceId,
|
||||
childResourceId: entry.childResourceId,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
workspaceForkResourceMap.childWorkspaceId,
|
||||
workspaceForkResourceMap.resourceType,
|
||||
workspaceForkResourceMap.parentResourceId,
|
||||
],
|
||||
set: { childResourceId: sql`excluded.child_resource_id`, updatedAt: now },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove mapping rows matched by their child-side (source) resource id, grouped by
|
||||
* resource type into a single OR-of-INs - one query for the whole push save (the
|
||||
* unique key is on the parent side, so a changed push target must drop the old
|
||||
* (parent, source) row before the new one is inserted).
|
||||
*/
|
||||
export async function deleteEdgeMappingsByChildResources(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
pairs: Array<{ resourceType: ForkResourceType; childResourceId: string }>
|
||||
): Promise<void> {
|
||||
if (pairs.length === 0) return
|
||||
const idsByType = new Map<ForkResourceType, string[]>()
|
||||
for (const { resourceType, childResourceId } of pairs) {
|
||||
const list = idsByType.get(resourceType)
|
||||
if (list) list.push(childResourceId)
|
||||
else idsByType.set(resourceType, [childResourceId])
|
||||
}
|
||||
const conditions = Array.from(idsByType, ([resourceType, ids]) =>
|
||||
and(
|
||||
eq(workspaceForkResourceMap.resourceType, resourceType),
|
||||
inArray(workspaceForkResourceMap.childResourceId, ids)
|
||||
)
|
||||
)
|
||||
await tx
|
||||
.delete(workspaceForkResourceMap)
|
||||
.where(and(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId), or(...conditions)))
|
||||
}
|
||||
|
||||
export interface BuildForkResolverOptions {
|
||||
/** When the source side of the promote is the parent workspace (a pull). */
|
||||
sourceIsParent: boolean
|
||||
/**
|
||||
* Env keys present in the target workspace. A workspace-secret env reference with
|
||||
* no explicit mapping resolves to itself when the same key exists in the target.
|
||||
*/
|
||||
targetEnvKeys?: Set<string>
|
||||
/**
|
||||
* Env keys defined at the SOURCE workspace level. Only these are workspace secrets
|
||||
* that can be mapped; any other `{{KEY}}` is a personal (user-scoped) secret that
|
||||
* resolves identically in any workspace and is left as-is (never mapped/required).
|
||||
*/
|
||||
sourceEnvKeys?: Set<string>
|
||||
/**
|
||||
* Target ids that still EXIST in the target workspace, per kind, among the mapped
|
||||
* targets. When a kind is present, a mapped target NOT in its set is treated as
|
||||
* unmapped (the target was deleted after the mapping was saved), so a dead id is
|
||||
* never written into the promoted workflow. Kinds absent here are not existence-
|
||||
* checked (resolved as before).
|
||||
*/
|
||||
validTargetIdsByKind?: Partial<Record<ForkRemapKind, Set<string>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a reference resolver from persisted mapping rows for the chosen
|
||||
* direction. Translates a source-space resource id to its mapped target id;
|
||||
* rows whose `childResourceId` is null (unmapped) are skipped. Env keys fall
|
||||
* back to an identity mapping when the target workspace already has the key.
|
||||
*/
|
||||
export function buildForkResolver(
|
||||
rows: ForkMappingRow[],
|
||||
options: BuildForkResolverOptions
|
||||
): ForkReferenceResolver {
|
||||
const index = new Map<ForkRemapKind, Map<string, string>>()
|
||||
for (const row of rows) {
|
||||
const kind = resourceTypeToForkKind(row.resourceType)
|
||||
if (!kind) continue
|
||||
if (row.childResourceId == null) continue
|
||||
const sourceId = options.sourceIsParent ? row.parentResourceId : row.childResourceId
|
||||
const targetId = options.sourceIsParent ? row.childResourceId : row.parentResourceId
|
||||
let kindIndex = index.get(kind)
|
||||
if (!kindIndex) {
|
||||
kindIndex = new Map()
|
||||
index.set(kind, kindIndex)
|
||||
}
|
||||
kindIndex.set(sourceId, targetId)
|
||||
}
|
||||
|
||||
return (kind, sourceId) => {
|
||||
const mapped = index.get(kind)?.get(sourceId)
|
||||
if (mapped != null) {
|
||||
const validSet = options.validTargetIdsByKind?.[kind]
|
||||
if (!validSet || validSet.has(mapped)) return mapped
|
||||
// The mapped target was deleted from the target workspace after the mapping was
|
||||
// saved. Fall through so the reference resolves as unmapped (surfaced as required
|
||||
// / cleared if optional) instead of writing a dead id into the promoted workflow.
|
||||
}
|
||||
if (kind === 'env-var') {
|
||||
// Personal/global env vars (not a source workspace secret) are user-scoped and
|
||||
// resolve identically in any workspace - leave them as-is, never map them.
|
||||
if (options.sourceEnvKeys && !options.sourceEnvKeys.has(sourceId)) return sourceId
|
||||
// Workspace secret already present in the target by the same name → identity.
|
||||
if (options.targetEnvKeys?.has(sourceId)) return sourceId
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
listForkCopyableSourceResources,
|
||||
listForkResourceCandidates,
|
||||
loadForkCopyableResourceLabels,
|
||||
} from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
|
||||
const executor = dbChainMock.db as unknown as DbOrTx
|
||||
|
||||
describe('listForkResourceCandidates', () => {
|
||||
beforeEach(() => {
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('populates file candidates keyed by storage key and leaves knowledge-document empty', async () => {
|
||||
// The grouped queries resolve in Promise.all array order, each ending in `.limit()`:
|
||||
// credentials, workspace env, tables, knowledge bases, MCP servers, custom tools, skills,
|
||||
// files. Queue the eight results in that exact order.
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'cred-1', displayName: 'Cred One', providerId: 'google-email' },
|
||||
])
|
||||
.mockResolvedValueOnce([{ variables: { API_KEY: 'secret' } }])
|
||||
.mockResolvedValueOnce([{ id: 'tbl-1', label: 'Table One' }])
|
||||
.mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }])
|
||||
.mockResolvedValueOnce([{ id: 'mcp-1', label: 'MCP One' }])
|
||||
.mockResolvedValueOnce([{ id: 'ct-1', label: 'Tool One' }])
|
||||
.mockResolvedValueOnce([{ id: 'sk-1', label: 'Skill One' }])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'workspace/WS/report.pdf', label: 'report.pdf' },
|
||||
{ id: 'workspace/WS/notes.md', label: 'notes.md' },
|
||||
])
|
||||
|
||||
const result = await listForkResourceCandidates(executor, 'ws-1')
|
||||
|
||||
// Files are mapping targets keyed by storage key (matching how `file-upload` references store
|
||||
// them) - never a `workspace_files.id`.
|
||||
expect(result.file).toEqual([
|
||||
{ id: 'workspace/WS/report.pdf', label: 'report.pdf' },
|
||||
{ id: 'workspace/WS/notes.md', label: 'notes.md' },
|
||||
])
|
||||
// Documents are not a standalone mappable kind - they ride their KB via the reconfigure flow.
|
||||
expect(result['knowledge-document']).toEqual([])
|
||||
expect(result['env-var']).toEqual([{ id: 'API_KEY', label: 'API_KEY' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('listForkCopyableSourceResources', () => {
|
||||
beforeEach(() => {
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('lists every sync-copyable kind, files keyed by storage key with folder grouping', async () => {
|
||||
// The grouped queries resolve in Promise.all array order, each ending in `.limit()`:
|
||||
// files (with folder), tables, knowledge bases, custom tools, skills.
|
||||
dbChainMockFns.limit
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'file-row-1',
|
||||
key: 'workspace/SRC/a.png',
|
||||
label: 'a.png',
|
||||
folderId: 'fld-1',
|
||||
folderName: 'Images',
|
||||
},
|
||||
{
|
||||
id: 'file-row-2',
|
||||
key: 'workspace/SRC/root.txt',
|
||||
label: 'root.txt',
|
||||
folderId: null,
|
||||
folderName: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'tbl-1', label: 'Table One' }])
|
||||
.mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }])
|
||||
.mockResolvedValueOnce([{ id: 'ct-1', label: 'Tool One' }])
|
||||
.mockResolvedValueOnce([{ id: 'sk-1', label: 'Skill One' }])
|
||||
|
||||
const result = await listForkCopyableSourceResources(executor, 'ws-src')
|
||||
|
||||
expect(result).toEqual([
|
||||
// Files are addressed by STORAGE KEY (matching `file-upload` references + the promote copy
|
||||
// selection), never by `workspace_files.id`, and carry their folder grouping.
|
||||
{
|
||||
kind: 'file',
|
||||
sourceId: 'workspace/SRC/a.png',
|
||||
label: 'a.png',
|
||||
parentId: 'fld-1',
|
||||
parentLabel: 'Images',
|
||||
},
|
||||
{
|
||||
kind: 'file',
|
||||
sourceId: 'workspace/SRC/root.txt',
|
||||
label: 'root.txt',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
},
|
||||
{ kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null },
|
||||
{
|
||||
kind: 'knowledge-base',
|
||||
sourceId: 'kb-1',
|
||||
label: 'KB One',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
},
|
||||
{
|
||||
kind: 'custom-tool',
|
||||
sourceId: 'ct-1',
|
||||
label: 'Tool One',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
},
|
||||
{ kind: 'skill', sourceId: 'sk-1', label: 'Skill One', parentId: null, parentLabel: null },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadForkCopyableResourceLabels', () => {
|
||||
beforeEach(() => {
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('carries the folder grouping for file entries (id + name, null at the root)', async () => {
|
||||
// Only the file branch queries (no other kind has ids), so its terminal `.where()` is the
|
||||
// single chain call.
|
||||
dbChainMockFns.where.mockResolvedValueOnce([
|
||||
{ key: 'workspace/SRC/a.png', label: 'a.png', folderId: 'fld-1', folderName: 'Images' },
|
||||
{ key: 'workspace/SRC/root.txt', label: 'root.txt', folderId: null, folderName: null },
|
||||
])
|
||||
|
||||
const labels = await loadForkCopyableResourceLabels(executor, 'ws-src', {
|
||||
file: ['workspace/SRC/a.png', 'workspace/SRC/root.txt'],
|
||||
})
|
||||
|
||||
expect(labels.get('file:workspace/SRC/a.png')).toEqual({
|
||||
label: 'a.png',
|
||||
parentId: 'fld-1',
|
||||
parentLabel: 'Images',
|
||||
})
|
||||
// A file at the workspace root (or whose folder was deleted) carries null folder grouping.
|
||||
expect(labels.get('file:workspace/SRC/root.txt')).toEqual({
|
||||
label: 'root.txt',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null folder grouping for non-file kinds (they render flat)', async () => {
|
||||
dbChainMockFns.where.mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }])
|
||||
|
||||
const labels = await loadForkCopyableResourceLabels(executor, 'ws-src', {
|
||||
'knowledge-base': ['kb-1'],
|
||||
})
|
||||
|
||||
expect(labels.get('knowledge-base:kb-1')).toEqual({
|
||||
label: 'KB One',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,632 @@
|
||||
import {
|
||||
credential,
|
||||
customTools,
|
||||
document,
|
||||
knowledgeBase,
|
||||
mcpServers,
|
||||
skill,
|
||||
userTableDefinitions,
|
||||
workflow,
|
||||
workflowDeploymentVersion,
|
||||
workflowMcpServer,
|
||||
workspaceEnvironment,
|
||||
workspaceFileFolder,
|
||||
workspaceFiles,
|
||||
} from '@sim/db/schema'
|
||||
import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import type { ForkCopyableKind } from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type { ForkResourceType } from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import type {
|
||||
ForkMcpServerMeta,
|
||||
ForkRemapKind,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
export interface ForkResourceCandidate {
|
||||
id: string
|
||||
label: string
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
export const CANDIDATE_LIMIT = 1000
|
||||
|
||||
/** The set of env-var keys defined in a workspace (for resolver identity + gating). */
|
||||
export async function getWorkspaceEnvKeys(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<Set<string>> {
|
||||
const [row] = await executor
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
const variables = row?.variables
|
||||
if (!variables || typeof variables !== 'object') return new Set()
|
||||
return new Set(Object.keys(variables as Record<string, unknown>))
|
||||
}
|
||||
|
||||
// Shared `{ id, label }` candidate queries for the content resource kinds that BOTH the
|
||||
// mapping-target picker and the fork-copy picker list - one source of the archived/deleted
|
||||
// filters so the two pickers can never drift apart, and one optional `ids` filter so the picker
|
||||
// path (unfiltered, capped) and the existence/label path (exact ids) share a single definition
|
||||
// per kind. When `ids` is given the query is filtered to those exact ids and is NOT capped, so a
|
||||
// valid target sitting past the candidate cap is never wrongly dropped. Credentials, env vars
|
||||
// (mapping-only), and files-with-folder (copy-only) keep their own helpers below.
|
||||
const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
||||
const query = executor
|
||||
.select({ id: userTableDefinitions.id, label: userTableDefinitions.name })
|
||||
.from(userTableDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableDefinitions.workspaceId, workspaceId),
|
||||
isNull(userTableDefinitions.archivedAt),
|
||||
ids ? inArray(userTableDefinitions.id, ids) : undefined
|
||||
)
|
||||
)
|
||||
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
||||
}
|
||||
|
||||
const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
||||
const query = executor
|
||||
.select({ id: knowledgeBase.id, label: knowledgeBase.name })
|
||||
.from(knowledgeBase)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBase.workspaceId, workspaceId),
|
||||
isNull(knowledgeBase.deletedAt),
|
||||
ids ? inArray(knowledgeBase.id, ids) : undefined
|
||||
)
|
||||
)
|
||||
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
||||
}
|
||||
|
||||
const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
||||
const query = executor
|
||||
.select({ id: customTools.id, label: customTools.title })
|
||||
.from(customTools)
|
||||
.where(
|
||||
and(eq(customTools.workspaceId, workspaceId), ids ? inArray(customTools.id, ids) : undefined)
|
||||
)
|
||||
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
||||
}
|
||||
|
||||
const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
||||
const query = executor
|
||||
.select({ id: skill.id, label: skill.name })
|
||||
.from(skill)
|
||||
.where(and(eq(skill.workspaceId, workspaceId), ids ? inArray(skill.id, ids) : undefined))
|
||||
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
||||
}
|
||||
|
||||
const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => {
|
||||
const query = executor
|
||||
.select({ id: mcpServers.id, label: mcpServers.name })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.workspaceId, workspaceId),
|
||||
isNull(mcpServers.deletedAt),
|
||||
ids ? inArray(mcpServers.id, ids) : undefined
|
||||
)
|
||||
)
|
||||
return ids ? query : query.limit(CANDIDATE_LIMIT)
|
||||
}
|
||||
|
||||
// Workspace-file mapping candidates are keyed by STORAGE KEY (not `workspace_files.id`): a
|
||||
// `file-upload` reference stores the storage key, so a mapping target must be a key too. Only
|
||||
// durable, non-deleted `workspace` files are mappable (chat/copilot uploads are session-scoped).
|
||||
// An optional `keys` filter shares this definition between the mapping picker (unfiltered, capped)
|
||||
// and the cap-free existence check.
|
||||
const fileCandidatesQuery = (executor: DbOrTx, workspaceId: string, keys?: string[]) => {
|
||||
const query = executor
|
||||
.select({
|
||||
id: workspaceFiles.key,
|
||||
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
|
||||
})
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.workspaceId, workspaceId),
|
||||
eq(workspaceFiles.context, 'workspace'),
|
||||
isNull(workspaceFiles.deletedAt),
|
||||
keys ? inArray(workspaceFiles.key, keys) : undefined
|
||||
)
|
||||
)
|
||||
return keys ? query : query.limit(CANDIDATE_LIMIT)
|
||||
}
|
||||
|
||||
// Copyable workspace files WITH their folder grouping (LEFT JOIN gated on a live folder, so a file
|
||||
// whose folder was deleted shows ungrouped). Shared by the fork-copy picker (unfiltered, capped)
|
||||
// and the promote copyable-label lookup (filtered by exact storage keys, never capped), so the
|
||||
// file+folder shape and its filters live in one place. Selects both the row id and the storage
|
||||
// key; the copy picker reads `id`, the key-addressed label lookup reads `key`.
|
||||
const fileCandidatesWithFolderQuery = (
|
||||
executor: DbOrTx,
|
||||
workspaceId: string,
|
||||
options: { keys?: string[] } = {}
|
||||
) => {
|
||||
const { keys } = options
|
||||
const query = executor
|
||||
.select({
|
||||
id: workspaceFiles.id,
|
||||
key: workspaceFiles.key,
|
||||
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
|
||||
folderId: workspaceFiles.folderId,
|
||||
folderName: workspaceFileFolder.name,
|
||||
})
|
||||
.from(workspaceFiles)
|
||||
.leftJoin(
|
||||
workspaceFileFolder,
|
||||
and(
|
||||
eq(workspaceFiles.folderId, workspaceFileFolder.id),
|
||||
isNull(workspaceFileFolder.deletedAt)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.workspaceId, workspaceId),
|
||||
eq(workspaceFiles.context, 'workspace'),
|
||||
isNull(workspaceFiles.deletedAt),
|
||||
keys ? inArray(workspaceFiles.key, keys) : undefined
|
||||
)
|
||||
)
|
||||
return keys ? query : query.limit(CANDIDATE_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* List the resources in a workspace that can serve as mapping targets, grouped by
|
||||
* remap kind. Used to populate the mapping UI's target pickers and to label the
|
||||
* source resources being mapped. `knowledge-document` is intentionally left empty:
|
||||
* documents are not a standalone mappable kind - they are dependent fields of their
|
||||
* knowledge base, re-picked in the per-KB reconfigure flow (and auto-remapped when
|
||||
* their KB is copied). `file` candidates are keyed by storage key.
|
||||
*/
|
||||
export async function listForkResourceCandidates(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<Record<ForkRemapKind, ForkResourceCandidate[]>> {
|
||||
const [creds, wsEnvRows, tables, kbs, servers, tools, skills, files] = await Promise.all([
|
||||
executor
|
||||
.select({
|
||||
id: credential.id,
|
||||
displayName: credential.displayName,
|
||||
providerId: credential.providerId,
|
||||
})
|
||||
.from(credential)
|
||||
// Only real connections are mappable credentials. `env_workspace`/`env_personal`
|
||||
// rows live in the same table but are environment variables (surfaced via the
|
||||
// 'env-var' kind), so they must never appear as credential targets.
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account'])
|
||||
)
|
||||
)
|
||||
.limit(CANDIDATE_LIMIT),
|
||||
executor
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1),
|
||||
tableCandidatesQuery(executor, workspaceId),
|
||||
knowledgeBaseCandidatesQuery(executor, workspaceId),
|
||||
mcpServerCandidatesQuery(executor, workspaceId),
|
||||
customToolCandidatesQuery(executor, workspaceId),
|
||||
skillCandidatesQuery(executor, workspaceId),
|
||||
fileCandidatesQuery(executor, workspaceId),
|
||||
])
|
||||
|
||||
const envVariables = wsEnvRows[0]?.variables
|
||||
const envKeys =
|
||||
envVariables && typeof envVariables === 'object'
|
||||
? Object.keys(envVariables as Record<string, unknown>)
|
||||
: []
|
||||
|
||||
return {
|
||||
credential: creds.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.displayName,
|
||||
providerId: c.providerId ?? undefined,
|
||||
})),
|
||||
'env-var': envKeys.map((key) => ({ id: key, label: key })),
|
||||
table: tables,
|
||||
'knowledge-base': kbs,
|
||||
'mcp-server': servers,
|
||||
'custom-tool': tools,
|
||||
skill: skills,
|
||||
'knowledge-document': [],
|
||||
file: files,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given mapped target ids grouped by kind, return the subset that still EXISTS in the
|
||||
* target workspace (same archived/deleted filters as `listForkResourceCandidates`).
|
||||
* Used at promote time so a mapping whose target was deleted after it was saved
|
||||
* resolves as unmapped (surfaced/cleared) instead of writing a dead id into the
|
||||
* promoted workflow. Queries the exact ids (not the capped candidate list) so a valid
|
||||
* target is never wrongly dropped, and only the DB-backed kinds are checked - env-var
|
||||
* existence is handled by the resolver's `targetEnvKeys`, and `file`/`workflow` are
|
||||
* resolved by other paths.
|
||||
*/
|
||||
export async function filterExistingForkTargets(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string,
|
||||
idsByKind: Partial<Record<ForkRemapKind, Set<string>>>
|
||||
): Promise<Partial<Record<ForkRemapKind, Set<string>>>> {
|
||||
const ids = (kind: ForkRemapKind): string[] => {
|
||||
const set = idsByKind[kind]
|
||||
return set && set.size > 0 ? Array.from(set) : []
|
||||
}
|
||||
const credIds = ids('credential')
|
||||
const tableIds = ids('table')
|
||||
const kbIds = ids('knowledge-base')
|
||||
const docIds = ids('knowledge-document')
|
||||
const mcpIds = ids('mcp-server')
|
||||
const toolIds = ids('custom-tool')
|
||||
const skillIds = ids('skill')
|
||||
// Files are identified by storage key (not `workspace_files.id`); a copied file's mapping
|
||||
// target is its child storage key, so existence is checked by key in the target workspace.
|
||||
const fileKeys = ids('file')
|
||||
|
||||
const [creds, tables, kbs, docs, servers, tools, skills, files] = await Promise.all([
|
||||
credIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: executor
|
||||
.select({ id: credential.id })
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account']),
|
||||
inArray(credential.id, credIds)
|
||||
)
|
||||
),
|
||||
tableIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: tableCandidatesQuery(executor, workspaceId, tableIds),
|
||||
kbIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: knowledgeBaseCandidatesQuery(executor, workspaceId, kbIds),
|
||||
// Documents are validated through a KB join (they are not a standalone candidate kind), so
|
||||
// this existence check stays inline rather than sharing a per-kind candidate query.
|
||||
docIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: executor
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBase.workspaceId, workspaceId),
|
||||
isNull(knowledgeBase.deletedAt),
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt),
|
||||
inArray(document.id, docIds)
|
||||
)
|
||||
),
|
||||
mcpIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: mcpServerCandidatesQuery(executor, workspaceId, mcpIds),
|
||||
toolIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: customToolCandidatesQuery(executor, workspaceId, toolIds),
|
||||
skillIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: skillCandidatesQuery(executor, workspaceId, skillIds),
|
||||
fileKeys.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: fileCandidatesQuery(executor, workspaceId, fileKeys),
|
||||
])
|
||||
|
||||
const result: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
if (credIds.length > 0) result.credential = new Set(creds.map((r) => r.id))
|
||||
if (tableIds.length > 0) result.table = new Set(tables.map((r) => r.id))
|
||||
if (kbIds.length > 0) result['knowledge-base'] = new Set(kbs.map((r) => r.id))
|
||||
if (docIds.length > 0) result['knowledge-document'] = new Set(docs.map((r) => r.id))
|
||||
if (mcpIds.length > 0) result['mcp-server'] = new Set(servers.map((r) => r.id))
|
||||
if (toolIds.length > 0) result['custom-tool'] = new Set(tools.map((r) => r.id))
|
||||
if (skillIds.length > 0) result.skill = new Set(skills.map((r) => r.id))
|
||||
// `fileCandidatesQuery` exposes the storage key under `id`, so file existence keys by `r.id`.
|
||||
if (fileKeys.length > 0) result.file = new Set(files.map((r) => r.id))
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity metadata (`name`/`url`) for the given MCP server ids in a workspace, looked up by
|
||||
* exact id (no candidate cap, same deleted filter as the candidates). Promote uses it for the
|
||||
* MAPPED TARGET servers so remapped tool-input entries rewrite their embedded server metadata
|
||||
* from the target row (see {@link ForkMcpServerMeta}) - one bounded `inArray` read per sync,
|
||||
* never per-entry. An id absent from the map no longer exists; its entries are left as-is.
|
||||
*/
|
||||
export async function getMcpServerMetaByIds(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string,
|
||||
ids: string[]
|
||||
): Promise<Map<string, ForkMcpServerMeta>> {
|
||||
if (ids.length === 0) return new Map()
|
||||
const rows = await executor
|
||||
.select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.workspaceId, workspaceId),
|
||||
isNull(mcpServers.deletedAt),
|
||||
inArray(mcpServers.id, ids)
|
||||
)
|
||||
)
|
||||
return new Map(rows.map((row) => [row.id, { name: row.name, url: row.url ?? null }]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider id for each given credential id in a workspace, looked up by exact id (no
|
||||
* candidate cap). Presence in the returned map means the credential exists in the
|
||||
* workspace, so this doubles as a cap-free existence + provider check for validation.
|
||||
*/
|
||||
export async function getCredentialProvidersByIds(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string,
|
||||
ids: string[]
|
||||
): Promise<Map<string, string | null>> {
|
||||
if (ids.length === 0) return new Map()
|
||||
const rows = await executor
|
||||
.select({ id: credential.id, providerId: credential.providerId })
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account']),
|
||||
inArray(credential.id, ids)
|
||||
)
|
||||
)
|
||||
return new Map(rows.map((row) => [row.id, row.providerId ?? null]))
|
||||
}
|
||||
|
||||
/** A copyable workspace file plus its folder grouping (null folder = workspace root). */
|
||||
export interface ForkCopyableFileResource extends ForkResourceCandidate {
|
||||
folderId: string | null
|
||||
folderName: string | null
|
||||
}
|
||||
|
||||
export interface ForkCopyableResources {
|
||||
files: ForkCopyableFileResource[]
|
||||
tables: ForkResourceCandidate[]
|
||||
knowledgeBases: ForkResourceCandidate[]
|
||||
customTools: ForkResourceCandidate[]
|
||||
skills: ForkResourceCandidate[]
|
||||
/** External MCP servers, copied as config rows (OAuth tokens never copied - re-auth in child). */
|
||||
mcpServers: ForkResourceCandidate[]
|
||||
/** Workflow-publishing MCP servers, copied as config-only shells with no workflows attached. */
|
||||
workflowMcpServers: ForkResourceCandidate[]
|
||||
/**
|
||||
* Count of deployed workflows that the fork would copy. When 0, the fork modal shows an
|
||||
* informational note (forking is never blocked) - create-fork seeds a blank starter
|
||||
* workflow so the child is still a usable workspace.
|
||||
*/
|
||||
deployedWorkflowCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* List the resources in a workspace that can be selected for copy at fork time
|
||||
* (the content kinds — never credentials or env vars). Powers the fork modal's
|
||||
* resource picker.
|
||||
*/
|
||||
export async function listForkCopyableResources(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<ForkCopyableResources> {
|
||||
const [files, tables, kbs, tools, skills, externalServers, servers, deployed] = await Promise.all(
|
||||
[
|
||||
fileCandidatesWithFolderQuery(executor, workspaceId),
|
||||
tableCandidatesQuery(executor, workspaceId),
|
||||
knowledgeBaseCandidatesQuery(executor, workspaceId),
|
||||
customToolCandidatesQuery(executor, workspaceId),
|
||||
skillCandidatesQuery(executor, workspaceId),
|
||||
// External MCP servers copy as config rows (same filter as the mapping candidates).
|
||||
mcpServerCandidatesQuery(executor, workspaceId),
|
||||
executor
|
||||
.select({ id: workflowMcpServer.id, label: workflowMcpServer.name })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt))
|
||||
)
|
||||
.limit(CANDIDATE_LIMIT),
|
||||
executor
|
||||
.select({ value: count() })
|
||||
.from(workflow)
|
||||
// Match listDeployedWorkflows: a workflow only counts as copyable when it has an
|
||||
// actually-active deployment version, not just the isDeployed flag, so the fork
|
||||
// modal's preflight count never over-reports "ghost" deployed workflows.
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
eq(workflow.isDeployed, true),
|
||||
isNull(workflow.archivedAt),
|
||||
exists(
|
||||
executor
|
||||
.select({ one: sql`1` })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
return {
|
||||
// The shared folder query also selects the storage key (for the label lookup); the copy
|
||||
// picker addresses files by `workspace_files.id`, so drop the key here.
|
||||
files: files.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
folderId: row.folderId,
|
||||
folderName: row.folderName,
|
||||
})),
|
||||
tables,
|
||||
knowledgeBases: kbs,
|
||||
customTools: tools,
|
||||
skills,
|
||||
mcpServers: externalServers,
|
||||
workflowMcpServers: servers,
|
||||
deployedWorkflowCount: deployed[0]?.value ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A copyable reference's display label plus its folder grouping. `parentId`/`parentLabel` are
|
||||
* populated only for files (their folder id + name; null at the workspace root) and are null for
|
||||
* every other copyable kind, which the picker renders flat.
|
||||
*/
|
||||
export interface ForkCopyableLabel {
|
||||
label: string
|
||||
parentId: string | null
|
||||
parentLabel: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One copyable resource in the sync SOURCE workspace, keyed the way the promote copy addresses
|
||||
* it: files by STORAGE KEY (matching `file-upload` references + `planForkFileCopies`), every
|
||||
* other kind by row id. `parentId`/`parentLabel` carry a file's folder grouping (null for
|
||||
* non-file kinds and root files).
|
||||
*/
|
||||
export interface ForkCopyableSourceResource {
|
||||
kind: ForkCopyableKind
|
||||
sourceId: string
|
||||
label: string
|
||||
parentId: string | null
|
||||
parentLabel: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every copyable-kind resource in the sync source workspace (same archived/deleted filters and
|
||||
* per-kind {@link CANDIDATE_LIMIT} cap as the copy picker), as sync-copy candidate entries. The
|
||||
* promote plan filters these down to the UNREFERENCED-and-unmapped set it offers for copy
|
||||
* alongside the referenced candidates. Covers exactly the sync-copyable kinds
|
||||
* (`forkCopyableKindSchema`): workflow-publishing MCP servers are fork-copy-only shells, and
|
||||
* credentials / env vars are never copied.
|
||||
*/
|
||||
export async function listForkCopyableSourceResources(
|
||||
executor: DbOrTx,
|
||||
sourceWorkspaceId: string
|
||||
): Promise<ForkCopyableSourceResource[]> {
|
||||
const [files, tables, kbs, tools, skills, mcp] = await Promise.all([
|
||||
fileCandidatesWithFolderQuery(executor, sourceWorkspaceId),
|
||||
tableCandidatesQuery(executor, sourceWorkspaceId),
|
||||
knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId),
|
||||
customToolCandidatesQuery(executor, sourceWorkspaceId),
|
||||
skillCandidatesQuery(executor, sourceWorkspaceId),
|
||||
mcpServerCandidatesQuery(executor, sourceWorkspaceId),
|
||||
])
|
||||
const flat = (
|
||||
kind: ForkCopyableKind,
|
||||
rows: Array<{ id: string; label: string }>
|
||||
): ForkCopyableSourceResource[] =>
|
||||
rows.map((row) => ({
|
||||
kind,
|
||||
sourceId: row.id,
|
||||
label: row.label,
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
}))
|
||||
return [
|
||||
...files.map((row) => ({
|
||||
kind: 'file' as const,
|
||||
sourceId: row.key,
|
||||
label: row.label,
|
||||
parentId: row.folderId,
|
||||
parentLabel: row.folderName,
|
||||
})),
|
||||
...flat('table', tables),
|
||||
...flat('knowledge-base', kbs),
|
||||
...flat('custom-tool', tools),
|
||||
...flat('skill', skills),
|
||||
...flat('mcp-server', mcp),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels (by exact id) for the copyable resource kinds referenced-but-unmapped at promote time,
|
||||
* scoped to the source workspace and the same archived/deleted filters as the copy picker. A
|
||||
* resource absent from the result no longer exists in the source, so it can't be copied and is
|
||||
* dropped from the sync copy candidates. Keyed `${kind}:${id}` so callers can look a reference up
|
||||
* directly; file entries additionally carry their folder grouping. Only kinds with ids are queried.
|
||||
*/
|
||||
export async function loadForkCopyableResourceLabels(
|
||||
executor: DbOrTx,
|
||||
sourceWorkspaceId: string,
|
||||
idsByKind: Partial<Record<ForkCopyableKind, string[]>>
|
||||
): Promise<Map<string, ForkCopyableLabel>> {
|
||||
const labels = new Map<string, ForkCopyableLabel>()
|
||||
const ids = (kind: ForkCopyableKind): string[] => {
|
||||
const list = idsByKind[kind]
|
||||
return list && list.length > 0 ? list : []
|
||||
}
|
||||
const kbIds = ids('knowledge-base')
|
||||
const tableIds = ids('table')
|
||||
const toolIds = ids('custom-tool')
|
||||
const skillIds = ids('skill')
|
||||
const mcpIds = ids('mcp-server')
|
||||
// Files are keyed by storage key (not `workspace_files.id`), so they label by key.
|
||||
const fileKeys = ids('file')
|
||||
|
||||
const [kbs, tables, tools, skills, mcp, files] = await Promise.all([
|
||||
kbIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
||||
: knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId, kbIds),
|
||||
tableIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
||||
: tableCandidatesQuery(executor, sourceWorkspaceId, tableIds),
|
||||
toolIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
||||
: customToolCandidatesQuery(executor, sourceWorkspaceId, toolIds),
|
||||
skillIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
||||
: skillCandidatesQuery(executor, sourceWorkspaceId, skillIds),
|
||||
mcpIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string; label: string }>)
|
||||
: mcpServerCandidatesQuery(executor, sourceWorkspaceId, mcpIds),
|
||||
fileKeys.length === 0
|
||||
? Promise.resolve(
|
||||
[] as Array<{
|
||||
key: string
|
||||
label: string
|
||||
folderId: string | null
|
||||
folderName: string | null
|
||||
}>
|
||||
)
|
||||
: fileCandidatesWithFolderQuery(executor, sourceWorkspaceId, { keys: fileKeys }),
|
||||
])
|
||||
|
||||
const flat = (label: string): ForkCopyableLabel => ({ label, parentId: null, parentLabel: null })
|
||||
for (const row of kbs) labels.set(`knowledge-base:${row.id}`, flat(row.label))
|
||||
for (const row of tables) labels.set(`table:${row.id}`, flat(row.label))
|
||||
for (const row of tools) labels.set(`custom-tool:${row.id}`, flat(row.label))
|
||||
for (const row of skills) labels.set(`skill:${row.id}`, flat(row.label))
|
||||
for (const row of mcp) labels.set(`mcp-server:${row.id}`, flat(row.label))
|
||||
for (const row of files) {
|
||||
labels.set(`file:${row.key}`, {
|
||||
label: row.label,
|
||||
parentId: row.folderId,
|
||||
parentLabel: row.folderName,
|
||||
})
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
/** Resolve a credential id to its stored mapping resource type. */
|
||||
export async function classifyCredentialResourceType(
|
||||
executor: DbOrTx,
|
||||
credentialId: string,
|
||||
workspaceId: string
|
||||
): Promise<Extract<ForkResourceType, 'oauth_credential' | 'service_account_credential'>> {
|
||||
const [row] = await executor
|
||||
.select({ type: credential.type })
|
||||
.from(credential)
|
||||
.where(and(eq(credential.id, credentialId), eq(credential.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
return row?.type === 'service_account' ? 'service_account_credential' : 'oauth_credential'
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
import { mcpServers, workflow } from '@sim/db/schema'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type {
|
||||
ForkClearedRef,
|
||||
ForkCopyableKind,
|
||||
ForkSyncBlocker,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
coerceObjectArray,
|
||||
isRecord,
|
||||
type SubBlockRecord,
|
||||
} from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import {
|
||||
buildSubBlockValues,
|
||||
type CanonicalModeOverrides,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { collectForkDependentReconfigs } from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
|
||||
import {
|
||||
filterExistingForkTargets,
|
||||
loadForkCopyableResourceLabels,
|
||||
} from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan'
|
||||
import {
|
||||
selectForkSyncBlockingRefs,
|
||||
toForkSyncBlockers,
|
||||
} from '@/ee/workspace-forking/lib/promote/sync-blockers'
|
||||
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import {
|
||||
createCanonicalModeGates,
|
||||
type ForkReference,
|
||||
type ForkReferenceResolver,
|
||||
type ForkRemapKind,
|
||||
REQUIRED_KINDS,
|
||||
remapForkSubBlocks,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Remappable kinds excluded from the `reference` cleared-ref list. REQUIRED kinds (credential,
|
||||
* env-var) gate Sync through the kind-level required gate with their own messaging, so they must
|
||||
* not double-report here (a credential is also preserved by name once mapped, an env-var always).
|
||||
* `knowledge-document` follows its parent KB - a document under an unmapped KB is implied by the
|
||||
* KB's own cleared-ref entry, and under a mapped/copied KB it is auto-copied. Every other kind's
|
||||
* entry IS a sync blocker (cause `reference`/`workflow`): a sync proceeds only when zero
|
||||
* references would clear.
|
||||
*/
|
||||
const CLEARED_REF_EXCLUDED_KINDS = new Set<ForkRemapKind>([...REQUIRED_KINDS, 'knowledge-document'])
|
||||
|
||||
interface ClearedRefItem {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
mode: 'create' | 'replace'
|
||||
sourceMeta: { name: string }
|
||||
}
|
||||
|
||||
export interface CollectForkClearedRefsParams {
|
||||
items: ClearedRefItem[]
|
||||
sourceStates: Map<string, WorkflowState>
|
||||
/** Plan resolver (persisted mappings + env identity), to detect which refs are currently unmapped. */
|
||||
resolver: ForkReferenceResolver
|
||||
/** Source workflow id -> target id for THIS sync; a ref to a workflow absent here is cleared. */
|
||||
workflowIdMap: Map<string, string>
|
||||
/** Same block-id resolver the sync uses, so a candidate's blockId matches the written block. */
|
||||
resolveBlockId: ForkBlockIdResolver
|
||||
/** `${kind}:${sourceId}` -> source resource label, for the `sourceLabel` display. */
|
||||
sourceLabels: Map<string, string>
|
||||
/** Source workflow id -> name, for `workflow`-kind candidate labels. */
|
||||
sourceWorkflowNames: Map<string, string>
|
||||
}
|
||||
|
||||
/** Strip an advanced-mode `_N` suffix so a subblock key matches its config id. */
|
||||
function baseSubBlockId(key: string): string {
|
||||
return key.replace(/_\d+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-workflow references (`workflow-selector`, multi-select `workflowSelector`, the
|
||||
* workspace-event trigger's multi-select `workflowIds` dropdown, nested `workflow_input` tools)
|
||||
* in a block's subBlocks. Mirrors the detection in
|
||||
* {@link remapWorkflowReferencesInSubBlocks} so the cleared-ref list flags exactly the refs that
|
||||
* remap would clear - the free-form manual fields (`manualWorkflowId`, `manualWorkflowIds`) are
|
||||
* user-owned and never remapped/cleared, so they are intentionally excluded (the `workflowIds`
|
||||
* branch is gated on TYPE `dropdown` because the logs block's `workflowIds` is a manual
|
||||
* `short-input`). Returns one entry per referenced workflow id with its owning subblock key.
|
||||
*/
|
||||
function collectForkWorkflowReferences(
|
||||
subBlocks: SubBlockRecord,
|
||||
config: ReturnType<typeof getBlock>,
|
||||
canonicalModes: CanonicalModeOverrides | undefined
|
||||
): Array<{ workflowId: string; subBlockKey: string }> {
|
||||
const out: Array<{ workflowId: string; subBlockKey: string }> = []
|
||||
// Collapse each canonical pair to its ACTIVE member and skip condition-hidden fields: only a
|
||||
// value that serializes is a ref that a sync would clear (the advanced
|
||||
// `manualWorkflowId`/`manualWorkflowIds` are user-owned and preserved verbatim, an inactive
|
||||
// operation's selector never executes) - neither may become an unresolvable sync blocker.
|
||||
// Shares {@link createCanonicalModeGates} with the reference scan, so the scalar `workflowId`
|
||||
// pair, the deployments block's scalar `workflowSelector` pair, and the logs block's
|
||||
// multi-select `workflowSelector` (`workflowIds` group) all resolve through their OWN group. A
|
||||
// missing config or a non-pair member is never skipped (no-pair states keep emitting).
|
||||
const gates = createCanonicalModeGates(
|
||||
config?.subBlocks,
|
||||
buildSubBlockValues(subBlocks),
|
||||
canonicalModes
|
||||
)
|
||||
const detectionSkipped = (key: string) =>
|
||||
gates.isDormantMember(key) || gates.isConditionHidden(key)
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (!subBlock || typeof subBlock !== 'object') continue
|
||||
const baseKey = baseSubBlockId(key)
|
||||
if (
|
||||
subBlock.type === 'workflow-selector' &&
|
||||
typeof subBlock.value === 'string' &&
|
||||
subBlock.value
|
||||
) {
|
||||
// Only the SELECTOR is remapped/cleared; the manual member is user-owned and preserved
|
||||
// verbatim, so skip the dormant selector when advanced/manual mode is active.
|
||||
if (detectionSkipped(key)) continue
|
||||
out.push({ workflowId: subBlock.value, subBlockKey: key })
|
||||
} else if (
|
||||
baseKey === 'workflowSelector' ||
|
||||
(subBlock.type === 'dropdown' && baseKey === 'workflowIds')
|
||||
) {
|
||||
if (detectionSkipped(key)) continue
|
||||
const ids = Array.isArray(subBlock.value)
|
||||
? subBlock.value
|
||||
: typeof subBlock.value === 'string'
|
||||
? subBlock.value.split(',').map((entry) => entry.trim())
|
||||
: []
|
||||
for (const id of ids) {
|
||||
if (typeof id === 'string' && id) out.push({ workflowId: id, subBlockKey: key })
|
||||
}
|
||||
} else if (subBlock.type === 'tool-input') {
|
||||
const { array } = coerceObjectArray(subBlock.value)
|
||||
if (!array) continue
|
||||
for (const tool of array) {
|
||||
if (
|
||||
isRecord(tool) &&
|
||||
tool.type === 'workflow_input' &&
|
||||
isRecord(tool.params) &&
|
||||
typeof tool.params.workflowId === 'string' &&
|
||||
tool.params.workflowId
|
||||
) {
|
||||
out.push({ workflowId: tool.params.workflowId, subBlockKey: key })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the per-block/field references this sync WILL blank in the target, for the pre-sync
|
||||
* "what will be cleared" list. Three causes (see {@link ForkClearedRef}):
|
||||
* - `reference`: an unmapped remappable resource (credential / KB / table / file / MCP server /
|
||||
* custom tool / skill). The client filters these against the live mapping + copy selection, so an
|
||||
* item disappears once mapped or selected for copy. Env vars (preserved) and documents (follow
|
||||
* their KB) are excluded.
|
||||
* - `workflow`: a cross-workflow reference to a workflow not carried into the target - always cleared.
|
||||
* - `dependent`: a create-target dependent selector the source configured that a remapped parent
|
||||
* clears. Carries `parentKind`/`parentSourceId` so the client can drop it once a KB parent is
|
||||
* mapped or copied (the document follows its KB); a credential's label or a table's column is
|
||||
* cleared on any parent remap, so it stays.
|
||||
*
|
||||
* Pure (no DB): the caller supplies the plan, source states, resolver, block-id resolver, and the
|
||||
* source label maps. Block + field labels come from the block registry / block state.
|
||||
*/
|
||||
export function collectForkClearedRefCandidates(
|
||||
params: CollectForkClearedRefsParams
|
||||
): ForkClearedRef[] {
|
||||
const { items, sourceStates, resolver, workflowIdMap, resolveBlockId, sourceLabels } = params
|
||||
const out: ForkClearedRef[] = []
|
||||
const labelFor = (kind: string, sourceId: string) =>
|
||||
sourceLabels.get(`${kind}:${sourceId}`) ?? sourceId
|
||||
|
||||
for (const item of items) {
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
|
||||
const config = getBlock(block.type)
|
||||
const blockLabel = block.name
|
||||
const targetBlockId = resolveBlockId(item.targetWorkflowId, sourceBlockId)
|
||||
// double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally
|
||||
// SubBlockRecord entries but lack the open index signature SubBlockRecord declares
|
||||
const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
|
||||
const fieldLabel = (subBlockKey: string) =>
|
||||
config?.subBlocks.find((cfg) => cfg.id === baseSubBlockId(subBlockKey))?.title ??
|
||||
subBlockKey
|
||||
|
||||
// Cause `reference`: unmapped remappable resource refs (per block/field). `blockType` +
|
||||
// `canonicalModes` gate detection to the ACTIVE canonical member, matching the plan's
|
||||
// reference scan - a dormant member's stale value is not a real reference, so it must not
|
||||
// become a blocker with no mapping entry to resolve it. `sourceDeleted` starts false; the
|
||||
// caller annotates it via {@link annotateForkClearedRefSourceLiveness} (DB check).
|
||||
const scan = remapForkSubBlocks(subBlocks, resolver, 'promote', {
|
||||
blockId: targetBlockId,
|
||||
blockName: blockLabel,
|
||||
blockType: block.type,
|
||||
canonicalModes: block.data?.canonicalModes,
|
||||
})
|
||||
for (const ref of scan.unmapped) {
|
||||
if (CLEARED_REF_EXCLUDED_KINDS.has(ref.kind)) continue
|
||||
out.push({
|
||||
targetWorkflowId: item.targetWorkflowId,
|
||||
workflowName: item.sourceMeta.name,
|
||||
blockId: targetBlockId,
|
||||
blockLabel,
|
||||
fieldLabel: fieldLabel(ref.subBlockKey),
|
||||
kind: ref.kind,
|
||||
sourceId: ref.sourceId,
|
||||
sourceLabel: labelFor(ref.kind, ref.sourceId),
|
||||
cause: 'reference',
|
||||
sourceDeleted: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Cause `workflow`: refs to a workflow not carried into the target.
|
||||
for (const wfRef of collectForkWorkflowReferences(
|
||||
subBlocks,
|
||||
config,
|
||||
block.data?.canonicalModes
|
||||
)) {
|
||||
if (workflowIdMap.has(wfRef.workflowId)) continue
|
||||
out.push({
|
||||
targetWorkflowId: item.targetWorkflowId,
|
||||
workflowName: item.sourceMeta.name,
|
||||
blockId: targetBlockId,
|
||||
blockLabel,
|
||||
fieldLabel: fieldLabel(wfRef.subBlockKey),
|
||||
kind: 'workflow',
|
||||
sourceId: wfRef.workflowId,
|
||||
sourceLabel: params.sourceWorkflowNames.get(wfRef.workflowId) ?? wfRef.workflowId,
|
||||
cause: 'workflow',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cause `dependent`: create-target dependent selectors the source configured that a remapped
|
||||
// parent clears. Only `replace` targets get the in-place reconfigure flow; a created target has
|
||||
// no draft to re-pick against, so these would clear silently - surface them here.
|
||||
const workflowNameByTarget = new Map(items.map((i) => [i.targetWorkflowId, i.sourceMeta.name]))
|
||||
for (const dependent of collectForkDependentReconfigs(
|
||||
items,
|
||||
sourceStates,
|
||||
resolveBlockId,
|
||||
'create'
|
||||
)) {
|
||||
if (dependent.currentValue === '') continue
|
||||
out.push({
|
||||
targetWorkflowId: dependent.targetWorkflowId,
|
||||
workflowName: workflowNameByTarget.get(dependent.targetWorkflowId) ?? '',
|
||||
blockId: dependent.targetBlockId,
|
||||
blockLabel: dependent.blockName,
|
||||
// Nested tool fields keep a plain `title` for the reconfigure UI; warnings need the
|
||||
// tool disambiguator so two tools with the same field name stay distinguishable.
|
||||
fieldLabel: dependent.toolName
|
||||
? `${dependent.toolName}: ${dependent.title}`
|
||||
: dependent.title,
|
||||
kind: dependent.parentKind,
|
||||
sourceId: dependent.parentSourceId,
|
||||
sourceLabel: labelFor(dependent.parentKind, dependent.parentSourceId),
|
||||
cause: 'dependent',
|
||||
// The dependsOn parent (its KB/credential/table). The client drops this entry once the parent
|
||||
// is mapped or copied ONLY when the child follows it (a document under a KB); a credential's
|
||||
// label or a table's column is cleared on any parent remap, so it stays.
|
||||
parentKind: dependent.parentKind,
|
||||
parentSourceId: dependent.parentSourceId,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill each `reference`-cause entry's `sourceDeleted` flag by checking whether its resource still
|
||||
* exists (not deleted/archived) in the SOURCE workspace. Reuses {@link filterExistingForkTargets}
|
||||
* - a per-kind, exact-id (cap-free) liveness check with the canonical archived/deleted filters -
|
||||
* pointed at the source workspace instead of a target. One batched round per kind present; a
|
||||
* no-op (zero queries) when no reference-cause entries exist. Files check by storage key, matching
|
||||
* how `file` references are recorded.
|
||||
*/
|
||||
export async function annotateForkClearedRefSourceLiveness(
|
||||
executor: DbOrTx,
|
||||
sourceWorkspaceId: string,
|
||||
clearedRefs: ForkClearedRef[]
|
||||
): Promise<ForkClearedRef[]> {
|
||||
const idsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
for (const ref of clearedRefs) {
|
||||
if (ref.cause !== 'reference') continue
|
||||
;(idsByKind[ref.kind] ??= new Set()).add(ref.sourceId)
|
||||
}
|
||||
if (Object.keys(idsByKind).length === 0) return clearedRefs
|
||||
const liveByKind = await filterExistingForkTargets(executor, sourceWorkspaceId, idsByKind)
|
||||
return clearedRefs.map((ref) =>
|
||||
ref.cause === 'reference'
|
||||
? { ...ref, sourceDeleted: !(liveByKind[ref.kind]?.has(ref.sourceId) ?? false) }
|
||||
: ref
|
||||
)
|
||||
}
|
||||
|
||||
/** Upper bound on the blockers a gate failure reports, so the error body stays sane. */
|
||||
const FORK_SYNC_BLOCKER_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Cheap existence check for blocking gate candidates, reusing the plan's already-computed scan
|
||||
* output instead of re-running the full per-block reference scan:
|
||||
* - `reference` cause: the collector detects references with the same per-block scan
|
||||
* ({@link remapForkSubBlocks}) over the same source states the plan already ran, so a
|
||||
* candidate exists iff some plan-unmapped reference of a non-excluded kind still resolves to
|
||||
* null through the gate resolver. The gate resolver only ADDS resolutions on top of the plan
|
||||
* resolver (promote's copy-selection overlay), so filtering the plan's unmapped set through it
|
||||
* yields exactly the gate's unmapped set. The plan's cascade-only additions (env-var /
|
||||
* credential) are excluded kinds and never contribute.
|
||||
* - `workflow` cause: cross-workflow refs are not part of the plan's scan, so walk the blocks
|
||||
* with the (much lighter) workflow-reference detection only, against the same workflowIdMap
|
||||
* predicate the collector applies.
|
||||
* `dependent`-cause candidates never block (see {@link forkSyncBlockerReasonFor}), so they are
|
||||
* not checked.
|
||||
*/
|
||||
function hasForkSyncBlockerCandidates(
|
||||
planUnmapped: ReadonlyArray<Pick<ForkReference, 'kind' | 'sourceId'>>,
|
||||
params: Pick<
|
||||
CollectForkClearedRefsParams,
|
||||
'items' | 'sourceStates' | 'resolver' | 'workflowIdMap'
|
||||
>
|
||||
): boolean {
|
||||
const { items, sourceStates, resolver, workflowIdMap } = params
|
||||
const hasReferenceCandidate = planUnmapped.some(
|
||||
(reference) =>
|
||||
!CLEARED_REF_EXCLUDED_KINDS.has(reference.kind) &&
|
||||
resolver(reference.kind, reference.sourceId) == null
|
||||
)
|
||||
if (hasReferenceCandidate) return true
|
||||
for (const item of items) {
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
for (const block of Object.values(state.blocks)) {
|
||||
// double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally
|
||||
// SubBlockRecord entries but lack the open index signature SubBlockRecord declares
|
||||
const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
|
||||
const workflowRefs = collectForkWorkflowReferences(
|
||||
subBlocks,
|
||||
getBlock(block.type),
|
||||
block.data?.canonicalModes
|
||||
)
|
||||
if (workflowRefs.some((ref) => !workflowIdMap.has(ref.workflowId))) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The authoritative would-clear gate input for a promote: collect the cleared-ref candidates for
|
||||
* the sync (against the caller's resolver, which must already account for the copy selection),
|
||||
* keep the blocking causes (`reference` / `workflow` - dependents stay with the reconfigure
|
||||
* flow), annotate source liveness, and return them as wire {@link ForkSyncBlocker}s with
|
||||
* best-effort labels. The happy path (nothing would clear) costs ZERO queries - the collection is
|
||||
* pure over the pre-read source states - and, when `planUnmapped` is supplied, ZERO re-scans of
|
||||
* the blocks the plan already scanned; liveness + label reads (and the full candidate collection,
|
||||
* for identical per-block/field blocker rows) run only when something blocks. Truncated to
|
||||
* {@link FORK_SYNC_BLOCKER_LIMIT} entries.
|
||||
*/
|
||||
export async function collectForkSyncBlockers(
|
||||
params: Omit<CollectForkClearedRefsParams, 'sourceLabels' | 'sourceWorkflowNames'> & {
|
||||
executor: DbOrTx
|
||||
sourceWorkspaceId: string
|
||||
/**
|
||||
* The plan's unmapped references (`unmappedRequired` + `unmappedOptional`), when the caller
|
||||
* computed the plan over the SAME `items`/`sourceStates` inside the same transaction AND the
|
||||
* gate `resolver` only augments the plan's resolver (never un-resolves a plan-mapped ref) -
|
||||
* promote's copy-selection overlay satisfies both. Enables the happy-path shortcut via
|
||||
* {@link hasForkSyncBlockerCandidates}: the full per-block reference scan the plan already
|
||||
* ran is skipped when no blocking candidate can exist, and re-run (for byte-identical blocker
|
||||
* rows) when one does. Omit to always collect from scratch.
|
||||
*/
|
||||
planUnmapped?: ReadonlyArray<Pick<ForkReference, 'kind' | 'sourceId'>>
|
||||
}
|
||||
): Promise<ForkSyncBlocker[]> {
|
||||
const { executor, sourceWorkspaceId, planUnmapped, ...collectParams } = params
|
||||
if (planUnmapped && !hasForkSyncBlockerCandidates(planUnmapped, collectParams)) return []
|
||||
const candidates = collectForkClearedRefCandidates({
|
||||
...collectParams,
|
||||
sourceLabels: new Map(),
|
||||
sourceWorkflowNames: new Map(),
|
||||
})
|
||||
if (!candidates.some((ref) => ref.cause === 'reference' || ref.cause === 'workflow')) return []
|
||||
|
||||
const annotated = await annotateForkClearedRefSourceLiveness(
|
||||
executor,
|
||||
sourceWorkspaceId,
|
||||
candidates
|
||||
)
|
||||
const blocking = selectForkSyncBlockingRefs(annotated).slice(0, FORK_SYNC_BLOCKER_LIMIT)
|
||||
if (blocking.length === 0) return []
|
||||
|
||||
// Best-effort display labels (failure path only). Copyable kinds go through the shared label
|
||||
// loader (live rows only - a deleted source keeps its id label); MCP servers are read without
|
||||
// the deleted filter so a source-deleted server still names itself; workflow names label the
|
||||
// `workflow`-cause entries.
|
||||
const copyableIdsByKind: Partial<Record<ForkCopyableKind, string[]>> = {}
|
||||
const mcpIds: string[] = []
|
||||
const workflowIds: string[] = []
|
||||
for (const { ref } of blocking) {
|
||||
if (ref.cause === 'workflow') workflowIds.push(ref.sourceId)
|
||||
else if (ref.kind === 'mcp-server') mcpIds.push(ref.sourceId)
|
||||
else if (isForkCopyableKind(ref.kind)) (copyableIdsByKind[ref.kind] ??= []).push(ref.sourceId)
|
||||
}
|
||||
const [copyableLabels, mcpRows, workflowRows] = await Promise.all([
|
||||
loadForkCopyableResourceLabels(executor, sourceWorkspaceId, copyableIdsByKind),
|
||||
mcpIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string; name: string }>)
|
||||
: executor
|
||||
.select({ id: mcpServers.id, name: mcpServers.name })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(eq(mcpServers.workspaceId, sourceWorkspaceId), inArray(mcpServers.id, mcpIds))
|
||||
),
|
||||
workflowIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string; name: string }>)
|
||||
: executor
|
||||
.select({ id: workflow.id, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(
|
||||
and(eq(workflow.workspaceId, sourceWorkspaceId), inArray(workflow.id, workflowIds))
|
||||
),
|
||||
])
|
||||
const mcpNames = new Map(mcpRows.map((row) => [row.id, row.name]))
|
||||
const workflowNames = new Map(workflowRows.map((row) => [row.id, row.name]))
|
||||
const labelFor = (ref: ForkClearedRef): string => {
|
||||
if (ref.cause === 'workflow') return workflowNames.get(ref.sourceId) ?? ref.sourceLabel
|
||||
if (ref.kind === 'mcp-server') return mcpNames.get(ref.sourceId) ?? ref.sourceLabel
|
||||
return copyableLabels.get(`${ref.kind}:${ref.sourceId}`)?.label ?? ref.sourceLabel
|
||||
}
|
||||
|
||||
return toForkSyncBlockers(
|
||||
blocking.map(({ ref, reason }) => ({ ref: { ...ref, sourceLabel: labelFor(ref) }, reason }))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
type ForkCopyableUnmapped,
|
||||
forkCopyableKindSchema,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
const {
|
||||
mockUpsertEdgeMappings,
|
||||
mockDeleteEdgeMappingsByChildResources,
|
||||
mockCopyForkResourceContainers,
|
||||
mockPlanForkMappedKbDocumentCopies,
|
||||
mockPlanForkFileCopies,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUpsertEdgeMappings: vi.fn(),
|
||||
mockDeleteEdgeMappingsByChildResources: vi.fn(),
|
||||
mockCopyForkResourceContainers: vi.fn(),
|
||||
mockPlanForkMappedKbDocumentCopies: vi.fn(),
|
||||
mockPlanForkFileCopies: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
|
||||
upsertEdgeMappings: mockUpsertEdgeMappings,
|
||||
deleteEdgeMappingsByChildResources: mockDeleteEdgeMappingsByChildResources,
|
||||
resourceTypeToForkKind: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/copy-resources', () => ({
|
||||
copyForkResourceContainers: mockCopyForkResourceContainers,
|
||||
planForkMappedKbDocumentCopies: mockPlanForkMappedKbDocumentCopies,
|
||||
copyForkResourceContent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({
|
||||
planForkFileCopies: mockPlanForkFileCopies,
|
||||
executeForkFileBlobCopies: vi.fn(),
|
||||
}))
|
||||
|
||||
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import type { ForkMappingUpsert } from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import {
|
||||
augmentForkResolver,
|
||||
buildPromoteCopySelection,
|
||||
copyPromoteUnmappedResources,
|
||||
FORK_COPYABLE_KIND_TO_SELECTION_KEY,
|
||||
hasPromoteCopySelection,
|
||||
persistPromoteCopiedMappings,
|
||||
} from '@/ee/workspace-forking/lib/promote/copy-unmapped'
|
||||
import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan'
|
||||
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
const candidates: ForkCopyableUnmapped[] = [
|
||||
{
|
||||
kind: 'knowledge-base',
|
||||
sourceId: 'kb-1',
|
||||
label: 'KB One',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
},
|
||||
{
|
||||
kind: 'table',
|
||||
sourceId: 'tbl-1',
|
||||
label: 'Table One',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
},
|
||||
{
|
||||
kind: 'custom-tool',
|
||||
sourceId: 'ct-1',
|
||||
label: 'Tool One',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
},
|
||||
{
|
||||
kind: 'skill',
|
||||
sourceId: 'sk-1',
|
||||
label: 'Skill One',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
},
|
||||
{
|
||||
kind: 'file',
|
||||
sourceId: 'workspace/SRC/a.png',
|
||||
label: 'a.png',
|
||||
parentId: 'fld-1',
|
||||
parentLabel: 'Images',
|
||||
referenced: true,
|
||||
},
|
||||
// An UNREFERENCED candidate (new in the source, used by no synced workflow): selectable for
|
||||
// copy exactly like a referenced one - the server treats the two identically.
|
||||
{
|
||||
kind: 'table',
|
||||
sourceId: 'tbl-unref',
|
||||
label: 'Scratch table',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: false,
|
||||
},
|
||||
]
|
||||
|
||||
describe('buildPromoteCopySelection', () => {
|
||||
it('groups requested ids into the selection by kind and records willResolve keys', () => {
|
||||
const { selection, willResolve } = buildPromoteCopySelection(
|
||||
{ knowledgeBases: ['kb-1'], tables: ['tbl-1'], customTools: ['ct-1'], skills: ['sk-1'] },
|
||||
candidates
|
||||
)
|
||||
expect(selection.knowledgeBases).toEqual(['kb-1'])
|
||||
expect(selection.tables).toEqual(['tbl-1'])
|
||||
expect(selection.customTools).toEqual(['ct-1'])
|
||||
expect(selection.skills).toEqual(['sk-1'])
|
||||
expect(willResolve.has('knowledge-base:kb-1')).toBe(true)
|
||||
expect(willResolve.has('skill:sk-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a requested id that is not an actual copy candidate (security)', () => {
|
||||
const { selection, willResolve } = buildPromoteCopySelection(
|
||||
{ knowledgeBases: ['kb-1', 'kb-not-a-candidate'] },
|
||||
candidates
|
||||
)
|
||||
expect(selection.knowledgeBases).toEqual(['kb-1'])
|
||||
expect(willResolve.has('knowledge-base:kb-not-a-candidate')).toBe(false)
|
||||
})
|
||||
|
||||
it('groups requested file storage keys (security: only actual candidates)', () => {
|
||||
const { selection, willResolve } = buildPromoteCopySelection(
|
||||
{ files: ['workspace/SRC/a.png', 'workspace/SRC/not-referenced.png'] },
|
||||
candidates
|
||||
)
|
||||
expect(selection.files).toEqual(['workspace/SRC/a.png'])
|
||||
expect(willResolve.has('file:workspace/SRC/a.png')).toBe(true)
|
||||
expect(willResolve.has('file:workspace/SRC/not-referenced.png')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns an empty selection when nothing is requested', () => {
|
||||
const { selection, willResolve } = buildPromoteCopySelection(undefined, candidates)
|
||||
expect(hasPromoteCopySelection(selection)).toBe(false)
|
||||
expect(willResolve.size).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts an UNREFERENCED candidate exactly like a referenced one', () => {
|
||||
// The client keeps unreferenced candidates default-unselected, but once the user opts in the
|
||||
// server validates + copies them through the same path. Its willResolve key matches no
|
||||
// unmapped reference (nothing references it), so the pre-copy gate is unaffected.
|
||||
const { selection, willResolve } = buildPromoteCopySelection(
|
||||
{ tables: ['tbl-unref'] },
|
||||
candidates
|
||||
)
|
||||
expect(selection.tables).toEqual(['tbl-unref'])
|
||||
expect(willResolve.has('table:tbl-unref')).toBe(true)
|
||||
})
|
||||
|
||||
it('copy-vs-map: maps win - a mapped resource is absent from the candidates, so a copy request for it is dropped', () => {
|
||||
// Reconciliation precedence at the server boundary: a resource the user mapped resolves to a
|
||||
// target, so the plan never lists it in `copyableUnmapped`. Even if a (stale) client still
|
||||
// requests it for copy, only the genuinely-unmapped candidates survive - the map wins.
|
||||
const onlyTableUnmapped: ForkCopyableUnmapped[] = [
|
||||
{
|
||||
kind: 'table',
|
||||
sourceId: 'tbl-1',
|
||||
label: 'Table One',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
},
|
||||
]
|
||||
const { selection, willResolve } = buildPromoteCopySelection(
|
||||
// kb-1 + the file were mapped (so absent from candidates); only the table remains copyable.
|
||||
{
|
||||
knowledgeBases: ['kb-1'],
|
||||
tables: ['tbl-1'],
|
||||
files: ['workspace/SRC/a.png'],
|
||||
},
|
||||
onlyTableUnmapped
|
||||
)
|
||||
expect(selection.knowledgeBases).toEqual([])
|
||||
expect(selection.files).toEqual([])
|
||||
expect(selection.tables).toEqual(['tbl-1'])
|
||||
expect(willResolve.has('knowledge-base:kb-1')).toBe(false)
|
||||
expect(willResolve.has('file:workspace/SRC/a.png')).toBe(false)
|
||||
expect(willResolve.has('table:tbl-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasPromoteCopySelection', () => {
|
||||
it('is true only when at least one copyable kind has ids', () => {
|
||||
expect(
|
||||
hasPromoteCopySelection({
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: [],
|
||||
knowledgeBases: ['kb-1'],
|
||||
files: [],
|
||||
mcpServers: [],
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
hasPromoteCopySelection({
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
files: [],
|
||||
mcpServers: [],
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasPromoteCopySelection({
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
files: ['workspace/SRC/file.png'],
|
||||
mcpServers: [],
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('augmentForkResolver', () => {
|
||||
it('resolves a just-copied resource via the extra map, else falls through to the base', () => {
|
||||
const base = (kind: ForkRemapKind, id: string) =>
|
||||
kind === 'credential' && id === 'cred-src' ? 'cred-dst' : null
|
||||
const extra = new Map<ForkRemapKind, Map<string, string>>([
|
||||
['knowledge-base', new Map([['kb-src', 'kb-dst']])],
|
||||
])
|
||||
const resolver = augmentForkResolver(base, extra)
|
||||
expect(resolver('knowledge-base', 'kb-src')).toBe('kb-dst')
|
||||
expect(resolver('credential', 'cred-src')).toBe('cred-dst')
|
||||
expect(resolver('table', 'tbl-x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistPromoteCopiedMappings', () => {
|
||||
const tx = {} as DbOrTx
|
||||
const entry: ForkMappingUpsert = {
|
||||
resourceType: 'knowledge_base',
|
||||
parentResourceId: 'src-kb',
|
||||
childResourceId: 'dst-kb',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('pull keeps the source(parent)->target(child) orientation as-is', async () => {
|
||||
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'pull', [entry])
|
||||
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [entry])
|
||||
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('push swaps to target(parent)->source(child) and deletes the prior row keyed on the source child', async () => {
|
||||
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [entry])
|
||||
// Delete keys on the source child resource (the swapped child id = the original parent id).
|
||||
expect(mockDeleteEdgeMappingsByChildResources).toHaveBeenCalledWith(tx, 'edge-child', [
|
||||
{ resourceType: 'knowledge_base', childResourceId: 'src-kb' },
|
||||
])
|
||||
// The swap flips parent/child: the new copy (dst) becomes the parent side on push.
|
||||
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
|
||||
{ resourceType: 'knowledge_base', parentResourceId: 'dst-kb', childResourceId: 'src-kb' },
|
||||
])
|
||||
})
|
||||
|
||||
it('push skips an entry with a null child id (the narrowing guard, no bogus mapping)', async () => {
|
||||
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [
|
||||
{ resourceType: 'knowledge_base', parentResourceId: 'src-kb', childResourceId: null },
|
||||
])
|
||||
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
|
||||
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns without writing when there are no entries', async () => {
|
||||
await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [])
|
||||
expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled()
|
||||
expect(mockUpsertEdgeMappings).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
|
||||
const tx = {} as DbOrTx
|
||||
// Only edge.childWorkspaceId is read by the copy path.
|
||||
const edge = { childWorkspaceId: 'edge-child' } as unknown as ForkEdge
|
||||
// The promote-built persisted-pair resolver; the copy must forward it verbatim so copied
|
||||
// tables' workflow-group outputs land on the same block ids the workflow writes assign.
|
||||
const resolveBlockId = (workflowId: string, blockId: string) => `${workflowId}:${blockId}`
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCopyForkResourceContainers.mockResolvedValue({
|
||||
idMap: new Map(),
|
||||
mappingEntries: [],
|
||||
contentPlan: {
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'target-ws',
|
||||
userId: 'user-1',
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
skills: [],
|
||||
documents: [],
|
||||
},
|
||||
names: {
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
},
|
||||
})
|
||||
mockPlanForkMappedKbDocumentCopies.mockResolvedValue({
|
||||
documents: [],
|
||||
docIdMap: new Map(),
|
||||
mappingEntries: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('copies selected files (keyMap + blobTasks), persists the file mapping, and threads file + folder content-ref maps', async () => {
|
||||
mockPlanForkFileCopies.mockResolvedValue({
|
||||
keyMap: new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]),
|
||||
idMap: new Map([['file-src', 'file-dst']]),
|
||||
blobTasks: [
|
||||
{
|
||||
sourceKey: 'workspace/SRC/a.png',
|
||||
targetKey: 'workspace/DST/a.png',
|
||||
context: 'workspace',
|
||||
fileName: 'a.png',
|
||||
contentType: 'image/png',
|
||||
userId: 'user-1',
|
||||
workspaceId: 'target-ws',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await copyPromoteUnmappedResources({
|
||||
tx,
|
||||
edge,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
targetWorkspaceId: 'target-ws',
|
||||
direction: 'pull',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: {
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
files: ['workspace/SRC/a.png'],
|
||||
},
|
||||
workflowIdMap: new Map(),
|
||||
folderIdMap: new Map([['fld-src', 'fld-dst']]),
|
||||
resolver: () => null,
|
||||
resolveBlockId,
|
||||
referencedDocumentIds: [],
|
||||
})
|
||||
|
||||
// planForkFileCopies is invoked by storage key (the sync references key files by key).
|
||||
expect(mockPlanForkFileCopies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileKeys: ['workspace/SRC/a.png'] })
|
||||
)
|
||||
// blobTasks bubble up for the post-commit blob duplication.
|
||||
expect(result.blobTasks).toHaveLength(1)
|
||||
// The copied file resolves by storage key for the subblock remap.
|
||||
expect(result.copyIdMapByKind.get('file')).toEqual(
|
||||
new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']])
|
||||
)
|
||||
// The file mapping is persisted (pull keeps source(parent)->target(child) orientation) so a
|
||||
// re-sync resolves the copy instead of re-copying it.
|
||||
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
|
||||
{
|
||||
resourceType: 'file',
|
||||
parentResourceId: 'workspace/SRC/a.png',
|
||||
childResourceId: 'workspace/DST/a.png',
|
||||
},
|
||||
])
|
||||
// The folder map AND the file key/id maps reach the in-content rewriter.
|
||||
expect(result.contentRefMaps.folders).toEqual({ 'fld-src': 'fld-dst' })
|
||||
expect(result.contentRefMaps.fileKeys).toEqual({ 'workspace/SRC/a.png': 'workspace/DST/a.png' })
|
||||
expect(result.contentRefMaps.fileIds).toEqual({ 'file-src': 'file-dst' })
|
||||
})
|
||||
|
||||
it('persists container mapping entries for copied resources (idempotency for unreferenced copies)', async () => {
|
||||
// An UNREFERENCED table selected for copy flows through the same container pipeline; its
|
||||
// mapping row is what makes the next sync resolve the copy instead of re-offering it.
|
||||
mockCopyForkResourceContainers.mockResolvedValue({
|
||||
idMap: new Map([['table', new Map([['tbl-unref', 'tbl-copy']])]]),
|
||||
mappingEntries: [
|
||||
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
|
||||
],
|
||||
contentPlan: {
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'target-ws',
|
||||
userId: 'user-1',
|
||||
tables: [{ sourceId: 'tbl-unref', childId: 'tbl-copy' }],
|
||||
knowledgeBases: [],
|
||||
skills: [],
|
||||
documents: [],
|
||||
},
|
||||
names: {
|
||||
tables: ['Scratch table'],
|
||||
knowledgeBases: [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
workflowMcpServers: [],
|
||||
},
|
||||
})
|
||||
mockPlanForkFileCopies.mockResolvedValue({
|
||||
keyMap: new Map<string, string>(),
|
||||
idMap: new Map<string, string>(),
|
||||
blobTasks: [],
|
||||
})
|
||||
|
||||
await copyPromoteUnmappedResources({
|
||||
tx,
|
||||
edge,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
targetWorkspaceId: 'target-ws',
|
||||
direction: 'pull',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: {
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: ['tbl-unref'],
|
||||
knowledgeBases: [],
|
||||
files: [],
|
||||
},
|
||||
workflowIdMap: new Map(),
|
||||
folderIdMap: new Map(),
|
||||
resolver: () => null,
|
||||
resolveBlockId,
|
||||
referencedDocumentIds: [],
|
||||
})
|
||||
|
||||
expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [
|
||||
{ resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' },
|
||||
])
|
||||
})
|
||||
|
||||
it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => {
|
||||
await copyPromoteUnmappedResources({
|
||||
tx,
|
||||
edge,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
targetWorkspaceId: 'target-ws',
|
||||
direction: 'pull',
|
||||
userId: 'user-1',
|
||||
now: new Date(),
|
||||
selection: {
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: [],
|
||||
knowledgeBases: ['kb-1'],
|
||||
files: [],
|
||||
mcpServers: [],
|
||||
},
|
||||
workflowIdMap: new Map(),
|
||||
folderIdMap: new Map(),
|
||||
resolver: () => null,
|
||||
resolveBlockId,
|
||||
// The doc ids come straight from the promote plan's references; the copy must forward them,
|
||||
// not re-scan every source workflow state inside the locked tx.
|
||||
referencedDocumentIds: ['doc-1', 'doc-2'],
|
||||
})
|
||||
|
||||
expect(mockCopyForkResourceContainers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referencedDocumentIds: ['doc-1', 'doc-2'],
|
||||
// Workflow-publishing MCP servers are fork-create-only; a sync always passes the
|
||||
// shared pipeline's slot empty. External MCP servers flow through the selection.
|
||||
selection: expect.objectContaining({ mcpServers: [], workflowMcpServers: [] }),
|
||||
// The promote-built block-id resolver reaches the table remap unchanged, so copied
|
||||
// tables' workflow-group outputs use the persisted-pair ids, not the derive.
|
||||
resolveBlockId,
|
||||
})
|
||||
)
|
||||
expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fork copyable kind drift', () => {
|
||||
it('FORK_COPYABLE_KIND_TO_SELECTION_KEY covers exactly the contract copyable kinds', () => {
|
||||
expect(Object.keys(FORK_COPYABLE_KIND_TO_SELECTION_KEY).sort()).toEqual(
|
||||
[...forkCopyableKindSchema.options].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('isForkCopyableKind matches the contract copyable kinds and excludes the rest', () => {
|
||||
for (const kind of forkCopyableKindSchema.options) {
|
||||
expect(isForkCopyableKind(kind)).toBe(true)
|
||||
}
|
||||
const nonCopyable: ForkRemapKind[] = ['credential', 'env-var', 'knowledge-document']
|
||||
for (const kind of nonCopyable) {
|
||||
expect(isForkCopyableKind(kind)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,356 @@
|
||||
import type {
|
||||
ForkCopyableKind,
|
||||
ForkCopyableUnmapped,
|
||||
PromoteCopyResources,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
type SerializableForkContentRefMaps,
|
||||
serializeContentRefMaps,
|
||||
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
|
||||
import { type BlobCopyTask, planForkFileCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
|
||||
import type { ForkContentPlan } from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import {
|
||||
copyForkResourceContainers,
|
||||
planForkMappedKbDocumentCopies,
|
||||
} from '@/ee/workspace-forking/lib/copy/copy-resources'
|
||||
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import {
|
||||
deleteEdgeMappingsByChildResources,
|
||||
type ForkMappingUpsert,
|
||||
resourceTypeToForkKind,
|
||||
upsertEdgeMappings,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import type {
|
||||
ForkReferenceResolver,
|
||||
ForkRemapKind,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
/**
|
||||
* The source ids selected for copy at promote, validated against the plan's copyable
|
||||
* candidates. Exactly the sync-copyable kinds (`forkCopyableKindSchema`): workflow-publishing
|
||||
* MCP servers are fork-create-only (never a promote copy candidate), so they have no slot here.
|
||||
*/
|
||||
export interface PromoteCopySelection {
|
||||
customTools: string[]
|
||||
skills: string[]
|
||||
tables: string[]
|
||||
knowledgeBases: string[]
|
||||
/** Workspace files to copy, identified by storage key (not `workspace_files.id`). */
|
||||
files: string[]
|
||||
/** External MCP servers to copy as config rows (OAuth tokens never copied - re-auth). */
|
||||
mcpServers: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Each copyable kind to its key in {@link PromoteCopySelection}. Keyed on `ForkCopyableKind`
|
||||
* (the wire contract enum) so TS fails to compile if the copyable enum grows a kind without a
|
||||
* selection key here, keeping the two in lockstep.
|
||||
*/
|
||||
export const FORK_COPYABLE_KIND_TO_SELECTION_KEY: Record<
|
||||
ForkCopyableKind,
|
||||
keyof PromoteCopySelection
|
||||
> = {
|
||||
'knowledge-base': 'knowledgeBases',
|
||||
table: 'tables',
|
||||
'custom-tool': 'customTools',
|
||||
skill: 'skills',
|
||||
file: 'files',
|
||||
'mcp-server': 'mcpServers',
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersect the user's requested copy with the plan's actual copyable candidates (referenced or
|
||||
* not, always unmapped + still existing in the source), so a crafted request can never copy an
|
||||
* arbitrary resource. Returns the validated selection plus the set of `${kind}:${sourceId}`
|
||||
* references the copy will resolve, for the pre-copy sync gate - an unreferenced candidate's key
|
||||
* simply matches no reference there, which is harmless.
|
||||
*/
|
||||
export function buildPromoteCopySelection(
|
||||
requested: PromoteCopyResources | undefined,
|
||||
copyableUnmapped: ForkCopyableUnmapped[]
|
||||
): { selection: PromoteCopySelection; willResolve: Set<string> } {
|
||||
const allowed = new Map<string, Set<string>>()
|
||||
for (const candidate of copyableUnmapped) {
|
||||
const set = allowed.get(candidate.kind)
|
||||
if (set) set.add(candidate.sourceId)
|
||||
else allowed.set(candidate.kind, new Set([candidate.sourceId]))
|
||||
}
|
||||
const selection: PromoteCopySelection = {
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
files: [],
|
||||
mcpServers: [],
|
||||
}
|
||||
const willResolve = new Set<string>()
|
||||
const apply = (
|
||||
kind: keyof typeof FORK_COPYABLE_KIND_TO_SELECTION_KEY,
|
||||
ids: string[] | undefined
|
||||
) => {
|
||||
const allowedIds = allowed.get(kind)
|
||||
if (!allowedIds || !ids) return
|
||||
const key = FORK_COPYABLE_KIND_TO_SELECTION_KEY[kind]
|
||||
for (const id of ids) {
|
||||
if (!allowedIds.has(id)) continue
|
||||
selection[key].push(id)
|
||||
willResolve.add(`${kind}:${id}`)
|
||||
}
|
||||
}
|
||||
apply('knowledge-base', requested?.knowledgeBases)
|
||||
apply('table', requested?.tables)
|
||||
apply('custom-tool', requested?.customTools)
|
||||
apply('skill', requested?.skills)
|
||||
apply('file', requested?.files)
|
||||
apply('mcp-server', requested?.mcpServers)
|
||||
return { selection, willResolve }
|
||||
}
|
||||
|
||||
/** Whether any resource is selected for copy. */
|
||||
export function hasPromoteCopySelection(selection: PromoteCopySelection): boolean {
|
||||
return (
|
||||
selection.customTools.length > 0 ||
|
||||
selection.skills.length > 0 ||
|
||||
selection.tables.length > 0 ||
|
||||
selection.knowledgeBases.length > 0 ||
|
||||
selection.files.length > 0 ||
|
||||
selection.mcpServers.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer the just-copied resources' source->target ids on top of the plan's resolver, so the
|
||||
* synced workflows' references to those resources resolve to the new copies. The base resolver
|
||||
* (persisted mappings + env identity) is consulted for everything else.
|
||||
*/
|
||||
export function augmentForkResolver(
|
||||
base: ForkReferenceResolver,
|
||||
extra: Map<ForkRemapKind, Map<string, string>>
|
||||
): ForkReferenceResolver {
|
||||
return (kind, sourceId) => extra.get(kind)?.get(sourceId) ?? base(kind, sourceId)
|
||||
}
|
||||
|
||||
export interface PromoteCopyResult {
|
||||
contentPlan: ForkContentPlan
|
||||
/** Copied source resource id -> new target id, by remap kind, for resolver augmentation. */
|
||||
copyIdMapByKind: Map<ForkRemapKind, Map<string, string>>
|
||||
/**
|
||||
* Serialized in-content reference maps for the post-commit copy to rewrite copied skill bodies
|
||||
* (their `sim:` links + embedded URLs) off the locked promote tx - carried through the durable
|
||||
* content-copy payload, mirroring fork.
|
||||
*/
|
||||
contentRefMaps: SerializableForkContentRefMaps
|
||||
/**
|
||||
* File blob duplications for copied workspace files, run post-commit by the durable content-copy
|
||||
* runner (no object-storage I/O inside the locked promote tx). Empty when no files were copied.
|
||||
*/
|
||||
blobTasks: BlobCopyTask[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the selected unmapped resources (referenced or not) a sync brings into the target (reusing
|
||||
* the fork copy pipeline), then persist the source<->target id map in the direction the edge expects: a pull
|
||||
* fills the existing `(parent, child=null)` row (fill-null), a push replaces any prior
|
||||
* `(parent, child)` row keyed on the source child resource (delete-then-insert). This covers:
|
||||
* - the user-selected copyable containers (KB / table / custom-tool / skill) and workspace files,
|
||||
* - documents referenced under a copied knowledge base (auto-placed under that copied KB),
|
||||
* - documents referenced under an ALREADY-mapped (existing) KB - copied into that existing KB so
|
||||
* the `document-selector` reference remaps instead of being cleared.
|
||||
*
|
||||
* The heavy content (table rows, KB documents + embeddings, file blobs) is returned as a content
|
||||
* plan + blob tasks for a post-commit, best-effort fill; no object-storage I/O runs inside the
|
||||
* locked promote tx. Always safe to call (a no-op when nothing is selected and nothing references
|
||||
* a mapped-KB document).
|
||||
*/
|
||||
export async function copyPromoteUnmappedResources(params: {
|
||||
tx: DbOrTx
|
||||
edge: ForkEdge
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
userId: string
|
||||
now: Date
|
||||
selection: PromoteCopySelection
|
||||
workflowIdMap: Map<string, string>
|
||||
/** source folder id -> target folder id, so copied skill/markdown bodies rewrite `sim:folder/<id>`. */
|
||||
folderIdMap: Map<string, string>
|
||||
/** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */
|
||||
resolver: ForkReferenceResolver
|
||||
/**
|
||||
* The SAME block-id resolver the sync's workflow writes use (persisted pairs preferred over
|
||||
* derive), so copied tables' workflow-group `outputs[].blockId` point at the blocks the sync
|
||||
* actually writes - on push the parent keeps its ORIGINAL block ids, never the derive.
|
||||
*/
|
||||
resolveBlockId: ForkBlockIdResolver
|
||||
/**
|
||||
* Knowledge-document ids the synced workflows reference, already scanned once in the promote
|
||||
* plan and threaded in so the copy doesn't re-scan every source state inside the locked tx.
|
||||
* `copyForkResourceContainers` / `planForkMappedKbDocumentCopies` place only those whose parent
|
||||
* KB is in this copy (or already mapped), so an extra id is FK-safe and simply skipped.
|
||||
*/
|
||||
referencedDocumentIds: string[]
|
||||
}): Promise<PromoteCopyResult> {
|
||||
const {
|
||||
tx,
|
||||
edge,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
direction,
|
||||
userId,
|
||||
now,
|
||||
selection,
|
||||
workflowIdMap,
|
||||
folderIdMap,
|
||||
resolver,
|
||||
resolveBlockId,
|
||||
referencedDocumentIds,
|
||||
} = params
|
||||
|
||||
const result = await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId,
|
||||
childWorkspaceId: targetWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
selection: {
|
||||
customTools: selection.customTools,
|
||||
skills: selection.skills,
|
||||
// External MCP servers copy as config rows (like fork); workflow-publishing MCP servers
|
||||
// are fork-create-only shells (the shared pipeline still takes the slot - pass it empty).
|
||||
mcpServers: selection.mcpServers,
|
||||
workflowMcpServers: [],
|
||||
tables: selection.tables,
|
||||
knowledgeBases: selection.knowledgeBases,
|
||||
},
|
||||
workflowIdMap,
|
||||
referencedDocumentIds,
|
||||
// A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs
|
||||
// rewritten through the same plan resolver that remaps subblock-value env refs.
|
||||
resolveEnvName: (key) => resolver('env-var', key),
|
||||
resolveBlockId,
|
||||
})
|
||||
|
||||
// Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob
|
||||
// duplications deferred to the post-commit runner.
|
||||
const fileResult =
|
||||
selection.files.length > 0
|
||||
? await planForkFileCopies({
|
||||
tx,
|
||||
sourceWorkspaceId,
|
||||
childWorkspaceId: targetWorkspaceId,
|
||||
userId,
|
||||
fileKeys: selection.files,
|
||||
now,
|
||||
})
|
||||
: {
|
||||
keyMap: new Map<string, string>(),
|
||||
idMap: new Map<string, string>(),
|
||||
blobTasks: [] as BlobCopyTask[],
|
||||
}
|
||||
|
||||
// U-docs: documents referenced under an already-mapped (not copied this sync) KB. Skip any doc
|
||||
// already placed under a copied KB above (its parent KB is in this copy), so a doc is never
|
||||
// copied twice.
|
||||
const containerDocMap = result.idMap.get('knowledge_document') ?? new Map<string, string>()
|
||||
const mappedKbDocs = await planForkMappedKbDocumentCopies({
|
||||
tx,
|
||||
resolver,
|
||||
referencedDocumentIds,
|
||||
alreadyCopiedSourceDocIds: new Set(containerDocMap.keys()),
|
||||
})
|
||||
result.contentPlan.documents.push(...mappedKbDocs.documents)
|
||||
|
||||
// Persist every copied resource's mapping (containers + files + U-docs) so a re-sync resolves
|
||||
// the copy instead of re-copying it. Files map by storage key; U-docs add knowledge_document rows.
|
||||
const fileMappingEntries: ForkMappingUpsert[] = Array.from(
|
||||
fileResult.keyMap,
|
||||
([source, child]) => ({
|
||||
resourceType: 'file' as const,
|
||||
parentResourceId: source,
|
||||
childResourceId: child,
|
||||
})
|
||||
)
|
||||
await persistPromoteCopiedMappings(tx, edge.childWorkspaceId, userId, direction, [
|
||||
...result.mappingEntries,
|
||||
...fileMappingEntries,
|
||||
...mappedKbDocs.mappingEntries,
|
||||
])
|
||||
|
||||
const copyIdMapByKind = new Map<ForkRemapKind, Map<string, string>>()
|
||||
for (const [resourceType, sourceToTarget] of result.idMap) {
|
||||
const kind = resourceTypeToForkKind(resourceType)
|
||||
if (!kind) continue
|
||||
copyIdMapByKind.set(kind, sourceToTarget)
|
||||
}
|
||||
if (fileResult.keyMap.size > 0) copyIdMapByKind.set('file', fileResult.keyMap)
|
||||
|
||||
// Merge the container's copied-KB document map with the U-docs map so every copied document
|
||||
// (under a copied KB or into an existing one) remaps its `document-selector` reference.
|
||||
const documentIdMap = new Map<string, string>([...containerDocMap, ...mappedKbDocs.docIdMap])
|
||||
if (documentIdMap.size > 0) copyIdMapByKind.set('knowledge-document', documentIdMap)
|
||||
|
||||
// Serialized maps for the post-commit content rewrite (run off the locked promote tx). Mirrors
|
||||
// fork: workspace + workflow + folder ids plus this copy's own file/skill/table/KB maps, so a
|
||||
// copied skill body / markdown blob's `sim:` links + embedded file URLs resolve to the new target
|
||||
// copies instead of the source.
|
||||
const contentRefMaps = serializeContentRefMaps({
|
||||
workspaceId: { from: sourceWorkspaceId, to: targetWorkspaceId },
|
||||
workflows: workflowIdMap,
|
||||
folders: folderIdMap,
|
||||
fileKeys: fileResult.keyMap,
|
||||
fileIds: fileResult.idMap,
|
||||
skills: result.idMap.get('skill'),
|
||||
tables: result.idMap.get('table'),
|
||||
knowledgeBases: result.idMap.get('knowledge_base'),
|
||||
})
|
||||
|
||||
return {
|
||||
contentPlan: result.contentPlan,
|
||||
copyIdMapByKind,
|
||||
contentRefMaps,
|
||||
blobTasks: fileResult.blobTasks,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the copied resources' id mappings for the edge. The copy returns entries oriented
|
||||
* source(parent)->target(child); a pull matches that orientation directly (fill-null upsert), a
|
||||
* push swaps it (the parent side is the new TARGET) and first drops any prior row keyed on the
|
||||
* source child resource so a changed target can't leak a second mapping.
|
||||
*/
|
||||
export async function persistPromoteCopiedMappings(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
userId: string,
|
||||
direction: 'push' | 'pull',
|
||||
entries: ForkMappingUpsert[]
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return
|
||||
if (direction === 'pull') {
|
||||
await upsertEdgeMappings(tx, childWorkspaceId, userId, entries)
|
||||
return
|
||||
}
|
||||
// Push: re-key on the source child resource. Skip any entry with a null child id (copy entries
|
||||
// always carry one; the guard narrows the type so neither the swap nor the delete needs a cast).
|
||||
// After the swap every childResourceId is the original (non-null) parent id, keyed for the
|
||||
// delete-then-insert that prevents a changed target from leaking a second mapping.
|
||||
const swapped: ForkMappingUpsert[] = []
|
||||
const deleteKeys: Array<{
|
||||
resourceType: ForkMappingUpsert['resourceType']
|
||||
childResourceId: string
|
||||
}> = []
|
||||
for (const entry of entries) {
|
||||
if (entry.childResourceId == null) continue
|
||||
swapped.push({
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.childResourceId,
|
||||
childResourceId: entry.parentResourceId,
|
||||
})
|
||||
deleteKeys.push({ resourceType: entry.resourceType, childResourceId: entry.parentResourceId })
|
||||
}
|
||||
if (swapped.length === 0) return
|
||||
await deleteEdgeMappingsByChildResources(tx, childWorkspaceId, deleteKeys)
|
||||
await upsertEdgeMappings(tx, childWorkspaceId, userId, swapped)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ForkCopyableLabel,
|
||||
ForkCopyableSourceResource,
|
||||
} from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
import {
|
||||
assembleForkCopyableUnmapped,
|
||||
buildPromoteWorkflowIdMap,
|
||||
collectForkCopyableIdsByKind,
|
||||
collectForkUnreferencedCopyables,
|
||||
} from '@/ee/workspace-forking/lib/promote/promote-plan'
|
||||
import type { ForkReference } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
const ref = (kind: ForkReference['kind'], sourceId: string): ForkReference => ({
|
||||
kind,
|
||||
sourceId,
|
||||
subBlockKey: 'sb',
|
||||
required: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* `buildPromoteWorkflowIdMap` decides which cross-workflow references survive a
|
||||
* promote: the resulting map is handed to `remapWorkflowReferencesInSubBlocks`,
|
||||
* where a hit repoints the reference and a miss (with `clearUnmapped`) blanks it.
|
||||
* These cases lock in the seed/overlay matrix so the "mapped sibling not in this
|
||||
* push" repoint and the "deleted / archived / never-mapped" clears can't drift.
|
||||
*/
|
||||
describe('buildPromoteWorkflowIdMap', () => {
|
||||
it("overlays this push's items (replace + create)", () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map(),
|
||||
existingSourceIds: new Set(),
|
||||
targetActiveIds: new Set(),
|
||||
items: [
|
||||
{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' },
|
||||
{ sourceWorkflowId: 'b-src', targetWorkflowId: 'b-new' },
|
||||
],
|
||||
})
|
||||
expect(map.get('a-src')).toBe('a-tgt')
|
||||
expect(map.get('b-src')).toBe('b-new')
|
||||
expect(map.size).toBe(2)
|
||||
})
|
||||
|
||||
it('repoints a mapped sibling that is not in this push when source exists and target is active', () => {
|
||||
// B is mapped + still deployed in the target but undeployed in the source, so it
|
||||
// is not an item this push. A references B and must keep pointing at target-B.
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['b-src', 'b-tgt']]),
|
||||
existingSourceIds: new Set(['b-src']),
|
||||
targetActiveIds: new Set(['b-tgt']),
|
||||
items: [{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' }],
|
||||
})
|
||||
expect(map.get('b-src')).toBe('b-tgt')
|
||||
expect(map.get('a-src')).toBe('a-tgt')
|
||||
})
|
||||
|
||||
it('does not seed a mapped pair whose source was deleted (reference clears)', () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['b-src', 'b-tgt']]),
|
||||
existingSourceIds: new Set(), // b-src deleted in the source
|
||||
targetActiveIds: new Set(['b-tgt']),
|
||||
items: [],
|
||||
})
|
||||
expect(map.has('b-src')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not seed a mapped pair whose target was archived (reference clears)', () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['b-src', 'b-tgt']]),
|
||||
existingSourceIds: new Set(['b-src']),
|
||||
targetActiveIds: new Set(), // b-tgt archived by a prior push
|
||||
items: [],
|
||||
})
|
||||
expect(map.has('b-src')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not map a workflow that was never mapped (reference clears)', () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['b-src', 'b-tgt']]),
|
||||
existingSourceIds: new Set(['b-src', 'c-src']),
|
||||
targetActiveIds: new Set(['b-tgt']),
|
||||
items: [],
|
||||
})
|
||||
expect(map.has('c-src')).toBe(false)
|
||||
})
|
||||
|
||||
it('lets this push override a stale identity mapping (re-created target wins)', () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['s', 't-old']]),
|
||||
existingSourceIds: new Set(['s']),
|
||||
targetActiveIds: new Set(['t-old']),
|
||||
items: [{ sourceWorkflowId: 's', targetWorkflowId: 't-new' }],
|
||||
})
|
||||
expect(map.get('s')).toBe('t-new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectForkCopyableIdsByKind', () => {
|
||||
it('groups copyable kinds and ignores non-copyable kinds (credential / env-var)', () => {
|
||||
const byKind = collectForkCopyableIdsByKind([
|
||||
ref('knowledge-base', 'kb-1'),
|
||||
ref('knowledge-base', 'kb-2'),
|
||||
ref('table', 'tbl-1'),
|
||||
ref('credential', 'cred-1'),
|
||||
ref('env-var', 'API_KEY'),
|
||||
ref('custom-tool', 'ct-1'),
|
||||
ref('skill', 'sk-1'),
|
||||
ref('file', 'fk-1'),
|
||||
])
|
||||
expect(byKind).toEqual({
|
||||
'knowledge-base': ['kb-1', 'kb-2'],
|
||||
table: ['tbl-1'],
|
||||
'custom-tool': ['ct-1'],
|
||||
skill: ['sk-1'],
|
||||
file: ['fk-1'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assembleForkCopyableUnmapped', () => {
|
||||
const flat = (label: string): ForkCopyableLabel => ({ label, parentId: null, parentLabel: null })
|
||||
|
||||
it('emits a candidate per copyable ref whose label resolved, carrying its labels', () => {
|
||||
const labels = new Map<string, ForkCopyableLabel>([
|
||||
['knowledge-base:kb-1', flat('Docs KB')],
|
||||
['file:fk-1', { label: 'a.png', parentId: 'fld-1', parentLabel: 'Folder' }],
|
||||
])
|
||||
const result = assembleForkCopyableUnmapped(
|
||||
[ref('knowledge-base', 'kb-1'), ref('file', 'fk-1'), ref('credential', 'cred-1')],
|
||||
labels
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
kind: 'knowledge-base',
|
||||
sourceId: 'kb-1',
|
||||
label: 'Docs KB',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
},
|
||||
{
|
||||
kind: 'file',
|
||||
sourceId: 'fk-1',
|
||||
label: 'a.png',
|
||||
parentId: 'fld-1',
|
||||
parentLabel: 'Folder',
|
||||
referenced: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a copyable ref whose label is missing (no longer exists in the source)', () => {
|
||||
expect(assembleForkCopyableUnmapped([ref('knowledge-base', 'kb-gone')], new Map())).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores non-copyable kinds entirely (credential / env-var)', () => {
|
||||
const result = assembleForkCopyableUnmapped(
|
||||
[ref('credential', 'cred-1'), ref('env-var', 'API_KEY')],
|
||||
new Map([['credential:cred-1', flat('X')]])
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectForkUnreferencedCopyables', () => {
|
||||
const source = (
|
||||
kind: ForkCopyableSourceResource['kind'],
|
||||
sourceId: string,
|
||||
label = sourceId
|
||||
): ForkCopyableSourceResource => ({ kind, sourceId, label, parentId: null, parentLabel: null })
|
||||
|
||||
const referencedCandidate = (kind: ForkCopyableSourceResource['kind'], sourceId: string) => ({
|
||||
kind,
|
||||
sourceId,
|
||||
label: sourceId,
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
})
|
||||
|
||||
it('emits an unmapped source resource no synced workflow references, flagged referenced: false', () => {
|
||||
const result = collectForkUnreferencedCopyables(
|
||||
[source('table', 'tbl-new', 'Scratch table')],
|
||||
[],
|
||||
() => null
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
kind: 'table',
|
||||
sourceId: 'tbl-new',
|
||||
label: 'Scratch table',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('dedupes against the referenced candidate set (a referenced resource is never double-listed)', () => {
|
||||
const result = collectForkUnreferencedCopyables(
|
||||
[source('knowledge-base', 'kb-1'), source('knowledge-base', 'kb-new')],
|
||||
[referencedCandidate('knowledge-base', 'kb-1')],
|
||||
() => null
|
||||
)
|
||||
expect(result.map((candidate) => candidate.sourceId)).toEqual(['kb-new'])
|
||||
})
|
||||
|
||||
it('excludes a resource with a persisted mapping (idempotency: a prior copy is never re-offered)', () => {
|
||||
// A resource copied by a prior sync resolves through its workspace_fork_resource_map row.
|
||||
const result = collectForkUnreferencedCopyables(
|
||||
[source('skill', 'sk-copied'), source('skill', 'sk-new')],
|
||||
[],
|
||||
(kind, sourceId) => (kind === 'skill' && sourceId === 'sk-copied' ? 'sk-target' : null)
|
||||
)
|
||||
expect(result.map((candidate) => candidate.sourceId)).toEqual(['sk-new'])
|
||||
})
|
||||
|
||||
it('does not confuse the same id across kinds when deduping or resolving', () => {
|
||||
const result = collectForkUnreferencedCopyables(
|
||||
[source('table', 'shared-id'), source('skill', 'shared-id')],
|
||||
[referencedCandidate('table', 'shared-id')],
|
||||
() => null
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ kind: 'skill', sourceId: 'shared-id', referenced: false })
|
||||
})
|
||||
|
||||
it('carries a file candidate keyed by storage key with its folder grouping', () => {
|
||||
const result = collectForkUnreferencedCopyables(
|
||||
[
|
||||
{
|
||||
kind: 'file',
|
||||
sourceId: 'workspace/SRC/new.png',
|
||||
label: 'new.png',
|
||||
parentId: 'fld-1',
|
||||
parentLabel: 'Images',
|
||||
},
|
||||
],
|
||||
[],
|
||||
() => null
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
kind: 'file',
|
||||
sourceId: 'workspace/SRC/new.png',
|
||||
label: 'new.png',
|
||||
parentId: 'fld-1',
|
||||
parentLabel: 'Images',
|
||||
referenced: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,399 @@
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { type ForkCopyableKind, forkCopyableKindSchema } from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type { DeployedWorkflowSummary } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
|
||||
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade'
|
||||
import {
|
||||
buildForkResolver,
|
||||
getEdgeMappingRows,
|
||||
resourceTypeToForkKind,
|
||||
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import {
|
||||
type ForkCopyableLabel,
|
||||
type ForkCopyableSourceResource,
|
||||
filterExistingForkTargets,
|
||||
getWorkspaceEnvKeys,
|
||||
listForkCopyableSourceResources,
|
||||
loadForkCopyableResourceLabels,
|
||||
} from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
|
||||
import {
|
||||
type ForkReference,
|
||||
type ForkReferenceResolver,
|
||||
type ForkRemapKind,
|
||||
scanWorkflowReferences,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export interface ForkPromotePlanItem {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
/** The matched target workflow's current name (for rename-aware mapping), null when creating. */
|
||||
targetName: string | null
|
||||
mode: 'create' | 'replace'
|
||||
sourceMeta: {
|
||||
name: string
|
||||
description: string | null
|
||||
folderId: string | null
|
||||
sortOrder: number
|
||||
/** Source's public-API flag, carried onto the written target (see copyWorkflowStateIntoTarget). */
|
||||
isPublicApi: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface ForkPromotePlan {
|
||||
childWorkspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
resolver: ForkReferenceResolver
|
||||
items: ForkPromotePlanItem[]
|
||||
workflowIdMap: Map<string, string>
|
||||
/** Previously-mapped target workflows whose source no longer exists (to remove). */
|
||||
archivedTargetIds: string[]
|
||||
/** Same as `archivedTargetIds`, with the target workflow name for the preview. */
|
||||
archivedTargets: Array<{ id: string; name: string }>
|
||||
|
||||
references: ForkReference[]
|
||||
unmappedRequired: ForkReference[]
|
||||
unmappedOptional: ForkReference[]
|
||||
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
|
||||
mcpReauthServerIds: string[]
|
||||
/** Review-only descriptions of inline secrets that cannot be id-mapped. */
|
||||
inlineSecretSources: string[]
|
||||
/**
|
||||
* Unmapped resources of copyable kinds that still exist in the source, so a sync can copy
|
||||
* them into the target instead of requiring a manual mapping (U15). `referenced: true`
|
||||
* entries are referenced by the synced workflows (default-selected in the modal - skipping
|
||||
* one clears its references); `referenced: false` entries are used by no synced workflow
|
||||
* (default-unselected - skipping one breaks nothing). Documents are auto-copied with their
|
||||
* parent KB and are not listed here. `parentId`/`parentLabel` carry a file's folder grouping
|
||||
* (null for non-file kinds and root files), for the nested picker.
|
||||
*/
|
||||
copyableUnmapped: Array<{
|
||||
kind: ForkCopyableKind
|
||||
sourceId: string
|
||||
label: string
|
||||
parentId: string | null
|
||||
parentLabel: string | null
|
||||
referenced: boolean
|
||||
}>
|
||||
willUpdate: number
|
||||
willCreate: number
|
||||
willArchive: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Copyable promote kinds, derived from the wire contract (`forkCopyableKindSchema`) so this
|
||||
* guard can never drift from the single source of truth: growing the schema automatically
|
||||
* grows the set. Typed as `ForkRemapKind` so `.has` accepts a broad scan-reference kind.
|
||||
*/
|
||||
const COPYABLE_PROMOTE_KINDS = new Set<ForkRemapKind>(forkCopyableKindSchema.options)
|
||||
|
||||
export function isForkCopyableKind(kind: ForkRemapKind): kind is ForkCopyableKind {
|
||||
return COPYABLE_PROMOTE_KINDS.has(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cross-workflow reference map used to rewrite `workflow-selector`,
|
||||
* `manualWorkflowId`, and `workflow_input` references inside promoted workflows.
|
||||
*
|
||||
* Seeded from the persistent identity mappings - not just the workflows in THIS
|
||||
* push - so a reference to a mapped sibling that isn't part of the current push
|
||||
* (e.g. a workflow undeployed in the source but still existing and already
|
||||
* deployed in the target) repoints at the existing target instead of clearing.
|
||||
* Only pairs whose source still EXISTS and whose target is still ACTIVE are
|
||||
* seeded: a deleted source (whose target is archived this push) stays unmapped so
|
||||
* its inbound references clear, and a target archived by a prior push is never
|
||||
* re-pointed at. The push's own items are overlaid last, so a created workflow
|
||||
* contributes its fresh target id and a replaced one re-sets the same id.
|
||||
*/
|
||||
export function buildPromoteWorkflowIdMap(params: {
|
||||
identityMap: Map<string, string>
|
||||
existingSourceIds: Set<string>
|
||||
targetActiveIds: Set<string>
|
||||
items: Array<{ sourceWorkflowId: string; targetWorkflowId: string }>
|
||||
}): Map<string, string> {
|
||||
const { identityMap, existingSourceIds, targetActiveIds, items } = params
|
||||
const workflowIdMap = new Map<string, string>()
|
||||
for (const [sourceId, targetId] of identityMap) {
|
||||
if (existingSourceIds.has(sourceId) && targetActiveIds.has(targetId)) {
|
||||
workflowIdMap.set(sourceId, targetId)
|
||||
}
|
||||
}
|
||||
for (const item of items) workflowIdMap.set(item.sourceWorkflowId, item.targetWorkflowId)
|
||||
return workflowIdMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the source ids of referenced-but-unmapped copyable resources, grouped by kind - the input
|
||||
* to the source-label lookup that builds {@link ForkPromotePlan.copyableUnmapped}. Pure.
|
||||
*/
|
||||
export function collectForkCopyableIdsByKind(
|
||||
unmappedReferences: ForkReference[]
|
||||
): Partial<Record<ForkCopyableKind, string[]>> {
|
||||
const byKind: Partial<Record<ForkCopyableKind, string[]>> = {}
|
||||
for (const reference of unmappedReferences) {
|
||||
if (!isForkCopyableKind(reference.kind)) continue
|
||||
;(byKind[reference.kind] ??= []).push(reference.sourceId)
|
||||
}
|
||||
return byKind
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the REFERENCED slice of {@link ForkPromotePlan.copyableUnmapped} from the unmapped
|
||||
* references and the loaded source labels: each copyable reference whose label resolved becomes a
|
||||
* copy candidate; one whose label is missing (the resource no longer exists in the source) is
|
||||
* dropped. Pure - split from the DB label load so it is unit-testable.
|
||||
*/
|
||||
export function assembleForkCopyableUnmapped(
|
||||
unmappedReferences: ForkReference[],
|
||||
copyableLabels: Map<string, ForkCopyableLabel>
|
||||
): ForkPromotePlan['copyableUnmapped'] {
|
||||
return unmappedReferences.flatMap((reference) => {
|
||||
if (!isForkCopyableKind(reference.kind)) return []
|
||||
const entry = copyableLabels.get(`${reference.kind}:${reference.sourceId}`)
|
||||
return entry
|
||||
? [
|
||||
{
|
||||
kind: reference.kind,
|
||||
sourceId: reference.sourceId,
|
||||
label: entry.label,
|
||||
parentId: entry.parentId,
|
||||
parentLabel: entry.parentLabel,
|
||||
referenced: true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the UNREFERENCED slice of {@link ForkPromotePlan.copyableUnmapped}: every copyable
|
||||
* resource in the source workspace that no synced workflow references (not in the referenced
|
||||
* candidate set) and that has no target mapping for this edge (the resolver returns null). A
|
||||
* previously-copied resource resolves through its persisted `workspace_fork_resource_map` row,
|
||||
* so a re-sync never re-offers it (idempotency). Pure - split from the DB source listing so it
|
||||
* is unit-testable.
|
||||
*/
|
||||
export function collectForkUnreferencedCopyables(
|
||||
sourceResources: ForkCopyableSourceResource[],
|
||||
referencedCopyables: ForkPromotePlan['copyableUnmapped'],
|
||||
resolver: ForkReferenceResolver
|
||||
): ForkPromotePlan['copyableUnmapped'] {
|
||||
const referencedKeys = new Set(
|
||||
referencedCopyables.map((candidate) => `${candidate.kind}:${candidate.sourceId}`)
|
||||
)
|
||||
return sourceResources.flatMap((resource) => {
|
||||
if (referencedKeys.has(`${resource.kind}:${resource.sourceId}`)) return []
|
||||
if (resolver(resource.kind, resource.sourceId) != null) return []
|
||||
return [{ ...resource, referenced: false }]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute everything a promote needs without mutating. Only the source's
|
||||
* **deployed** workflows participate; each plan item carries the source's active
|
||||
* deployed state. Targets matched by the persisted workflow identity map are
|
||||
* replaced; unmatched deployed sources create new targets. A target is archived
|
||||
* only when it was previously mapped and its source is no longer deployed -
|
||||
* target-native workflows are never touched. Shared by the diff preview and the
|
||||
* promote orchestrator.
|
||||
*/
|
||||
export async function computeForkPromotePlan(params: {
|
||||
executor: DbOrTx
|
||||
edge: ForkEdge
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
/**
|
||||
* Source deployed workflows + their states, read by the caller BEFORE its
|
||||
* transaction (see `loadSourceDeployedStates`) so the plan never checks out a
|
||||
* second pooled connection from inside a tx.
|
||||
*/
|
||||
deployedSourceWorkflows: DeployedWorkflowSummary[]
|
||||
sourceStates: Map<string, WorkflowState>
|
||||
}): Promise<ForkPromotePlan> {
|
||||
const {
|
||||
executor,
|
||||
edge,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
direction,
|
||||
deployedSourceWorkflows,
|
||||
sourceStates,
|
||||
} = params
|
||||
|
||||
const mappingRows = await getEdgeMappingRows(executor, edge.childWorkspaceId)
|
||||
const [targetEnvKeys, sourceEnvKeys] = await Promise.all([
|
||||
getWorkspaceEnvKeys(executor, targetWorkspaceId),
|
||||
getWorkspaceEnvKeys(executor, sourceWorkspaceId),
|
||||
])
|
||||
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
|
||||
|
||||
// Collect each mapping's chosen target id (per kind) and keep only those that still
|
||||
// exist in the target workspace, so a target deleted after the mapping was saved
|
||||
// resolves as unmapped instead of writing a dead id into the promoted workflow.
|
||||
const mappedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
for (const row of mappingRows) {
|
||||
const kind = resourceTypeToForkKind(row.resourceType)
|
||||
if (!kind) continue
|
||||
const targetId = sourceIsParent ? row.childResourceId : row.parentResourceId
|
||||
if (targetId == null) continue
|
||||
const set = mappedTargetIdsByKind[kind] ?? new Set<string>()
|
||||
set.add(targetId)
|
||||
mappedTargetIdsByKind[kind] = set
|
||||
}
|
||||
const validTargetIdsByKind = await filterExistingForkTargets(
|
||||
executor,
|
||||
targetWorkspaceId,
|
||||
mappedTargetIdsByKind
|
||||
)
|
||||
|
||||
const resolver = buildForkResolver(mappingRows, {
|
||||
sourceIsParent,
|
||||
targetEnvKeys,
|
||||
sourceEnvKeys,
|
||||
validTargetIdsByKind,
|
||||
})
|
||||
|
||||
const identityMap = new Map<string, string>()
|
||||
for (const row of mappingRows) {
|
||||
if (row.resourceType !== 'workflow' || row.childResourceId == null) continue
|
||||
if (sourceIsParent) identityMap.set(row.parentResourceId, row.childResourceId)
|
||||
else identityMap.set(row.childResourceId, row.parentResourceId)
|
||||
}
|
||||
|
||||
const [targetWorkflows, sourceWorkflowRows] = await Promise.all([
|
||||
executor
|
||||
.select({ id: workflow.id, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, targetWorkspaceId), isNull(workflow.archivedAt))),
|
||||
executor
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, sourceWorkspaceId), isNull(workflow.archivedAt))),
|
||||
])
|
||||
|
||||
const targetActiveIds = new Set(targetWorkflows.map((w) => w.id))
|
||||
const targetNameById = new Map(targetWorkflows.map((w) => [w.id, w.name]))
|
||||
// Every source workflow that still EXISTS (deployed or not). A mapped target is
|
||||
// archived only when its source was DELETED - not merely undeployed. A fresh fork
|
||||
// leaves the child's workflows undeployed, so pushing back must not archive the
|
||||
// parent's originals; undeployed sources are simply skipped (target left as-is).
|
||||
const existingSourceIds = new Set(sourceWorkflowRows.map((w) => w.id))
|
||||
|
||||
// Build the items and scan references in one pass from the pre-read source states
|
||||
// (loaded before the caller's transaction; see loadSourceDeployedStates).
|
||||
const items: ForkPromotePlanItem[] = []
|
||||
const referenceByKey = new Map<string, ForkReference>()
|
||||
for (const source of deployedSourceWorkflows) {
|
||||
const sourceState = sourceStates.get(source.id)
|
||||
if (!sourceState) continue
|
||||
|
||||
const mappedTargetId = identityMap.get(source.id)
|
||||
const isReplace = Boolean(mappedTargetId && targetActiveIds.has(mappedTargetId))
|
||||
const targetWorkflowId = isReplace ? (mappedTargetId as string) : generateId()
|
||||
items.push({
|
||||
sourceWorkflowId: source.id,
|
||||
targetWorkflowId,
|
||||
targetName: isReplace ? (targetNameById.get(targetWorkflowId) ?? null) : null,
|
||||
mode: isReplace ? 'replace' : 'create',
|
||||
sourceMeta: {
|
||||
name: source.name,
|
||||
description: source.description,
|
||||
folderId: source.folderId,
|
||||
sortOrder: source.sortOrder,
|
||||
isPublicApi: source.isPublicApi,
|
||||
},
|
||||
})
|
||||
|
||||
for (const reference of scanWorkflowReferences(toScannerBlocks(sourceState), resolver)
|
||||
.references) {
|
||||
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
|
||||
}
|
||||
}
|
||||
|
||||
const workflowIdMap = buildPromoteWorkflowIdMap({
|
||||
identityMap,
|
||||
existingSourceIds,
|
||||
targetActiveIds,
|
||||
items,
|
||||
})
|
||||
|
||||
const writtenTargetIds = new Set(items.map((item) => item.targetWorkflowId))
|
||||
const archivedTargetIds: string[] = []
|
||||
for (const row of mappingRows) {
|
||||
if (row.resourceType !== 'workflow' || row.childResourceId == null) continue
|
||||
const mappedSourceId = sourceIsParent ? row.parentResourceId : row.childResourceId
|
||||
const mappedTargetId = sourceIsParent ? row.childResourceId : row.parentResourceId
|
||||
if (existingSourceIds.has(mappedSourceId)) continue
|
||||
if (writtenTargetIds.has(mappedTargetId)) continue
|
||||
if (targetActiveIds.has(mappedTargetId)) archivedTargetIds.push(mappedTargetId)
|
||||
}
|
||||
const archivedTargets = archivedTargetIds.map((id) => ({
|
||||
id,
|
||||
name: targetNameById.get(id) ?? id,
|
||||
}))
|
||||
|
||||
const cascade = await detectForkCascadeReferences({
|
||||
executor,
|
||||
sourceWorkspaceId,
|
||||
references: Array.from(referenceByKey.values()),
|
||||
resolve: resolver,
|
||||
})
|
||||
for (const reference of cascade.references) {
|
||||
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
|
||||
}
|
||||
|
||||
const allReferences = Array.from(referenceByKey.values())
|
||||
const allUnmapped = allReferences.filter(
|
||||
(reference) => resolver(reference.kind, reference.sourceId) == null
|
||||
)
|
||||
const unmappedRequired = allUnmapped.filter((reference) => reference.required)
|
||||
const unmappedOptional = allUnmapped.filter((reference) => !reference.required)
|
||||
|
||||
// Referenced-but-unmapped resources of copyable kinds that still exist in the source, so the
|
||||
// sync modal can offer to copy them into the target (fork-style) instead of mapping by hand.
|
||||
const copyableLabels = await loadForkCopyableResourceLabels(
|
||||
executor,
|
||||
sourceWorkspaceId,
|
||||
collectForkCopyableIdsByKind(allUnmapped)
|
||||
)
|
||||
const referencedCopyables = assembleForkCopyableUnmapped(allUnmapped, copyableLabels)
|
||||
// Also offer the source's UNREFERENCED copyable resources with no target mapping (e.g. newly
|
||||
// created since the fork), default-unselected in the modal. Mapped ones (including everything
|
||||
// a prior sync copied) resolve non-null and drop out, so a re-sync never re-offers a copy.
|
||||
const sourceCopyables = await listForkCopyableSourceResources(executor, sourceWorkspaceId)
|
||||
const copyableUnmapped = [
|
||||
...referencedCopyables,
|
||||
...collectForkUnreferencedCopyables(sourceCopyables, referencedCopyables, resolver),
|
||||
]
|
||||
|
||||
const willUpdate = items.filter((i) => i.mode === 'replace').length
|
||||
const willCreate = items.filter((i) => i.mode === 'create').length
|
||||
|
||||
return {
|
||||
childWorkspaceId: edge.childWorkspaceId,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
direction,
|
||||
resolver,
|
||||
items,
|
||||
workflowIdMap,
|
||||
archivedTargetIds,
|
||||
archivedTargets,
|
||||
references: allReferences,
|
||||
unmappedRequired,
|
||||
unmappedOptional,
|
||||
mcpReauthServerIds: cascade.mcpReauthServerIds,
|
||||
inlineSecretSources: cascade.inlineSecretSources,
|
||||
copyableUnmapped,
|
||||
willUpdate,
|
||||
willCreate,
|
||||
willArchive: archivedTargetIds.length,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { workspaceForkPromoteRun } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
/**
|
||||
* A target workflow's pre-promote deployed-version reference. Rollback reactivates
|
||||
* `priorVersion` (and loads it into the draft); `null` means the target was not
|
||||
* deployed before the promote, so rollback undeploys it instead.
|
||||
*/
|
||||
export interface PromoteRunWorkflowSnapshot {
|
||||
workflowId: string
|
||||
priorVersion: number | null
|
||||
}
|
||||
|
||||
export interface PromoteRunSnapshot {
|
||||
/** Replaced targets: reactivate their prior deployed version on rollback. */
|
||||
updated: PromoteRunWorkflowSnapshot[]
|
||||
/** Targets the promote created: undeploy + archive on rollback. */
|
||||
created: string[]
|
||||
/** Orphan targets the promote archived: un-archive + reactivate on rollback. */
|
||||
archived: PromoteRunWorkflowSnapshot[]
|
||||
}
|
||||
|
||||
export interface PromoteRunRow {
|
||||
id: string
|
||||
childWorkspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
snapshot: PromoteRunSnapshot
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
/** Replace the edge's undo point with a new run (single-level history). */
|
||||
export async function upsertPromoteRun(
|
||||
tx: DbOrTx,
|
||||
params: {
|
||||
childWorkspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
snapshot: PromoteRunSnapshot
|
||||
userId: string
|
||||
}
|
||||
): Promise<string> {
|
||||
const now = new Date()
|
||||
const id = generateId()
|
||||
await tx
|
||||
.insert(workspaceForkPromoteRun)
|
||||
.values({
|
||||
id,
|
||||
childWorkspaceId: params.childWorkspaceId,
|
||||
sourceWorkspaceId: params.sourceWorkspaceId,
|
||||
targetWorkspaceId: params.targetWorkspaceId,
|
||||
direction: params.direction,
|
||||
snapshot: params.snapshot,
|
||||
createdBy: params.userId,
|
||||
createdAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceForkPromoteRun.childWorkspaceId, workspaceForkPromoteRun.targetWorkspaceId],
|
||||
set: {
|
||||
id,
|
||||
sourceWorkspaceId: params.sourceWorkspaceId,
|
||||
direction: params.direction,
|
||||
snapshot: params.snapshot,
|
||||
createdBy: params.userId,
|
||||
createdAt: now,
|
||||
},
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove EVERY undo point targeting this workspace. Called after a rollback so the
|
||||
* undo is single-level: only the latest sync into a target is ever undoable, and
|
||||
* once it is undone there is no stack of older syncs to walk back into.
|
||||
*/
|
||||
export async function deleteAllPromoteRunsForTarget(
|
||||
tx: DbOrTx,
|
||||
targetWorkspaceId: string
|
||||
): Promise<void> {
|
||||
await tx
|
||||
.delete(workspaceForkPromoteRun)
|
||||
.where(eq(workspaceForkPromoteRun.targetWorkspaceId, targetWorkspaceId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest undo point targeting this workspace. A workspace can be the target of
|
||||
* several edges (pushes from its children, a pull from its parent), so order by
|
||||
* recency: this is the ONLY undoable sync - older ones are stale the moment a newer
|
||||
* sync lands, and rollback refuses them.
|
||||
*/
|
||||
export async function getLatestPromoteRunForTarget(
|
||||
executor: DbOrTx,
|
||||
targetWorkspaceId: string
|
||||
): Promise<PromoteRunRow | null> {
|
||||
const [row] = await executor
|
||||
.select({
|
||||
id: workspaceForkPromoteRun.id,
|
||||
childWorkspaceId: workspaceForkPromoteRun.childWorkspaceId,
|
||||
sourceWorkspaceId: workspaceForkPromoteRun.sourceWorkspaceId,
|
||||
targetWorkspaceId: workspaceForkPromoteRun.targetWorkspaceId,
|
||||
direction: workspaceForkPromoteRun.direction,
|
||||
snapshot: workspaceForkPromoteRun.snapshot,
|
||||
createdAt: workspaceForkPromoteRun.createdAt,
|
||||
})
|
||||
.from(workspaceForkPromoteRun)
|
||||
.where(eq(workspaceForkPromoteRun.targetWorkspaceId, targetWorkspaceId))
|
||||
.orderBy(desc(workspaceForkPromoteRun.createdAt))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
return { ...row, snapshot: row.snapshot as PromoteRunSnapshot }
|
||||
}
|
||||
|
||||
/**
|
||||
* The "other" workspace and direction of the latest sync into this target, for the
|
||||
* UI's undo affordance. `sourceWorkspaceId` is the workspace the sync came from
|
||||
* (rollback resolves the edge from target + other).
|
||||
*/
|
||||
export async function getUndoableRunForTarget(
|
||||
executor: DbOrTx,
|
||||
targetWorkspaceId: string
|
||||
): Promise<{ sourceWorkspaceId: string; direction: 'push' | 'pull' } | null> {
|
||||
const run = await getLatestPromoteRunForTarget(executor, targetWorkspaceId)
|
||||
return run ? { sourceWorkspaceId: run.sourceWorkspaceId, direction: run.direction } : null
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ForkSyncBlocker } from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
const {
|
||||
mockComputePlan,
|
||||
mockBuildCopySelection,
|
||||
mockHasCopySelection,
|
||||
mockCopyUnmapped,
|
||||
mockCollectBlockers,
|
||||
mockLoadBlockMap,
|
||||
mockBuildBlockIdResolver,
|
||||
mockResolveFolderMapping,
|
||||
mockUpsertPromoteRun,
|
||||
mockLoadSourceDeployedStates,
|
||||
mockGetUsersWithPermissions,
|
||||
mockGetMcpServerMeta,
|
||||
mockCreateTransform,
|
||||
mockSumForkCopyBytes,
|
||||
mockAssertForkStorageHeadroom,
|
||||
} = vi.hoisted(() => ({
|
||||
mockComputePlan: vi.fn(),
|
||||
mockBuildCopySelection: vi.fn(),
|
||||
mockHasCopySelection: vi.fn(),
|
||||
mockCopyUnmapped: vi.fn(),
|
||||
mockCollectBlockers: vi.fn(),
|
||||
mockLoadBlockMap: vi.fn(),
|
||||
mockBuildBlockIdResolver: vi.fn(),
|
||||
mockResolveFolderMapping: vi.fn(),
|
||||
mockUpsertPromoteRun: vi.fn(),
|
||||
mockLoadSourceDeployedStates: vi.fn(),
|
||||
mockGetUsersWithPermissions: vi.fn(),
|
||||
mockGetMcpServerMeta: vi.fn(),
|
||||
mockCreateTransform: vi.fn(),
|
||||
mockSumForkCopyBytes: vi.fn(),
|
||||
mockAssertForkStorageHeadroom: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/deployment-outbox', () => ({
|
||||
enqueueWorkflowUndeploySideEffects: vi.fn(),
|
||||
processWorkflowDeploymentOutboxEvent: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/workflows/orchestration/deploy', () => ({
|
||||
performFullDeploy: vi.fn(async () => ({ success: true })),
|
||||
}))
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
undeployWorkflow: vi.fn(async () => ({ success: true })),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
|
||||
startBackgroundWork: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/content-copy-runner', () => ({
|
||||
hasForkContentToCopy: vi.fn(() => false),
|
||||
scheduleForkContentCopy: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/copy-workflows', () => ({
|
||||
copyWorkflowStateIntoTarget: vi.fn(),
|
||||
loadTargetDraftSubBlocks: vi.fn(async () => new Map()),
|
||||
loadWorkflowNameRegistry: vi.fn(async () => new Map()),
|
||||
resolveForkFolderMapping: mockResolveFolderMapping,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/storage-quota', () => ({
|
||||
sumForkCopyBytes: mockSumForkCopyBytes,
|
||||
assertForkStorageHeadroom: mockAssertForkStorageHeadroom,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
|
||||
getActiveDeploymentVersionNumbers: vi.fn(async () => new Map()),
|
||||
loadSourceDeployedStates: mockLoadSourceDeployedStates,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
|
||||
acquireForkEdgeLock: vi.fn(),
|
||||
acquireForkTargetLock: vi.fn(),
|
||||
setForkLockTimeout: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
|
||||
loadForkBlockMap: mockLoadBlockMap,
|
||||
reconcileForkBlockPairs: vi.fn(),
|
||||
toForkBlockPairs: vi.fn(() => []),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/dependent-value-store', () => ({
|
||||
loadForkDependentValues: vi.fn(async () => []),
|
||||
reconcileForkDependentValues: vi.fn(),
|
||||
// Faithful mirror of the real pure translation (unit-tested in dependent-value-store.test.ts),
|
||||
// so promote's apply/reconcile paths exercise the actual source-doc-id rewrite.
|
||||
translateForkDependentValues: vi.fn(
|
||||
(
|
||||
values: Array<{ value: string }>,
|
||||
resolve: (kind: string, sourceId: string) => string | null | undefined
|
||||
) =>
|
||||
values.map((entry) => {
|
||||
if (entry.value === '') return entry
|
||||
const translated = resolve('knowledge-document', entry.value)
|
||||
return translated != null && translated !== entry.value
|
||||
? { ...entry, value: translated }
|
||||
: entry
|
||||
})
|
||||
),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
|
||||
deleteWorkflowIdentityByIds: vi.fn(),
|
||||
upsertEdgeMappings: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/promote/cleared-refs', () => ({
|
||||
collectForkSyncBlockers: mockCollectBlockers,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/promote/copy-unmapped', () => ({
|
||||
// Faithful mirror of the real overlay so a copy's id maps resolve through the augmented
|
||||
// resolver (the dependent-value translation and MCP meta read depend on it).
|
||||
augmentForkResolver: vi.fn(
|
||||
(
|
||||
base: (kind: string, sourceId: string) => string | null | undefined,
|
||||
extra: Map<string, Map<string, string>>
|
||||
) =>
|
||||
(kind: string, sourceId: string) =>
|
||||
extra.get(kind)?.get(sourceId) ?? base(kind, sourceId)
|
||||
),
|
||||
buildPromoteCopySelection: mockBuildCopySelection,
|
||||
copyPromoteUnmappedResources: mockCopyUnmapped,
|
||||
hasPromoteCopySelection: mockHasCopySelection,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({
|
||||
computeForkPromotePlan: mockComputePlan,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/copy-chats', () => ({
|
||||
copyForkChatDeployments: vi.fn(async () => ({ created: 0 })),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/copy/workflow-mcp-attachments', () => ({
|
||||
reconcileForkWorkflowMcpAttachments: vi.fn(async () => ({ affectedServerIds: [] })),
|
||||
}))
|
||||
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
|
||||
notifyMcpToolServers: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
|
||||
upsertPromoteRun: mockUpsertPromoteRun,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({
|
||||
getMcpServerMetaByIds: mockGetMcpServerMeta,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/remap/block-identity', () => ({
|
||||
buildForkBlockIdResolver: mockBuildBlockIdResolver,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/remap/remap-references', () => ({
|
||||
createForkSubBlockTransform: mockCreateTransform,
|
||||
}))
|
||||
vi.mock('@/ee/workspace-forking/lib/socket', () => ({
|
||||
notifyForkWorkflowChanged: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUsersWithPermissions: mockGetUsersWithPermissions,
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { copyWorkflowStateIntoTarget } from '@/ee/workspace-forking/lib/copy/copy-workflows'
|
||||
import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
|
||||
import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
|
||||
import type { ForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
|
||||
|
||||
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
|
||||
|
||||
const EMPTY_SELECTION = {
|
||||
customTools: [],
|
||||
skills: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
files: [],
|
||||
}
|
||||
|
||||
function makePlan(overrides: Partial<ForkPromotePlan> = {}): ForkPromotePlan {
|
||||
return {
|
||||
childWorkspaceId: EDGE.childWorkspaceId,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
targetWorkspaceId: 'tgt-ws',
|
||||
direction: 'push',
|
||||
resolver: () => null,
|
||||
items: [],
|
||||
workflowIdMap: new Map(),
|
||||
archivedTargetIds: [],
|
||||
archivedTargets: [],
|
||||
references: [],
|
||||
unmappedRequired: [],
|
||||
unmappedOptional: [],
|
||||
mcpReauthServerIds: [],
|
||||
inlineSecretSources: [],
|
||||
copyableUnmapped: [],
|
||||
willUpdate: 0,
|
||||
willCreate: 0,
|
||||
willArchive: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCKER: ForkSyncBlocker = {
|
||||
workflowName: 'Caller',
|
||||
blockLabel: 'Table Block',
|
||||
fieldLabel: 'Table',
|
||||
kind: 'table',
|
||||
sourceId: 'tbl-1',
|
||||
sourceLabel: 'Orders',
|
||||
reason: 'unmapped-copyable',
|
||||
}
|
||||
|
||||
function promoteParams() {
|
||||
return {
|
||||
edge: EDGE as never,
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
targetWorkspaceId: 'tgt-ws',
|
||||
direction: 'push' as const,
|
||||
userId: 'user-1',
|
||||
}
|
||||
}
|
||||
|
||||
/** A copy result carrying no content/id maps, for tests that only need the copy to run. */
|
||||
function emptyCopyResult() {
|
||||
return {
|
||||
contentPlan: {
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'tgt-ws',
|
||||
userId: 'user-1',
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
skills: [],
|
||||
documents: [],
|
||||
},
|
||||
copyIdMapByKind: new Map(),
|
||||
contentRefMaps: {},
|
||||
blobTasks: [],
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(db.transaction).mockImplementation(
|
||||
async (cb: (tx: unknown) => unknown) => cb({}) as never
|
||||
)
|
||||
mockGetUsersWithPermissions.mockResolvedValue([])
|
||||
mockLoadSourceDeployedStates.mockResolvedValue({
|
||||
deployedWorkflows: [],
|
||||
sourceStates: new Map(),
|
||||
})
|
||||
mockComputePlan.mockResolvedValue(makePlan())
|
||||
mockBuildCopySelection.mockReturnValue({
|
||||
selection: EMPTY_SELECTION,
|
||||
willResolve: new Set<string>(),
|
||||
})
|
||||
mockHasCopySelection.mockReturnValue(false)
|
||||
mockCollectBlockers.mockResolvedValue([])
|
||||
mockLoadBlockMap.mockResolvedValue(new Map())
|
||||
mockBuildBlockIdResolver.mockReturnValue((_wf: string, blockId: string) => blockId)
|
||||
mockResolveFolderMapping.mockResolvedValue(new Map())
|
||||
mockUpsertPromoteRun.mockResolvedValue('run-1')
|
||||
mockGetMcpServerMeta.mockResolvedValue(new Map())
|
||||
mockCreateTransform.mockReturnValue((subBlocks: unknown) => subBlocks)
|
||||
mockSumForkCopyBytes.mockResolvedValue(0)
|
||||
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('promoteFork gates', () => {
|
||||
it('blocks an over-quota copy selection before any lock, read, or write', async () => {
|
||||
mockSumForkCopyBytes.mockResolvedValue(999_999)
|
||||
mockAssertForkStorageHeadroom.mockRejectedValue(
|
||||
new Error(
|
||||
'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
promoteFork({
|
||||
...promoteParams(),
|
||||
copyResources: { files: ['workspace/src-ws/key-1'], knowledgeBases: ['kb-1'] },
|
||||
})
|
||||
).rejects.toThrow('Not enough storage to copy the selected resources')
|
||||
|
||||
expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 })
|
||||
// Fails fast: no source-state loads, no locked transaction, no writes of any kind.
|
||||
expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled()
|
||||
expect(db.transaction).not.toHaveBeenCalled()
|
||||
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sums the requested copy selection bytes against the SOURCE workspace (files by key, KBs by id)', async () => {
|
||||
await promoteFork({
|
||||
...promoteParams(),
|
||||
copyResources: {
|
||||
files: ['workspace/src-ws/key-1'],
|
||||
knowledgeBases: ['kb-1'],
|
||||
tables: ['tbl-1'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockSumForkCopyBytes).toHaveBeenCalledTimes(1)
|
||||
expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', {
|
||||
fileKeys: ['workspace/src-ws/key-1'],
|
||||
knowledgeBaseIds: ['kb-1'],
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks on unmapped required credentials/secrets BEFORE the cleared-refs gate runs', async () => {
|
||||
mockComputePlan.mockResolvedValue(
|
||||
makePlan({
|
||||
unmappedRequired: [
|
||||
{ kind: 'credential', sourceId: 'c1', subBlockKey: 'credential', required: true },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const result = await promoteFork(promoteParams())
|
||||
|
||||
expect(result.blocked).toBe('unmapped')
|
||||
expect(result.unmappedRequired).toEqual([
|
||||
{ kind: 'credential', sourceId: 'c1', required: true, blockName: undefined },
|
||||
])
|
||||
expect(result.blockers).toEqual([])
|
||||
expect(mockCollectBlockers).not.toHaveBeenCalled()
|
||||
expect(mockResolveFolderMapping).not.toHaveBeenCalled()
|
||||
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks with the structured blocker list when references would clear, writing NOTHING', async () => {
|
||||
mockCollectBlockers.mockResolvedValue([BLOCKER])
|
||||
|
||||
const result = await promoteFork(promoteParams())
|
||||
|
||||
expect(result.blocked).toBe('cleared-refs')
|
||||
expect(result.blockers).toEqual([BLOCKER])
|
||||
expect(result.promoteRunId).toBe('')
|
||||
expect(result.updated).toBe(0)
|
||||
expect(result.created).toBe(0)
|
||||
expect(result.archived).toBe(0)
|
||||
// Blocked before the first write: no folder creation, no resource copy, no undo point.
|
||||
expect(mockResolveFolderMapping).not.toHaveBeenCalled()
|
||||
expect(mockCopyUnmapped).not.toHaveBeenCalled()
|
||||
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('evaluates the gate against the plan resolver overlaid with the copy selection', async () => {
|
||||
const planResolver = vi.fn(() => 'plan-resolved')
|
||||
mockComputePlan.mockResolvedValue(makePlan({ resolver: planResolver }))
|
||||
mockBuildCopySelection.mockReturnValue({
|
||||
selection: EMPTY_SELECTION,
|
||||
willResolve: new Set(['table:t1']),
|
||||
})
|
||||
|
||||
await promoteFork(promoteParams())
|
||||
|
||||
expect(mockCollectBlockers).toHaveBeenCalledTimes(1)
|
||||
const gateParams = mockCollectBlockers.mock.calls[0][0]
|
||||
// A copy-selected reference resolves through the overlay (never hits the plan resolver);
|
||||
// everything else falls through to the plan's persisted-mapping resolver.
|
||||
expect(gateParams.resolver('table', 't1')).toBe('t1')
|
||||
expect(planResolver).not.toHaveBeenCalled()
|
||||
expect(gateParams.resolver('table', 't2')).toBe('plan-resolved')
|
||||
expect(planResolver).toHaveBeenCalledWith('table', 't2')
|
||||
})
|
||||
|
||||
it('threads the SAME block-id resolver into the gate and the resource copy as the workflow writes', async () => {
|
||||
// Copied tables' workflow-group outputs must land on the block ids the sync actually writes
|
||||
// (persisted pairs preferred over derive), so the copy receives the resolver built from the
|
||||
// loaded block map - the identical instance the cleared-refs gate uses.
|
||||
const resolver = (_workflowId: string, blockId: string) => `pair-${blockId}`
|
||||
mockBuildBlockIdResolver.mockReturnValue(resolver)
|
||||
mockHasCopySelection.mockReturnValue(true)
|
||||
mockCopyUnmapped.mockResolvedValue({
|
||||
contentPlan: {
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
childWorkspaceId: 'tgt-ws',
|
||||
userId: 'user-1',
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
skills: [],
|
||||
documents: [],
|
||||
},
|
||||
copyIdMapByKind: new Map(),
|
||||
contentRefMaps: {},
|
||||
blobTasks: [],
|
||||
})
|
||||
|
||||
await promoteFork(promoteParams())
|
||||
|
||||
expect(mockCopyUnmapped).toHaveBeenCalledTimes(1)
|
||||
expect(mockCopyUnmapped.mock.calls[0][0].resolveBlockId).toBe(resolver)
|
||||
expect(mockCollectBlockers.mock.calls[0][0].resolveBlockId).toBe(resolver)
|
||||
})
|
||||
|
||||
it('proceeds when zero references would clear (empty blocker list)', async () => {
|
||||
const plan = makePlan()
|
||||
mockComputePlan.mockResolvedValue(plan)
|
||||
|
||||
const result = await promoteFork(promoteParams())
|
||||
|
||||
expect(result.blocked).toBeNull()
|
||||
expect(result.blockers).toEqual([])
|
||||
expect(result.promoteRunId).toBe('run-1')
|
||||
expect(mockCollectBlockers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceWorkspaceId: 'src-ws',
|
||||
items: plan.items,
|
||||
workflowIdMap: plan.workflowIdMap,
|
||||
})
|
||||
)
|
||||
expect(mockUpsertPromoteRun).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("threads the plan's unmapped references into the gate so it can reuse the plan's scan", async () => {
|
||||
const unmappedOptional = [
|
||||
{ kind: 'table' as const, sourceId: 'tbl-1', subBlockKey: 'tbl', required: false },
|
||||
]
|
||||
mockComputePlan.mockResolvedValue(makePlan({ unmappedOptional }))
|
||||
|
||||
await promoteFork(promoteParams())
|
||||
|
||||
expect(mockCollectBlockers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ planUnmapped: unmappedOptional })
|
||||
)
|
||||
})
|
||||
|
||||
it('batch-loads the mapped TARGET MCP server rows and threads them into the subblock transform', async () => {
|
||||
// Two references resolving to the SAME target and one unmapped: the read must cover the
|
||||
// distinct mapped target ids only (one bounded query, unmapped ids dropped).
|
||||
const resolver = (kind: string, id: string) => {
|
||||
if (kind !== 'mcp-server') return null
|
||||
if (id === 'srv-a' || id === 'srv-b') return 'srv-tgt'
|
||||
return null
|
||||
}
|
||||
mockComputePlan.mockResolvedValue(
|
||||
makePlan({
|
||||
resolver,
|
||||
references: [
|
||||
{ kind: 'mcp-server', sourceId: 'srv-a', subBlockKey: 'tools', required: false },
|
||||
{ kind: 'mcp-server', sourceId: 'srv-b', subBlockKey: 'server', required: false },
|
||||
{ kind: 'mcp-server', sourceId: 'srv-unmapped', subBlockKey: 'tools', required: false },
|
||||
],
|
||||
})
|
||||
)
|
||||
mockGetMcpServerMeta.mockResolvedValue(
|
||||
new Map([['srv-tgt', { name: 'Target Server', url: 'https://target.example/mcp' }]])
|
||||
)
|
||||
|
||||
await promoteFork(promoteParams())
|
||||
|
||||
expect(mockGetMcpServerMeta).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetMcpServerMeta).toHaveBeenCalledWith(expect.anything(), 'tgt-ws', ['srv-tgt'])
|
||||
// The transform receives a lookup resolving the TARGET id to its row metadata, so remapped
|
||||
// tool-input entries rewrite their embedded serverUrl/serverName from the target server.
|
||||
expect(mockCreateTransform).toHaveBeenCalledTimes(1)
|
||||
const [, transformOptions] = mockCreateTransform.mock.calls[0]
|
||||
expect(transformOptions.resolveMcpServerMeta('srv-tgt')).toEqual({
|
||||
name: 'Target Server',
|
||||
url: 'https://target.example/mcp',
|
||||
})
|
||||
expect(transformOptions.resolveMcpServerMeta('srv-unknown')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('promoteFork dependent values', () => {
|
||||
it('unions the dependent-value picks into the copy discovery set (a re-picked document must be copied)', async () => {
|
||||
mockComputePlan.mockResolvedValue(
|
||||
makePlan({
|
||||
references: [
|
||||
{
|
||||
kind: 'knowledge-document',
|
||||
sourceId: 'doc-a',
|
||||
subBlockKey: 'documentSelector',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
// No container selection: the document candidates alone must trigger the copy pass.
|
||||
mockHasCopySelection.mockReturnValue(false)
|
||||
mockCopyUnmapped.mockResolvedValue(emptyCopyResult())
|
||||
|
||||
await promoteFork({
|
||||
...promoteParams(),
|
||||
dependentValues: [
|
||||
// Duplicates the plan's own scan -> deduped.
|
||||
{ workflowId: 'wf-t', blockId: 'b1', subBlockKey: 'documentSelector', value: 'doc-a' },
|
||||
// A fresh pick the source state does not reference -> must join the discovery set.
|
||||
{ workflowId: 'wf-t', blockId: 'b2', subBlockKey: 'documentSelector', value: 'doc-b' },
|
||||
// Cleared values are skipped; non-document values ride along (DB-filtered downstream).
|
||||
{ workflowId: 'wf-t', blockId: 'b3', subBlockKey: 'folder', value: '' },
|
||||
{ workflowId: 'wf-t', blockId: 'b4', subBlockKey: 'folder', value: 'INBOX' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(mockCopyUnmapped).toHaveBeenCalledTimes(1)
|
||||
expect(mockCopyUnmapped.mock.calls[0][0].referencedDocumentIds).toEqual([
|
||||
'doc-a',
|
||||
'doc-b',
|
||||
'INBOX',
|
||||
])
|
||||
})
|
||||
|
||||
it('translates a source document id under a copy-resolved KB for BOTH the written state and the store', async () => {
|
||||
const item = {
|
||||
sourceWorkflowId: 'wf-src',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetName: 'Flow',
|
||||
mode: 'replace' as const,
|
||||
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
|
||||
}
|
||||
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
|
||||
mockLoadSourceDeployedStates.mockResolvedValue({
|
||||
deployedWorkflows: [],
|
||||
sourceStates: new Map([
|
||||
['wf-src', { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }],
|
||||
]),
|
||||
})
|
||||
// The KB is copy-selected; the copy assigns the picked source document its copied id.
|
||||
mockHasCopySelection.mockReturnValue(true)
|
||||
mockCopyUnmapped.mockResolvedValue({
|
||||
...emptyCopyResult(),
|
||||
copyIdMapByKind: new Map([['knowledge-document', new Map([['doc-src', 'doc-copy']])]]),
|
||||
})
|
||||
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
mode: 'replace',
|
||||
name: 'Flow',
|
||||
blocksCount: 0,
|
||||
edgesCount: 0,
|
||||
subflowsCount: 0,
|
||||
clearedDependents: [],
|
||||
blockIdMapping: new Map(),
|
||||
})
|
||||
|
||||
const result = await promoteFork({
|
||||
...promoteParams(),
|
||||
copyResources: { knowledgeBases: ['kb-src'] },
|
||||
dependentValues: [
|
||||
{
|
||||
workflowId: 'wf-tgt',
|
||||
blockId: 'blk-1',
|
||||
subBlockKey: 'documentSelector',
|
||||
value: 'doc-src',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.blocked).toBeNull()
|
||||
// The apply map the workflow write receives carries the COPIED id: the dependent-value
|
||||
// apply runs AFTER the reference remap and wins for its subblock, so a raw source id
|
||||
// would clobber the remapped value in the written state.
|
||||
expect(vi.mocked(copyWorkflowStateIntoTarget)).toHaveBeenCalledTimes(1)
|
||||
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
|
||||
expect(writeParams.dependentOverrides?.get('blk-1')?.get('documentSelector')).toBe('doc-copy')
|
||||
// The store persists the translated value too, so the next sync (whose parent is then
|
||||
// MAPPED via the persisted copy mapping) pre-fills a document id that resolves in the target.
|
||||
expect(vi.mocked(reconcileForkDependentValues)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'child-ws',
|
||||
['wf-tgt'],
|
||||
[
|
||||
{
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetBlockId: 'blk-1',
|
||||
subBlockKey: 'documentSelector',
|
||||
value: 'doc-copy',
|
||||
},
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a mapped parent dependent value verbatim (a target-space value never re-translates)', async () => {
|
||||
const item = {
|
||||
sourceWorkflowId: 'wf-src',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetName: 'Flow',
|
||||
mode: 'replace' as const,
|
||||
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
|
||||
}
|
||||
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
|
||||
mockLoadSourceDeployedStates.mockResolvedValue({
|
||||
deployedWorkflows: [],
|
||||
sourceStates: new Map([
|
||||
['wf-src', { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }],
|
||||
]),
|
||||
})
|
||||
// The value joins the discovery candidates, so the copy pass runs - and resolves nothing.
|
||||
mockCopyUnmapped.mockResolvedValue(emptyCopyResult())
|
||||
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
mode: 'replace',
|
||||
name: 'Flow',
|
||||
blocksCount: 0,
|
||||
edgesCount: 0,
|
||||
subflowsCount: 0,
|
||||
clearedDependents: [],
|
||||
blockIdMapping: new Map(),
|
||||
})
|
||||
|
||||
await promoteFork({
|
||||
...promoteParams(),
|
||||
dependentValues: [
|
||||
{
|
||||
workflowId: 'wf-tgt',
|
||||
blockId: 'blk-1',
|
||||
subBlockKey: 'documentSelector',
|
||||
value: 'doc-tgt-existing',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
|
||||
expect(writeParams.dependentOverrides?.get('blk-1')?.get('documentSelector')).toBe(
|
||||
'doc-tgt-existing'
|
||||
)
|
||||
expect(vi.mocked(reconcileForkDependentValues)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'child-ws',
|
||||
['wf-tgt'],
|
||||
[
|
||||
{
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetBlockId: 'blk-1',
|
||||
subBlockKey: 'documentSelector',
|
||||
value: 'doc-tgt-existing',
|
||||
},
|
||||
]
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { enqueueWorkflowDeploymentSideEffects } from '@/lib/workflows/deployment-outbox'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
interface ReactivateDeployedVersionParams {
|
||||
tx: DbOrTx
|
||||
workflowId: string
|
||||
version: number
|
||||
userId: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface ReactivateDeployedVersionResult {
|
||||
deploymentVersionId: string
|
||||
/**
|
||||
* Outbox event id enqueued inside the transaction. Process it AFTER the tx commits
|
||||
* (or rely on the outbox cron/reaper if the process dies first).
|
||||
*/
|
||||
outboxEventId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a prior deployment version AND restore the workflow's draft to it using
|
||||
* ONLY DB writes against the provided transaction, enqueuing the deployment
|
||||
* side-effect (webhook / schedule / MCP re-subscription) to the outbox for processing
|
||||
* AFTER the tx commits. This composes the DB halves of {@link activateWorkflowVersion}
|
||||
* and `performRevertToVersion` so a fork rollback can run atomically under its fork
|
||||
* advisory lock - the heavy side-effects never run inside the locked tx.
|
||||
*
|
||||
* Deliberately does NOT call `assertWorkflowMutable`: a rollback is an admin force-undo
|
||||
* and must not be blocked by a workflow/folder lock (that check is also not tx-safe).
|
||||
* Idempotent: deactivate-all + activate-target + overwrite-draft yield the same state
|
||||
* on retry.
|
||||
*
|
||||
* Returns null when the target version row no longer exists, so the caller can mark the
|
||||
* workflow skipped rather than failing the whole rollback.
|
||||
*/
|
||||
export async function reactivateDeployedVersionInTx(
|
||||
params: ReactivateDeployedVersionParams
|
||||
): Promise<ReactivateDeployedVersionResult | null> {
|
||||
const { tx, workflowId, version, userId, requestId } = params
|
||||
const now = new Date()
|
||||
|
||||
// Lock the workflow row so this serializes with a concurrent (unlocked) promote
|
||||
// deploy loop, which locks the same row in deployWorkflow - guaranteeing the final
|
||||
// (active version, draft) pair is always coherent regardless of commit order.
|
||||
await tx
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
.for('update')
|
||||
|
||||
const [versionRow] = await tx
|
||||
.select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.version, version)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!versionRow) return null
|
||||
|
||||
const deployedState = versionRow.state as {
|
||||
blocks?: Record<string, unknown>
|
||||
edges?: unknown[]
|
||||
loops?: Record<string, unknown>
|
||||
parallels?: Record<string, unknown>
|
||||
variables?: WorkflowState['variables']
|
||||
}
|
||||
if (!deployedState.blocks || !deployedState.edges) {
|
||||
throw new Error(
|
||||
`Deployment version ${version} for workflow ${workflowId} has an invalid state structure`
|
||||
)
|
||||
}
|
||||
|
||||
// Activate the target version (deactivate every other), mark the workflow deployed.
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ isActive: false })
|
||||
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ isActive: true })
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.version, version)
|
||||
)
|
||||
)
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ isDeployed: true, deployedAt: now })
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
// Restore the draft to the deployed version's state.
|
||||
const hasVariables = Object.hasOwn(deployedState, 'variables')
|
||||
const restoredState: WorkflowState = {
|
||||
blocks: deployedState.blocks,
|
||||
edges: deployedState.edges,
|
||||
loops: deployedState.loops || {},
|
||||
parallels: deployedState.parallels || {},
|
||||
lastSaved: now.getTime(),
|
||||
} as WorkflowState
|
||||
if (hasVariables) {
|
||||
restoredState.variables = deployedState.variables || {}
|
||||
}
|
||||
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, restoredState, tx)
|
||||
if (!saveResult.success) {
|
||||
throw new Error(saveResult.error || `Failed to restore draft for workflow ${workflowId}`)
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
...(hasVariables ? { variables: deployedState.variables || {} } : {}),
|
||||
lastSynced: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
const outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
|
||||
workflowId,
|
||||
deploymentVersionId: versionRow.id,
|
||||
userId,
|
||||
requestId,
|
||||
forceRecreateSubscriptions: true,
|
||||
})
|
||||
|
||||
return { deploymentVersionId: versionRow.id, outboxEventId }
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockResolveForkEdge,
|
||||
mockAcquireTargetLock,
|
||||
mockAcquireEdgeLock,
|
||||
mockGetLatestRun,
|
||||
mockDeleteAllRuns,
|
||||
mockReactivate,
|
||||
mockUndeploy,
|
||||
mockDeleteIdentity,
|
||||
mockEnqueueUndeploy,
|
||||
mockProcessOutbox,
|
||||
mockNotify,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveForkEdge: vi.fn(),
|
||||
mockAcquireTargetLock: vi.fn(),
|
||||
mockAcquireEdgeLock: vi.fn(),
|
||||
mockGetLatestRun: vi.fn(),
|
||||
mockDeleteAllRuns: vi.fn(),
|
||||
mockReactivate: vi.fn(),
|
||||
mockUndeploy: vi.fn(),
|
||||
mockDeleteIdentity: vi.fn(),
|
||||
mockEnqueueUndeploy: vi.fn(),
|
||||
mockProcessOutbox: vi.fn(),
|
||||
mockNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
|
||||
resolveForkEdge: mockResolveForkEdge,
|
||||
acquireForkTargetLock: mockAcquireTargetLock,
|
||||
acquireForkEdgeLock: mockAcquireEdgeLock,
|
||||
setForkLockTimeout: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
|
||||
getLatestPromoteRunForTarget: mockGetLatestRun,
|
||||
deleteAllPromoteRunsForTarget: mockDeleteAllRuns,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/promote/reactivate-in-tx', () => ({
|
||||
reactivateDeployedVersionInTx: mockReactivate,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
undeployWorkflow: mockUndeploy,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
|
||||
deleteWorkflowIdentityByIds: mockDeleteIdentity,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/deployment-outbox', () => ({
|
||||
enqueueWorkflowUndeploySideEffects: mockEnqueueUndeploy,
|
||||
processWorkflowDeploymentOutboxEvent: mockProcessOutbox,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/socket', () => ({
|
||||
notifyForkWorkflowChanged: mockNotify,
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { rollbackFork } from '@/ee/workspace-forking/lib/promote/rollback'
|
||||
|
||||
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
|
||||
|
||||
/** A fake transaction whose existence query returns the given undeploy ids. */
|
||||
function makeTx(existingUndeployIds: string[] = []) {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve(existingUndeployIds.map((id) => ({ id })))),
|
||||
})),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function setTx(existingUndeployIds: string[] = []) {
|
||||
vi.mocked(db.transaction).mockImplementation(
|
||||
async (cb: (tx: unknown) => unknown) => cb(makeTx(existingUndeployIds)) as never
|
||||
)
|
||||
}
|
||||
|
||||
function makeRun(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'run-1',
|
||||
childWorkspaceId: EDGE.childWorkspaceId,
|
||||
direction: 'push' as const,
|
||||
snapshot: { updated: [], created: [], archived: [] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('rollbackFork', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveForkEdge.mockResolvedValue(EDGE)
|
||||
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv', outboxEventId: 'evt' })
|
||||
mockUndeploy.mockResolvedValue({ success: true })
|
||||
mockProcessOutbox.mockResolvedValue('completed')
|
||||
setTx([])
|
||||
})
|
||||
|
||||
it('reactivates updated workflows and processes side-effects after commit', async () => {
|
||||
const run = makeRun({
|
||||
snapshot: {
|
||||
updated: [
|
||||
{ workflowId: 'wf-b', priorVersion: 5 },
|
||||
{ workflowId: 'wf-a', priorVersion: 3 },
|
||||
],
|
||||
created: [],
|
||||
archived: [],
|
||||
},
|
||||
})
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => ({
|
||||
deploymentVersionId: `dv-${workflowId}`,
|
||||
outboxEventId: `evt-${workflowId}`,
|
||||
}))
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
restored: 2,
|
||||
archived: 0,
|
||||
unarchived: 0,
|
||||
skipped: 0,
|
||||
skippedIds: [],
|
||||
})
|
||||
// Deterministic (sorted) order: wf-a before wf-b.
|
||||
expect(mockReactivate.mock.calls.map((c) => c[0].workflowId)).toEqual(['wf-a', 'wf-b'])
|
||||
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-a')
|
||||
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-b')
|
||||
expect(mockDeleteAllRuns).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotify).toHaveBeenCalledWith('wf-a')
|
||||
expect(mockNotify).toHaveBeenCalledWith('wf-b')
|
||||
})
|
||||
|
||||
it('un-archives and reactivates an archived orphan (prior version restored)', async () => {
|
||||
const run = makeRun({
|
||||
snapshot: { updated: [], created: [], archived: [{ workflowId: 'wf-x', priorVersion: 2 }] },
|
||||
})
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv-x', outboxEventId: 'evt-x' })
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result.unarchived).toBe(1)
|
||||
expect(result.restored).toBe(0)
|
||||
expect(result.archived).toBe(0)
|
||||
expect(result.skipped).toBe(0)
|
||||
expect(mockReactivate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workflowId: 'wf-x', version: 2 })
|
||||
)
|
||||
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-x')
|
||||
expect(mockNotify).toHaveBeenCalledWith('wf-x')
|
||||
})
|
||||
|
||||
it('aborts with 409 and writes nothing when a newer sync supersedes it mid-flight', async () => {
|
||||
const run = makeRun({
|
||||
snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] },
|
||||
})
|
||||
// Unlocked read returns our run; the in-tx re-check sees a newer run.
|
||||
mockGetLatestRun.mockResolvedValueOnce(run).mockResolvedValueOnce(makeRun({ id: 'run-2' }))
|
||||
|
||||
await expect(
|
||||
rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
).rejects.toMatchObject({ statusCode: 409 })
|
||||
|
||||
// No partial restore: nothing reactivated, no undo point consumed.
|
||||
expect(mockReactivate).not.toHaveBeenCalled()
|
||||
expect(mockUndeploy).not.toHaveBeenCalled()
|
||||
expect(mockDeleteAllRuns).not.toHaveBeenCalled()
|
||||
expect(mockProcessOutbox).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a skipped reactivation when the version is gone (never silent)', async () => {
|
||||
const run = makeRun({
|
||||
snapshot: {
|
||||
updated: [
|
||||
{ workflowId: 'wf-a', priorVersion: 3 },
|
||||
{ workflowId: 'wf-b', priorVersion: 5 },
|
||||
],
|
||||
created: [],
|
||||
archived: [],
|
||||
},
|
||||
})
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) =>
|
||||
workflowId === 'wf-b' ? null : { deploymentVersionId: 'dv', outboxEventId: 'evt-wf-a' }
|
||||
)
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result.restored).toBe(1)
|
||||
expect(result.skipped).toBe(1)
|
||||
expect(result.skippedIds).toEqual(['wf-b'])
|
||||
expect(mockNotify).not.toHaveBeenCalledWith('wf-b')
|
||||
})
|
||||
|
||||
it('undeploys + archives created workflows and dissolves their identity rows', async () => {
|
||||
setTx(['wf-c'])
|
||||
const run = makeRun({
|
||||
direction: 'push',
|
||||
snapshot: { updated: [], created: ['wf-c'], archived: [] },
|
||||
})
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
mockUndeploy.mockImplementation(
|
||||
async ({
|
||||
onUndeployTransaction,
|
||||
}: {
|
||||
onUndeployTransaction?: (
|
||||
tx: unknown,
|
||||
r: { deploymentVersionIds: string[] }
|
||||
) => Promise<void>
|
||||
}) => {
|
||||
await onUndeployTransaction?.(makeTx(), { deploymentVersionIds: ['dv-c'] })
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
mockEnqueueUndeploy.mockResolvedValue('undeploy-evt')
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result.archived).toBe(1)
|
||||
expect(result.skipped).toBe(0)
|
||||
expect(mockUndeploy).toHaveBeenCalledTimes(1)
|
||||
expect(mockDeleteIdentity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
EDGE.childWorkspaceId,
|
||||
'parent',
|
||||
['wf-c']
|
||||
)
|
||||
expect(mockProcessOutbox).toHaveBeenCalledWith('undeploy-evt')
|
||||
})
|
||||
|
||||
it('skips a created workflow that was hard-deleted (not archived, surfaced)', async () => {
|
||||
setTx([]) // wf-c no longer exists
|
||||
const run = makeRun({ snapshot: { updated: [], created: ['wf-c'], archived: [] } })
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(mockUndeploy).not.toHaveBeenCalled()
|
||||
expect(result.skipped).toBe(1)
|
||||
expect(result.skippedIds).toEqual(['wf-c'])
|
||||
expect(result.archived).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,300 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, inArray, isNull } from 'drizzle-orm'
|
||||
import {
|
||||
enqueueWorkflowUndeploySideEffects,
|
||||
processWorkflowDeploymentOutboxEvent,
|
||||
} from '@/lib/workflows/deployment-outbox'
|
||||
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import {
|
||||
acquireForkEdgeLock,
|
||||
acquireForkTargetLock,
|
||||
resolveForkEdge,
|
||||
setForkLockTimeout,
|
||||
} from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import { deleteWorkflowIdentityByIds } from '@/ee/workspace-forking/lib/mapping/mapping-store'
|
||||
import {
|
||||
deleteAllPromoteRunsForTarget,
|
||||
getLatestPromoteRunForTarget,
|
||||
} from '@/ee/workspace-forking/lib/promote/promote-run-store'
|
||||
import { reactivateDeployedVersionInTx } from '@/ee/workspace-forking/lib/promote/reactivate-in-tx'
|
||||
import { notifyForkWorkflowChanged } from '@/ee/workspace-forking/lib/socket'
|
||||
|
||||
const logger = createLogger('WorkspaceForkRollback')
|
||||
|
||||
export interface RollbackForkParams {
|
||||
targetWorkspaceId: string
|
||||
otherWorkspaceId: string
|
||||
userId: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface RollbackForkResult {
|
||||
restored: number
|
||||
archived: number
|
||||
unarchived: number
|
||||
/** Snapshot workflows that no longer exist and so couldn't be restored. */
|
||||
skipped: number
|
||||
/** Ids of the skipped workflows (surfaced so the partial restore is never silent). */
|
||||
skippedIds: string[]
|
||||
}
|
||||
|
||||
/** A single restore action, sorted by workflow id for a deterministic lock order. */
|
||||
type RollbackOp =
|
||||
| { workflowId: string; kind: 'reactivate'; version: number }
|
||||
| { workflowId: string; kind: 'undeploy' }
|
||||
|
||||
/**
|
||||
* Undo the most recent promote into `targetWorkspaceId` in ONE atomic, fork-locked,
|
||||
* DB-only transaction. Because a concurrent promote takes the same target advisory
|
||||
* lock for its write transaction, it cannot interleave with the rollback: it runs
|
||||
* fully before or fully after. If a newer sync superseded our undo point, we abort
|
||||
* with 409 BEFORE any write, so the operation is strictly all-or-nothing - it never
|
||||
* leaves a partially reverted target.
|
||||
*
|
||||
* The heavy webhook / schedule / MCP re-subscription work is enqueued to the
|
||||
* deployment outbox INSIDE the transaction and processed AFTER commit (and durably
|
||||
* retried by the outbox cron/reaper if this process dies first), so the locked
|
||||
* transaction never holds across a network call. No draft blobs are stored - the
|
||||
* deployed version is the source of truth.
|
||||
*/
|
||||
export async function rollbackFork(params: RollbackForkParams): Promise<RollbackForkResult> {
|
||||
const { targetWorkspaceId, otherWorkspaceId, userId } = params
|
||||
const requestId = params.requestId ?? 'unknown'
|
||||
|
||||
const edge = await resolveForkEdge(targetWorkspaceId, otherWorkspaceId)
|
||||
if (!edge) {
|
||||
throw new ForkError('These workspaces are not a direct fork edge', 400)
|
||||
}
|
||||
|
||||
// Only the most recent sync into the target is undoable. Undoing an older sibling's
|
||||
// sync while a newer one stands would partially revert the target and strand the
|
||||
// newer sync's changes.
|
||||
const run = await getLatestPromoteRunForTarget(db, targetWorkspaceId)
|
||||
if (!run) {
|
||||
throw new ForkError('There is no promote to undo for this workspace', 404)
|
||||
}
|
||||
if (run.childWorkspaceId !== edge.childWorkspaceId) {
|
||||
throw new ForkError(
|
||||
'A newer sync into this workspace exists; reopen and undo the most recent sync.',
|
||||
409
|
||||
)
|
||||
}
|
||||
|
||||
const { updated, created, archived } = run.snapshot
|
||||
|
||||
// Build the restore ops: reactivate a prior version, or undeploy (created targets +
|
||||
// updated targets that had no prior deployment). Sort by workflow id so the locked
|
||||
// transaction acquires workflow row locks in a deterministic order, avoiding
|
||||
// deadlocks with the (unlocked) promote deploy loop, which locks the same rows.
|
||||
const undeployIds = [
|
||||
...created,
|
||||
...updated.filter((i) => i.priorVersion == null).map((i) => i.workflowId),
|
||||
]
|
||||
const toReactivateOps = (
|
||||
list: Array<{ workflowId: string; priorVersion: number | null }>
|
||||
): RollbackOp[] =>
|
||||
list
|
||||
.filter((item) => item.priorVersion != null)
|
||||
.map((item) => ({
|
||||
workflowId: item.workflowId,
|
||||
kind: 'reactivate' as const,
|
||||
version: item.priorVersion as number,
|
||||
}))
|
||||
const ops: RollbackOp[] = [
|
||||
...toReactivateOps(updated),
|
||||
...toReactivateOps(archived),
|
||||
...undeployIds.map((workflowId) => ({ workflowId, kind: 'undeploy' as const })),
|
||||
].sort((a, b) => a.workflowId.localeCompare(b.workflowId))
|
||||
|
||||
const skipped = new Set<string>()
|
||||
const outboxEventIds: string[] = []
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await setForkLockTimeout(tx)
|
||||
await acquireForkTargetLock(tx, targetWorkspaceId)
|
||||
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
|
||||
|
||||
// Re-confirm our run is still the newest sync, now under the lock. If a promote
|
||||
// landed since the unlocked read above, abort with NO writes (tx rolls back).
|
||||
const current = await getLatestPromoteRunForTarget(tx, targetWorkspaceId)
|
||||
if (!current || current.id !== run.id) {
|
||||
throw new ForkError(
|
||||
'A newer sync into this workspace exists; reopen and undo the most recent sync.',
|
||||
409
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Un-archive the orphans the promote archived BEFORE reactivating them.
|
||||
if (archived.length > 0) {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ archivedAt: null, updatedAt: now })
|
||||
.where(
|
||||
inArray(
|
||||
workflow.id,
|
||||
archived.map((i) => i.workflowId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Which undeploy targets still exist (created targets can be hard-deleted after the
|
||||
// promote; a missing one is already gone, so skip rather than fail the rollback).
|
||||
const existingUndeploy =
|
||||
undeployIds.length === 0
|
||||
? new Set<string>()
|
||||
: new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(inArray(workflow.id, undeployIds))
|
||||
).map((row) => row.id)
|
||||
)
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.kind === 'reactivate') {
|
||||
const result = await reactivateDeployedVersionInTx({
|
||||
tx,
|
||||
workflowId: op.workflowId,
|
||||
version: op.version,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
// A null result means the workflow / version was hard-deleted since the
|
||||
// promote - record it so the partial restore is surfaced, never silent.
|
||||
if (!result) {
|
||||
skipped.add(op.workflowId)
|
||||
continue
|
||||
}
|
||||
outboxEventIds.push(result.outboxEventId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existingUndeploy.has(op.workflowId)) {
|
||||
skipped.add(op.workflowId)
|
||||
continue
|
||||
}
|
||||
const undeployResult = await undeployWorkflow({
|
||||
workflowId: op.workflowId,
|
||||
tx,
|
||||
onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => {
|
||||
if (deploymentVersionIds.length === 0) return
|
||||
const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, {
|
||||
workflowId: op.workflowId,
|
||||
deploymentVersionIds,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
outboxEventIds.push(eventId)
|
||||
},
|
||||
})
|
||||
if (!undeployResult.success) {
|
||||
// The workflow exists but couldn't be undeployed - abort so we never leave a
|
||||
// partial undo. The whole tx rolls back and the undo point is preserved.
|
||||
throw new ForkError(
|
||||
`Rollback could not undeploy workflow ${op.workflowId}: ${undeployResult.error ?? 'unknown error'}. The undo point is preserved - retry the rollback.`,
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Archive the workflows the promote created and dissolve their identity rows.
|
||||
if (created.length > 0) {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ archivedAt: now, updatedAt: now })
|
||||
.where(inArray(workflow.id, created))
|
||||
// Archive their chat deployments too (matching `archiveWorkflow`): the sync carried a
|
||||
// chat onto each created target, and leaving it live would keep a working chat URL (with
|
||||
// the copied auth config) pointing at the archived workflow. The undeploy above already
|
||||
// cleans webhooks + MCP tools via the outbox; chats have no undeploy hook.
|
||||
await tx
|
||||
.update(chat)
|
||||
.set({ archivedAt: now, isActive: false, updatedAt: now })
|
||||
.where(and(inArray(chat.workflowId, created), isNull(chat.archivedAt)))
|
||||
// A created target is the child side on pull and the parent side on push.
|
||||
await deleteWorkflowIdentityByIds(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
run.direction === 'pull' ? 'child' : 'parent',
|
||||
created
|
||||
)
|
||||
}
|
||||
|
||||
// Single-level undo: drop every undo point for this target so no older sibling
|
||||
// sync becomes undoable once this one is undone.
|
||||
await deleteAllPromoteRunsForTarget(tx, targetWorkspaceId)
|
||||
})
|
||||
|
||||
// After commit: process the enqueued side-effects (webhooks / schedules / MCP). These
|
||||
// are durable outbox rows, so a crash here is recovered by the outbox cron/reaper -
|
||||
// failures only warn, they never undo the (committed) restore.
|
||||
for (const eventId of outboxEventIds) {
|
||||
try {
|
||||
await processWorkflowDeploymentOutboxEvent(eventId)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${requestId}] Deferred rollback side-effect processing failed (will retry via outbox)`,
|
||||
{ eventId, error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.size > 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] Rollback skipped ${skipped.size} workflow(s) no longer in the database`,
|
||||
{
|
||||
targetWorkspaceId,
|
||||
skipped: Array.from(skipped),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Notify connected canvases to adopt the restored state (reactivated drafts + the
|
||||
// undeployed/archived created targets). Skipped (gone) workflows have no room.
|
||||
const notifyIds = new Set<string>()
|
||||
for (const op of ops) {
|
||||
if (!skipped.has(op.workflowId)) notifyIds.add(op.workflowId)
|
||||
}
|
||||
for (const workflowId of notifyIds) void notifyForkWorkflowChanged(workflowId)
|
||||
|
||||
// Attribute each skip to its bucket (a workflow is in exactly one) so the counts
|
||||
// reflect what was actually restored, not the snapshot size.
|
||||
const createdSet = new Set(created)
|
||||
const archivedSet = new Set(archived.map((i) => i.workflowId))
|
||||
const updatedSet = new Set(updated.map((i) => i.workflowId))
|
||||
let skippedUpdated = 0
|
||||
let skippedCreated = 0
|
||||
let skippedArchived = 0
|
||||
for (const id of skipped) {
|
||||
if (updatedSet.has(id)) skippedUpdated += 1
|
||||
else if (createdSet.has(id)) skippedCreated += 1
|
||||
else if (archivedSet.has(id)) skippedArchived += 1
|
||||
}
|
||||
|
||||
const restored = updated.length - skippedUpdated
|
||||
const archivedCount = created.length - skippedCreated
|
||||
const unarchived = archived.length - skippedArchived
|
||||
|
||||
const result: RollbackForkResult = {
|
||||
restored,
|
||||
archived: archivedCount,
|
||||
unarchived,
|
||||
skipped: skipped.size,
|
||||
skippedIds: Array.from(skipped),
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Rolled back promote into ${targetWorkspaceId}`, {
|
||||
restored: result.restored,
|
||||
archived: result.archived,
|
||||
unarchived: result.unarchived,
|
||||
skipped: result.skipped,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
forkSyncBlockerReasonFor,
|
||||
selectForkSyncBlockingRefs,
|
||||
toForkSyncBlockers,
|
||||
} from '@/ee/workspace-forking/lib/promote/sync-blockers'
|
||||
|
||||
type ReferenceRef = Extract<ForkClearedRef, { cause: 'reference' }>
|
||||
type DependentRef = Extract<ForkClearedRef, { cause: 'dependent' }>
|
||||
|
||||
const base = {
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
workflowName: 'Workflow',
|
||||
blockId: 'block-1',
|
||||
blockLabel: 'Block',
|
||||
fieldLabel: 'Field',
|
||||
sourceLabel: 'Source',
|
||||
}
|
||||
|
||||
const referenceRef = (
|
||||
kind: ReferenceRef['kind'],
|
||||
sourceId: string,
|
||||
sourceDeleted = false
|
||||
): ReferenceRef => ({ ...base, cause: 'reference', kind, sourceId, sourceDeleted })
|
||||
|
||||
const workflowRef = (sourceId: string): ForkClearedRef => ({
|
||||
...base,
|
||||
cause: 'workflow',
|
||||
kind: 'workflow',
|
||||
sourceId,
|
||||
})
|
||||
|
||||
const dependentRef = (parentKind: DependentRef['parentKind']): DependentRef => ({
|
||||
...base,
|
||||
cause: 'dependent',
|
||||
kind: parentKind,
|
||||
sourceId: 'parent-src',
|
||||
parentKind,
|
||||
parentSourceId: 'parent-src',
|
||||
})
|
||||
|
||||
describe('forkSyncBlockerReasonFor', () => {
|
||||
it('maps a live unmapped copyable-kind reference to unmapped-copyable (map or copy)', () => {
|
||||
for (const kind of [
|
||||
'table',
|
||||
'knowledge-base',
|
||||
'file',
|
||||
'custom-tool',
|
||||
'skill',
|
||||
// External MCP servers are copyable too (config rows; OAuth tokens never copied).
|
||||
'mcp-server',
|
||||
] as const) {
|
||||
expect(forkSyncBlockerReasonFor(referenceRef(kind, 'src-1'))).toBe('unmapped-copyable')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps a source-deleted reference of ANY kind to source-deleted (no exemption)', () => {
|
||||
expect(forkSyncBlockerReasonFor(referenceRef('table', 'tbl-gone', true))).toBe('source-deleted')
|
||||
expect(forkSyncBlockerReasonFor(referenceRef('mcp-server', 'srv-gone', true))).toBe(
|
||||
'source-deleted'
|
||||
)
|
||||
expect(forkSyncBlockerReasonFor(referenceRef('file', 'workspace/SRC/gone.png', true))).toBe(
|
||||
'source-deleted'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps a workflow-cause entry to workflow-missing', () => {
|
||||
expect(forkSyncBlockerReasonFor(workflowRef('wf-other'))).toBe('workflow-missing')
|
||||
})
|
||||
|
||||
it('never blocks a dependent-cause entry (the reconfigure flow owns dependents)', () => {
|
||||
expect(forkSyncBlockerReasonFor(dependentRef('credential'))).toBeNull()
|
||||
expect(forkSyncBlockerReasonFor(dependentRef('knowledge-base'))).toBeNull()
|
||||
})
|
||||
|
||||
it('defensively ignores kinds the collector excludes (credential / env-var / document)', () => {
|
||||
// These never reach the cleared list (excluded by the collector); if one leaked, the
|
||||
// kind-level required gate owns credentials/env-vars, so this path must not double-block.
|
||||
expect(forkSyncBlockerReasonFor(referenceRef('credential', 'c1'))).toBeNull()
|
||||
expect(forkSyncBlockerReasonFor(referenceRef('env-var', 'KEY'))).toBeNull()
|
||||
expect(forkSyncBlockerReasonFor(referenceRef('knowledge-document', 'doc-1'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectForkSyncBlockingRefs / toForkSyncBlockers', () => {
|
||||
it('keeps reference + workflow causes with their reasons and drops dependents', () => {
|
||||
const refs: ForkClearedRef[] = [
|
||||
referenceRef('table', 'tbl-1'),
|
||||
referenceRef('mcp-server', 'srv-1'),
|
||||
referenceRef('skill', 'sk-gone', true),
|
||||
workflowRef('wf-other'),
|
||||
dependentRef('credential'),
|
||||
]
|
||||
const blocking = selectForkSyncBlockingRefs(refs)
|
||||
expect(blocking.map(({ ref, reason }) => [ref.sourceId, reason])).toEqual([
|
||||
['tbl-1', 'unmapped-copyable'],
|
||||
['srv-1', 'unmapped-copyable'],
|
||||
['sk-gone', 'source-deleted'],
|
||||
['wf-other', 'workflow-missing'],
|
||||
])
|
||||
})
|
||||
|
||||
it('maps blocking entries to the wire blocker shape', () => {
|
||||
const blocking = selectForkSyncBlockingRefs([referenceRef('table', 'tbl-1')])
|
||||
expect(toForkSyncBlockers(blocking)).toEqual([
|
||||
{
|
||||
workflowName: 'Workflow',
|
||||
blockLabel: 'Block',
|
||||
fieldLabel: 'Field',
|
||||
kind: 'table',
|
||||
sourceId: 'tbl-1',
|
||||
sourceLabel: 'Source',
|
||||
reason: 'unmapped-copyable',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
type ForkClearedRef,
|
||||
type ForkSyncBlocker,
|
||||
type ForkSyncBlockerReason,
|
||||
forkCopyableKindSchema,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
/**
|
||||
* Pure sync-blocker taxonomy, shared by the server gate (promote) and the modal's blocker
|
||||
* rendering. A sync is allowed only when ZERO references would clear in any synced target
|
||||
* workflow; every would-clear entry of cause `reference` or `workflow` is a blocker with an
|
||||
* actionable reason. `dependent`-cause entries are NOT blockers - the dependent/reconfigure
|
||||
* flow owns them (its own required gating), and a credential-anchored dependent clears on any
|
||||
* parent remap, so blocking on it would be unresolvable.
|
||||
*/
|
||||
|
||||
/** Copyable kinds derived from the wire contract, so the reason split can never drift. */
|
||||
const COPYABLE_BLOCKER_KINDS: ReadonlySet<string> = new Set(forkCopyableKindSchema.options)
|
||||
|
||||
/**
|
||||
* The blocker reason for a would-clear entry, or null when the entry does not block
|
||||
* (`dependent` cause, and - defensively - any kind the cleared-ref collector excludes):
|
||||
* - `workflow` cause -> `workflow-missing` (deploy the referenced workflow in the source, or
|
||||
* remove the reference).
|
||||
* - `reference` + source deleted -> `source-deleted` (map the dead id to a live target
|
||||
* resource, or fix/archive the source workflow).
|
||||
* - `reference` + copyable kind (incl. external MCP servers) -> `unmapped-copyable` (map it
|
||||
* or select it for copy).
|
||||
*/
|
||||
export function forkSyncBlockerReasonFor(ref: ForkClearedRef): ForkSyncBlockerReason | null {
|
||||
if (ref.cause === 'workflow') return 'workflow-missing'
|
||||
if (ref.cause !== 'reference') return null
|
||||
if (ref.sourceDeleted) return 'source-deleted'
|
||||
if (COPYABLE_BLOCKER_KINDS.has(ref.kind)) return 'unmapped-copyable'
|
||||
// Credential / env-var / knowledge-document never reach the cleared list (excluded by the
|
||||
// collector; the first two gate via the kind-level required gate, documents follow their KB).
|
||||
return null
|
||||
}
|
||||
|
||||
/** The would-clear entries that BLOCK the sync, paired with their reason. */
|
||||
export function selectForkSyncBlockingRefs(
|
||||
clearedRefs: ForkClearedRef[]
|
||||
): Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }> {
|
||||
return clearedRefs.flatMap((ref) => {
|
||||
const reason = forkSyncBlockerReasonFor(ref)
|
||||
return reason ? [{ ref, reason }] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** Map blocking entries to the wire {@link ForkSyncBlocker} shape of the promote gate error. */
|
||||
export function toForkSyncBlockers(
|
||||
blocking: Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }>
|
||||
): ForkSyncBlocker[] {
|
||||
return blocking.map(({ ref, reason }) => ({
|
||||
workflowName: ref.workflowName,
|
||||
blockLabel: ref.blockLabel,
|
||||
fieldLabel: ref.fieldLabel,
|
||||
kind: ref.kind,
|
||||
sourceId: ref.sourceId,
|
||||
sourceLabel: ref.sourceLabel,
|
||||
reason,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { isValidUuid } from '@sim/utils/id'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildForkBlockIdResolver,
|
||||
deriveForkBlockId,
|
||||
EMPTY_FORK_BLOCK_MAP,
|
||||
type ForkBlockMap,
|
||||
} from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
|
||||
describe('deriveForkBlockId', () => {
|
||||
const targetA = 'wf-target-a'
|
||||
const targetB = 'wf-target-b'
|
||||
const block1 = 'block-1'
|
||||
const block2 = 'block-2'
|
||||
|
||||
it('is deterministic for the same (targetWorkflowId, sourceBlockId)', () => {
|
||||
expect(deriveForkBlockId(targetA, block1)).toBe(deriveForkBlockId(targetA, block1))
|
||||
})
|
||||
|
||||
it('yields different ids for the same source block in different target workflows', () => {
|
||||
expect(deriveForkBlockId(targetA, block1)).not.toBe(deriveForkBlockId(targetB, block1))
|
||||
})
|
||||
|
||||
it('yields different ids for different source blocks in the same target workflow', () => {
|
||||
expect(deriveForkBlockId(targetA, block1)).not.toBe(deriveForkBlockId(targetA, block2))
|
||||
})
|
||||
|
||||
it('produces a valid UUID string (v5)', () => {
|
||||
const id = deriveForkBlockId(targetA, block1)
|
||||
expect(isValidUuid(id)).toBe(true)
|
||||
expect(id[14]).toBe('5')
|
||||
})
|
||||
|
||||
it('does not collide the colon separator (a:bc vs ab:c)', () => {
|
||||
expect(deriveForkBlockId('a', 'bc')).not.toBe(deriveForkBlockId('ab', 'c'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForkBlockIdResolver', () => {
|
||||
const parentWf = 'wf-parent'
|
||||
const childWf = 'wf-child'
|
||||
const parentBlock = 'block-parent'
|
||||
// The pair the fork created: child block derived from the parent block.
|
||||
const childBlock = deriveForkBlockId(childWf, parentBlock)
|
||||
const seededMap: ForkBlockMap = {
|
||||
parentToChild: new Map([
|
||||
[parentBlock, { targetBlockId: childBlock, targetWorkflowId: childWf }],
|
||||
]),
|
||||
childToParent: new Map([
|
||||
[childBlock, { targetBlockId: parentBlock, targetWorkflowId: parentWf }],
|
||||
]),
|
||||
}
|
||||
|
||||
it('push maps a child block back to the parent ORIGINAL id (keeps the webhook URL stable)', () => {
|
||||
const pushResolve = buildForkBlockIdResolver(false, seededMap)
|
||||
expect(pushResolve(parentWf, childBlock)).toBe(parentBlock)
|
||||
// The bug this fixes: without the map, push would re-derive and re-key the parent block.
|
||||
expect(pushResolve(parentWf, childBlock)).not.toBe(deriveForkBlockId(parentWf, childBlock))
|
||||
})
|
||||
|
||||
it('pull maps a parent block to its existing child id', () => {
|
||||
const pullResolve = buildForkBlockIdResolver(true, seededMap)
|
||||
expect(pullResolve(childWf, parentBlock)).toBe(childBlock)
|
||||
})
|
||||
|
||||
it('derives (does NOT reuse) when the target workflow was re-created (different id)', () => {
|
||||
// Parent workflow archived + re-created as wf-parent-2: the pair points at the old
|
||||
// workflow, so reusing parentBlock there would collide on the global block PK. Derive.
|
||||
const pushResolve = buildForkBlockIdResolver(false, seededMap)
|
||||
expect(pushResolve('wf-parent-2', childBlock)).toBe(
|
||||
deriveForkBlockId('wf-parent-2', childBlock)
|
||||
)
|
||||
expect(pushResolve('wf-parent-2', childBlock)).not.toBe(parentBlock)
|
||||
})
|
||||
|
||||
it('derives a fresh id for a source block with no recorded pair (added since last sync)', () => {
|
||||
const pushResolve = buildForkBlockIdResolver(false, seededMap)
|
||||
expect(pushResolve(parentWf, 'block-new')).toBe(deriveForkBlockId(parentWf, 'block-new'))
|
||||
})
|
||||
|
||||
it('derives everything when the map is empty (fork creation)', () => {
|
||||
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
|
||||
expect(resolve(childWf, parentBlock)).toBe(deriveForkBlockId(childWf, parentBlock))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Fixed namespace UUID for fork block-identity derivation. Changing this value
|
||||
* would re-key every forked workflow's block ids, breaking webhook URLs and
|
||||
* external block references (table workflow groups, chat output configs) across
|
||||
* promotes - so it must never change.
|
||||
*/
|
||||
const FORK_BLOCK_NAMESPACE = '6f1c0e2a-9b3d-5e47-8a1c-2d4f6b8e0c13'
|
||||
|
||||
function uuidToBytes(uuid: string): Buffer {
|
||||
return Buffer.from(uuid.replace(/-/g, ''), 'hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic UUIDv5 (SHA-1) of `name` within `namespace`. The same inputs
|
||||
* always yield the same UUID, which is how fork block identity stays stable.
|
||||
*
|
||||
* SHA-1 is mandated by RFC 4122 for UUIDv5 and is used here only for deterministic id derivation,
|
||||
* never for secrecy or integrity — not a security use of the algorithm. Swapping it would change
|
||||
* every derived id, breaking webhook URLs and stored block-id references across existing forks
|
||||
* (see {@link FORK_BLOCK_NAMESPACE}).
|
||||
*/
|
||||
function uuidV5(name: string, namespace: string): string {
|
||||
const hash = createHash('sha1')
|
||||
hash.update(uuidToBytes(namespace)) // lgtm[js/weak-cryptographic-algorithm]
|
||||
hash.update(Buffer.from(name, 'utf8')) // lgtm[js/weak-cryptographic-algorithm]
|
||||
const bytes = hash.digest().subarray(0, 16)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x50
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = bytes.toString('hex')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the target block id for a source block copied into a target workflow.
|
||||
*
|
||||
* Identity is deterministic in `(targetWorkflowId, sourceBlockId)`, so a logical
|
||||
* block keeps the same target id across every promote. This is what keeps trigger
|
||||
* webhook URLs consistent and keeps external block-id references (table workflow
|
||||
* groups, chat output configs, sim_trigger_state) valid across promotes. A source
|
||||
* block that no longer exists simply has no derived target, so the target block
|
||||
* disappears on the next force-replace.
|
||||
*/
|
||||
export function deriveForkBlockId(targetWorkflowId: string, sourceBlockId: string): string {
|
||||
return uuidV5(`${targetWorkflowId}:${sourceBlockId}`, FORK_BLOCK_NAMESPACE)
|
||||
}
|
||||
|
||||
/** A persisted counterpart: the target block id plus the workflow it belongs to. */
|
||||
export interface ForkBlockMapEntry {
|
||||
targetBlockId: string
|
||||
/** The target-side workflow the pair belongs to (childWorkflowId for parentToChild). */
|
||||
targetWorkflowId: string
|
||||
}
|
||||
|
||||
/** Persisted block-identity pairs for an edge, indexed for both promote directions. */
|
||||
export interface ForkBlockMap {
|
||||
/** parent block id -> { child block, child workflow } (pull/create resolve source=parent). */
|
||||
parentToChild: ReadonlyMap<string, ForkBlockMapEntry>
|
||||
/** child block id -> { parent block, parent workflow } (push resolves source=child). */
|
||||
childToParent: ReadonlyMap<string, ForkBlockMapEntry>
|
||||
}
|
||||
|
||||
/** An empty map - fork creation has no prior pairs, so every block id is derived fresh. */
|
||||
export const EMPTY_FORK_BLOCK_MAP: ForkBlockMap = {
|
||||
parentToChild: new Map(),
|
||||
childToParent: new Map(),
|
||||
}
|
||||
|
||||
/** Resolve a source block to its target block id for a promote (map-or-derive). */
|
||||
export type ForkBlockIdResolver = (targetWorkflowId: string, sourceBlockId: string) => string
|
||||
|
||||
/**
|
||||
* Build the block-id resolver a promote uses to assign target block ids. It reuses the
|
||||
* persisted counterpart when one exists AND that pair belongs to the workflow being written,
|
||||
* else falls back to {@link deriveForkBlockId} (blocks added since the last sync; fork
|
||||
* creation, which has no map). `sourceIsParent` is true on pull/create (source = parent) and
|
||||
* false on push (source = child); it selects the lookup direction.
|
||||
*
|
||||
* The workflow guard is what makes a re-created target safe: if the original target workflow
|
||||
* was archived and the promote creates a new one, the recorded pair points at the OLD
|
||||
* workflow, so it no longer matches and we derive a fresh id - never reusing the archived
|
||||
* workflow's block id (which would collide on the global `workflow_blocks` primary key).
|
||||
*
|
||||
* For a stable workflow this still maps each child block back to the parent's ORIGINAL id on
|
||||
* push, keeping its trigger webhook URL fixed. The SAME resolver must back
|
||||
* `copyWorkflowStateIntoTarget` (which writes the blocks) and `collectForkDependentReconfigs`
|
||||
* (which keys the modal's override by target block id), or the two would disagree.
|
||||
*/
|
||||
export function buildForkBlockIdResolver(
|
||||
sourceIsParent: boolean,
|
||||
map: ForkBlockMap
|
||||
): ForkBlockIdResolver {
|
||||
const existing = sourceIsParent ? map.parentToChild : map.childToParent
|
||||
return (targetWorkflowId, sourceBlockId) => {
|
||||
const entry = existing.get(sourceBlockId)
|
||||
if (entry && entry.targetWorkflowId === targetWorkflowId) return entry.targetBlockId
|
||||
return deriveForkBlockId(targetWorkflowId, sourceBlockId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
import {
|
||||
clearDependentsOnRemap,
|
||||
remapForkSubBlocks,
|
||||
type SubBlockTransform,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
/**
|
||||
* Resolves a source resource reference to its copied child id, or null when the
|
||||
* resource was not copied into the fork. Credentials are never copied (always
|
||||
* null), so credential references are cleared.
|
||||
*/
|
||||
export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string | null
|
||||
|
||||
/**
|
||||
* A `copyWorkflowStateIntoTarget` transform for the initial fork. Runs the shared
|
||||
* fork remapper in `create` mode: copyable resources the user selected are
|
||||
* rewritten to their child ids; references to resources that were not copied (and
|
||||
* all credential references) are cleared so the child workflow's subblocks start
|
||||
* empty; env-var `{{KEY}}` references are preserved (name-based, they resolve once
|
||||
* the child defines the key).
|
||||
*/
|
||||
export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): SubBlockTransform {
|
||||
return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged) => {
|
||||
// Every resolution at fork-create IS a copy (the resolver is the copy id map), so all
|
||||
// remapped keys carry copy provenance - copy-faithful dependents (column picks) survive.
|
||||
// `blockType`/`canonicalModes` activate the mode policy: active basic remaps, active
|
||||
// advanced (manual) passes through with its dependents, dormant members clear.
|
||||
const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create', {
|
||||
blockType,
|
||||
canonicalModes,
|
||||
isCopiedTarget: (kind, sourceId) => resolveCopied(kind, sourceId) != null,
|
||||
})
|
||||
if (result.canonicalModes) onCanonicalModesChanged?.(result.canonicalModes)
|
||||
return clearDependentsOnRemap(
|
||||
result.subBlocks,
|
||||
blockType,
|
||||
result.remappedKeys,
|
||||
result.canonicalModes ?? canonicalModes,
|
||||
result.copyRemappedKeys
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
|
||||
import {
|
||||
type ForkRemapKind,
|
||||
scanWorkflowReferences,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/** A block reduced to what the reference scanner reads (incl. canonical context for detection). */
|
||||
interface ScannerBlock {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
subBlocks: unknown
|
||||
canonicalModes?: CanonicalModeOverrides
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique source ids of one remap kind referenced across a set of blocks - both top-level
|
||||
* selectors and nested tool params. Used at fork/promote time to decide which KB documents
|
||||
* a copy must create placeholders for (so their references remap to the copied doc instead
|
||||
* of being cleared). The resolver is irrelevant here (every reference is detected regardless
|
||||
* of mapping), so a null resolver is passed.
|
||||
*/
|
||||
export function collectReferencedResourceIds(
|
||||
blocks: ScannerBlock[],
|
||||
kind: ForkRemapKind
|
||||
): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
|
||||
if (reference.kind === kind) ids.add(reference.sourceId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a workflow state's blocks to the `{ id, name, subBlocks }` shape the reference scanner
|
||||
* consumes. Confines the `subBlocks as unknown` widening (the stored subblock record is opaque
|
||||
* to the scanner, which re-narrows per subblock type) to one spot shared by every fork caller
|
||||
* that scans a source workflow's references.
|
||||
*/
|
||||
export function toScannerBlocks(state: WorkflowState): ScannerBlock[] {
|
||||
return Object.values(state.blocks).map((block) => ({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
type: block.type,
|
||||
subBlocks: block.subBlocks as unknown,
|
||||
canonicalModes: block.data?.canonicalModes,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique knowledge-document ids referenced across a set of source workflow states. Fork and
|
||||
* promote use this to decide which KB documents a copy must pre-create placeholders for, so a
|
||||
* `document-selector` reference remaps to the copied doc instead of being cleared.
|
||||
*/
|
||||
export function collectReferencedDocumentIds(states: Iterable<WorkflowState>): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const state of states) {
|
||||
for (const docId of collectReferencedResourceIds(
|
||||
toScannerBlocks(state),
|
||||
'knowledge-document'
|
||||
)) {
|
||||
ids.add(docId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type ForkContentRefMaps,
|
||||
rewriteForkContentRefs,
|
||||
rewriteForkResourceUrls,
|
||||
} from '@/ee/workspace-forking/lib/remap/remap-content-refs'
|
||||
|
||||
const SRC_KEY = 'workspace/SRC/1700000000000-deadbeef-photo.png'
|
||||
const DST_KEY = 'workspace/DST/1700000000001-cafebabe-photo.png'
|
||||
|
||||
const maps = (): ForkContentRefMaps => ({
|
||||
workspaceId: { from: 'SRC', to: 'DST' },
|
||||
fileKeys: new Map([[SRC_KEY, DST_KEY]]),
|
||||
fileIds: new Map([['file-src', 'file-dst']]),
|
||||
workflows: new Map([['wf-src', 'wf-dst']]),
|
||||
knowledgeBases: new Map([['kb-src', 'kb-dst']]),
|
||||
tables: new Map([['tbl-src', 'tbl-dst']]),
|
||||
skills: new Map([['skill-src', 'skill-dst']]),
|
||||
folders: new Map([['fld-src', 'fld-dst']]),
|
||||
})
|
||||
|
||||
describe('rewriteForkContentRefs - sim: links', () => {
|
||||
it('remaps each mapped sim: link kind by its id map', () => {
|
||||
const input = [
|
||||
'see [F](sim:file/file-src)',
|
||||
'[W](sim:workflow/wf-src)',
|
||||
'[K](sim:knowledge/kb-src)',
|
||||
'[T](sim:table/tbl-src)',
|
||||
'[S](sim:skill/skill-src)',
|
||||
'[D](sim:folder/fld-src)',
|
||||
].join(' ')
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(
|
||||
[
|
||||
'see [F](sim:file/file-dst)',
|
||||
'[W](sim:workflow/wf-dst)',
|
||||
'[K](sim:knowledge/kb-dst)',
|
||||
'[T](sim:table/tbl-dst)',
|
||||
'[S](sim:skill/skill-dst)',
|
||||
'[D](sim:folder/fld-dst)',
|
||||
].join(' ')
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an unmapped id unchanged (graceful broken link)', () => {
|
||||
const input = '[F](sim:file/unknown-file) and [I](sim:integration/gmail_v2)'
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves a kind with no supplied map unchanged', () => {
|
||||
const input = '[W](sim:workflow/wf-src)'
|
||||
expect(rewriteForkContentRefs(input, { fileIds: new Map() })).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteForkContentRefs - embedded urls', () => {
|
||||
it('remaps a serve-url storage key (encoded form output)', () => {
|
||||
const input = `}?context=workspace)`
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(
|
||||
`}?context=workspace)`
|
||||
)
|
||||
})
|
||||
|
||||
it('remaps a serve-url key given in raw (unencoded) form', () => {
|
||||
const input = ``
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(
|
||||
`})`
|
||||
)
|
||||
})
|
||||
|
||||
it('remaps an s3/blob-prefixed serve url, preserving the prefix', () => {
|
||||
const input = `})`
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(
|
||||
`})`
|
||||
)
|
||||
})
|
||||
|
||||
it('remaps a view-url file id', () => {
|
||||
const input = ''
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe('')
|
||||
})
|
||||
|
||||
it('remaps both the workspace id and file id in an in-app files path', () => {
|
||||
const input = ''
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe('')
|
||||
})
|
||||
|
||||
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 = ''
|
||||
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 = ''
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves an unmapped storage key / file id unchanged', () => {
|
||||
const input =
|
||||
' '
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves an external / data url unchanged', () => {
|
||||
const input = ' '
|
||||
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)',
|
||||
`})`,
|
||||
'link [W](sim:workflow/wf-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 = ''
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(input)
|
||||
})
|
||||
|
||||
it('does nothing when no maps are supplied', () => {
|
||||
const input = `[S](sim:skill/skill-src) `
|
||||
expect(rewriteForkContentRefs(input, {})).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteForkResourceUrls - table cell resource chip urls', () => {
|
||||
it('rewrites the workspace id + resource id for each section when the resource is mapped', () => {
|
||||
expect(rewriteForkResourceUrls('/workspace/SRC/w/wf-src', maps())).toBe(
|
||||
'/workspace/DST/w/wf-dst'
|
||||
)
|
||||
expect(rewriteForkResourceUrls('/workspace/SRC/tables/tbl-src', maps())).toBe(
|
||||
'/workspace/DST/tables/tbl-dst'
|
||||
)
|
||||
expect(rewriteForkResourceUrls('/workspace/SRC/knowledge/kb-src', maps())).toBe(
|
||||
'/workspace/DST/knowledge/kb-dst'
|
||||
)
|
||||
expect(rewriteForkResourceUrls('/workspace/SRC/files/file-src', maps())).toBe(
|
||||
'/workspace/DST/files/file-dst'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an unmapped resource id unchanged (both-or-nothing -> graceful plain link)', () => {
|
||||
expect(rewriteForkResourceUrls('/workspace/SRC/w/wf-unknown', maps())).toBe(
|
||||
'/workspace/SRC/w/wf-unknown'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a foreign / unknown workspace id unchanged', () => {
|
||||
expect(rewriteForkResourceUrls('/workspace/OTHER/w/wf-src', maps())).toBe(
|
||||
'/workspace/OTHER/w/wf-src'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a non-matching string (unknown section / no resource path) unchanged', () => {
|
||||
const input = 'text /workspace/ and /workspace/SRC/settings/x and /workspace/SRC/w/'
|
||||
expect(rewriteForkResourceUrls(input, maps())).toBe(input)
|
||||
})
|
||||
|
||||
it('does nothing without a workspaceId map', () => {
|
||||
const input = '/workspace/SRC/w/wf-src'
|
||||
expect(rewriteForkResourceUrls(input, { workflows: new Map([['wf-src', 'wf-dst']]) })).toBe(
|
||||
input
|
||||
)
|
||||
})
|
||||
|
||||
it('rewrites a URL embedded mid-text', () => {
|
||||
const input = 'See [chip](/workspace/SRC/knowledge/kb-src) here'
|
||||
expect(rewriteForkResourceUrls(input, maps())).toBe(
|
||||
'See [chip](/workspace/DST/knowledge/kb-dst) here'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Pure rewriter for the in-content references embedded in copied free-text (skill bodies,
|
||||
* markdown file blobs) at fork/sync time. It rewrites the two reference shapes a copy must
|
||||
* keep pointing at the right workspace:
|
||||
*
|
||||
* - `sim:<kind>/<id>` deep links (the `@`-mention / chip scheme) - the id is remapped through
|
||||
* the matching fork id map by kind (file, folder, table, knowledge, workflow, skill).
|
||||
* - Embedded file/image URLs - `/api/files/serve/<key>` (workspace storage key), `/api/files/view/<id>`
|
||||
* (workspace file id), and the in-app `/workspace/<id>/files/<id>` path - remapped through the file
|
||||
* key / file id / workspace id maps.
|
||||
*
|
||||
* A reference whose target has no mapping is LEFT UNCHANGED (a graceful broken link), never deleted,
|
||||
* so a copied document is never silently corrupted. Pure and isomorphic (no DOM/Node/DB).
|
||||
*/
|
||||
|
||||
/** Per-kind source->target id maps a content copy threads in (any subset may be supplied). */
|
||||
export interface ForkContentRefMaps {
|
||||
/** Workspace id rewrite for the in-app `/workspace/<id>/files/...` path. */
|
||||
workspaceId?: { from: string; to: string }
|
||||
/** source workspace-file storage key -> child storage key (serve-url embeds). */
|
||||
fileKeys?: ReadonlyMap<string, string>
|
||||
/** source workspace-file id -> child id (view-url + in-app-path embeds). */
|
||||
fileIds?: ReadonlyMap<string, string>
|
||||
/** source workflow id -> child id (`sim:workflow/<id>`). */
|
||||
workflows?: ReadonlyMap<string, string>
|
||||
/** source knowledge-base id -> child id (`sim:knowledge/<id>`). */
|
||||
knowledgeBases?: ReadonlyMap<string, string>
|
||||
/** source table id -> child id (`sim:table/<id>`). */
|
||||
tables?: ReadonlyMap<string, string>
|
||||
/** source skill id -> child id (`sim:skill/<id>`). */
|
||||
skills?: ReadonlyMap<string, string>
|
||||
/** source folder id -> child id (`sim:folder/<id>`). */
|
||||
folders?: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** `sim:<kind>/<id>` token; the id charset matches generateId/generateShortId so it stops at delimiters. */
|
||||
const SIM_LINK_RE = /sim:([a-z_]+)\/([A-Za-z0-9_-]+)/g
|
||||
/** `/api/files/serve/[s3/|blob/]<key>` (key may be raw or percent-encoded, ends at a delimiter). */
|
||||
const SERVE_URL_RE = /\/api\/files\/serve\/(s3\/|blob\/)?([^\s)"'<>?]+)/g
|
||||
/** `/api/files/view/<workspaceFileId>`. */
|
||||
const VIEW_URL_RE = /\/api\/files\/view\/([A-Za-z0-9_-]+)/g
|
||||
/** In-app `/workspace/<workspaceId>/files/<workspaceFileId>` embed path. */
|
||||
const INAPP_FILE_RE = /\/workspace\/([A-Za-z0-9-]+)\/files\/([A-Za-z0-9_-]+)/g
|
||||
|
||||
export function rewriteForkContentRefs(content: string, maps: ForkContentRefMaps): string {
|
||||
if (!content) return content
|
||||
|
||||
const idMapForSimKind: Record<string, ReadonlyMap<string, string> | undefined> = {
|
||||
file: maps.fileIds,
|
||||
folder: maps.folders,
|
||||
table: maps.tables,
|
||||
knowledge: maps.knowledgeBases,
|
||||
workflow: maps.workflows,
|
||||
skill: maps.skills,
|
||||
}
|
||||
|
||||
let result = content.replace(SIM_LINK_RE, (full, kind: string, id: string) => {
|
||||
const target = idMapForSimKind[kind]?.get(id)
|
||||
return target ? `sim:${kind}/${target}` : full
|
||||
})
|
||||
|
||||
result = result.replace(SERVE_URL_RE, (full, prefix: string | undefined, encodedKey: string) => {
|
||||
if (!maps.fileKeys) return full
|
||||
let key: string
|
||||
try {
|
||||
key = decodeURIComponent(encodedKey)
|
||||
} catch {
|
||||
return full
|
||||
}
|
||||
const target = maps.fileKeys.get(key)
|
||||
return target ? `/api/files/serve/${prefix ?? ''}${encodeURIComponent(target)}` : full
|
||||
})
|
||||
|
||||
result = result.replace(VIEW_URL_RE, (full, id: string) => {
|
||||
const target = maps.fileIds?.get(id)
|
||||
return target ? `/api/files/view/${target}` : full
|
||||
})
|
||||
|
||||
// Both-or-nothing: rewrite only when the workspace id is the mapped source AND the file id
|
||||
// resolves, so we never emit a child-workspace path with an unmapped (guaranteed-404) id -
|
||||
// matching the serve/view branches and rewriteForkResourceUrls. A foreign workspace id or an
|
||||
// unmapped file id leaves the original path untouched (graceful).
|
||||
result = result.replace(INAPP_FILE_RE, (full, wsId: string, fileId: string) => {
|
||||
if (maps.workspaceId?.from !== wsId) return full
|
||||
const mappedFile = maps.fileIds?.get(fileId)
|
||||
return mappedFile ? `/workspace/${maps.workspaceId.to}/files/${mappedFile}` : full
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app `/workspace/<wsId>/(w|tables|knowledge|files)/<resourceId>` deep link - the form a TABLE
|
||||
* CELL renders as a resource chip (`resolveSimResourceKind`), but ONLY when `wsId` matches the
|
||||
* current workspace. The id charset stops at delimiters.
|
||||
*/
|
||||
const RESOURCE_URL_RE =
|
||||
/\/workspace\/([A-Za-z0-9-]+)\/(w|tables|knowledge|files)\/([A-Za-z0-9_-]+)/g
|
||||
|
||||
/**
|
||||
* Rewrite the in-workspace resource deep links a copied table cell renders as a resource chip, so
|
||||
* the chip keeps resolving after a cross-workspace copy (a cell chip renders only when the URL's
|
||||
* workspace id is the current workspace, so a stale source id silently degrades to a plain link).
|
||||
* Repoints both the workspace id and the resource id at the child copy, per section: `w` ->
|
||||
* workflows, `tables` -> tables, `knowledge` -> knowledge bases, `files` -> file ids.
|
||||
*
|
||||
* Both-or-nothing: a match is rewritten ONLY when its workspace id is the mapped source AND its
|
||||
* resource id is in that section's map - otherwise it is left UNCHANGED. Emitting a child-workspace
|
||||
* URL with an unmapped id would render a "Not found" chip (worse than the graceful plain link an
|
||||
* unchanged URL degrades to). Distinct from {@link rewriteForkContentRefs}: table cells do NOT
|
||||
* specially render the `sim:` / serve / view forms that skill + markdown bodies do.
|
||||
*/
|
||||
export function rewriteForkResourceUrls(content: string, maps: ForkContentRefMaps): string {
|
||||
if (!content || !maps.workspaceId) return content
|
||||
const { from, to } = maps.workspaceId
|
||||
const idMapForSection: Record<string, ReadonlyMap<string, string> | undefined> = {
|
||||
w: maps.workflows,
|
||||
tables: maps.tables,
|
||||
knowledge: maps.knowledgeBases,
|
||||
files: maps.fileIds,
|
||||
}
|
||||
return content.replace(RESOURCE_URL_RE, (full, wsId: string, section: string, id: string) => {
|
||||
if (wsId !== from) return full
|
||||
const mappedId = idMapForSection[section]?.get(id)
|
||||
return mappedId ? `/workspace/${to}/${section}/${mappedId}` : full
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { remapForkFileUploadValue } from '@/ee/workspace-forking/lib/remap/remap-files'
|
||||
|
||||
const map = (entries: Record<string, string>) => (key: string) => entries[key] ?? null
|
||||
|
||||
describe('remapForkFileUploadValue', () => {
|
||||
it('rewrites a copied single object key, preserving other fields', () => {
|
||||
const value = { key: 'src/a.pdf', name: 'a.pdf', type: 'application/pdf', size: 10 }
|
||||
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
|
||||
expect(result).toEqual({ key: 'child/a.pdf', name: 'a.pdf', type: 'application/pdf', size: 10 })
|
||||
})
|
||||
|
||||
it('clears a single object whose file was not copied', () => {
|
||||
const value = { key: 'src/a.pdf', name: 'a.pdf' }
|
||||
expect(remapForkFileUploadValue(value, map({}))).toBe('')
|
||||
})
|
||||
|
||||
it('remaps copied items and drops uncopied ones in an array', () => {
|
||||
const value = [
|
||||
{ key: 'src/a.pdf', name: 'a.pdf' },
|
||||
{ key: 'src/b.pdf', name: 'b.pdf' },
|
||||
]
|
||||
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
|
||||
expect(result).toEqual([{ key: 'child/a.pdf', name: 'a.pdf' }])
|
||||
})
|
||||
|
||||
it('handles a JSON-stringified value and re-serializes', () => {
|
||||
const value = JSON.stringify({ key: 'src/a.pdf', name: 'a.pdf' })
|
||||
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
|
||||
expect(result).toBe(JSON.stringify({ key: 'child/a.pdf', name: 'a.pdf' }))
|
||||
})
|
||||
|
||||
it('falls back to the path field when there is no key', () => {
|
||||
const value = { path: 'src/a.pdf', name: 'a.pdf' }
|
||||
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
|
||||
expect(result).toEqual({ path: 'child/a.pdf', name: 'a.pdf' })
|
||||
})
|
||||
|
||||
it('returns the value unchanged when no items match', () => {
|
||||
const value = { key: 'src/a.pdf', name: 'a.pdf' }
|
||||
const sameKey = map({ 'src/a.pdf': 'src/a.pdf' })
|
||||
expect(remapForkFileUploadValue(value, sameKey)).toBe(value)
|
||||
})
|
||||
|
||||
it('returns empty/unparseable values untouched', () => {
|
||||
expect(remapForkFileUploadValue('', map({}))).toBe('')
|
||||
expect(remapForkFileUploadValue(null, map({}))).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* `file-upload` subblock remapping for fork/promote.
|
||||
*
|
||||
* A `file-upload` value is a workspace-file reference (or array of them) stored as
|
||||
* objects `{ key, name, ... }` where `key` is the object-storage key (NOT the
|
||||
* `workspace_files.id`). Forking copies the blob to a new key; this rewrites each
|
||||
* reference's key to the copied key, preserving the rest of the object. References
|
||||
* whose file was not copied are dropped (the field is emptied) rather than left
|
||||
* pointing at another workspace's blob. External `file-selector` references
|
||||
* (provider file ids, credential-scoped) are NOT handled here - they carry over
|
||||
* unchanged.
|
||||
*/
|
||||
|
||||
function parseMaybeJson(value: unknown): { value: unknown; serialized: boolean } {
|
||||
if (typeof value !== 'string') return { value, serialized: false }
|
||||
const trimmed = value.trim()
|
||||
const looksJson =
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
if (!looksJson) return { value, serialized: false }
|
||||
try {
|
||||
return { value: JSON.parse(trimmed), serialized: true }
|
||||
} catch {
|
||||
return { value, serialized: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** The field a file-upload item uses as its storage key, and that key's value. */
|
||||
function fileItemKeyField(item: unknown): { field: 'key' | 'path' | 'name'; key: string } | null {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
||||
const record = item as Record<string, unknown>
|
||||
for (const field of ['key', 'path', 'name'] as const) {
|
||||
const value = record[field]
|
||||
if (typeof value === 'string' && value.trim().length > 0) return { field, key: value }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate the workspace-file storage keys referenced by a `file-upload` subblock
|
||||
* value (single object, array, or JSON-string form). Used at promote time to emit each
|
||||
* workspace file as a `file` reference (keyed by storage key) so it surfaces in the
|
||||
* scan / unmapped set and can be copied into the target. Deduplicated, order-preserving.
|
||||
*/
|
||||
export function collectForkFileUploadKeys(value: unknown): string[] {
|
||||
const parsed = parseMaybeJson(value)
|
||||
const items = Array.isArray(parsed.value)
|
||||
? (parsed.value as unknown[])
|
||||
: parsed.value
|
||||
? [parsed.value]
|
||||
: []
|
||||
const keys: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of items) {
|
||||
const keyInfo = fileItemKeyField(item)
|
||||
if (!keyInfo || seen.has(keyInfo.key)) continue
|
||||
seen.add(keyInfo.key)
|
||||
keys.push(keyInfo.key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap a `file-upload` subblock value. `resolveFileKey(sourceKey)` returns the
|
||||
* copied target storage key, or null when the file was not copied (drop the ref).
|
||||
*/
|
||||
export function remapForkFileUploadValue(
|
||||
value: unknown,
|
||||
resolveFileKey: (sourceKey: string) => string | null
|
||||
): unknown {
|
||||
const parsed = parseMaybeJson(value)
|
||||
const isArray = Array.isArray(parsed.value)
|
||||
const items = isArray ? (parsed.value as unknown[]) : parsed.value ? [parsed.value] : []
|
||||
if (items.length === 0) return value
|
||||
|
||||
const next: unknown[] = []
|
||||
let changed = false
|
||||
for (const item of items) {
|
||||
const keyInfo = fileItemKeyField(item)
|
||||
if (!keyInfo) {
|
||||
next.push(item)
|
||||
continue
|
||||
}
|
||||
const targetKey = resolveFileKey(keyInfo.key)
|
||||
if (targetKey == null) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (targetKey === keyInfo.key) {
|
||||
next.push(item)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
next.push({ ...(item as Record<string, unknown>), [keyInfo.field]: targetKey })
|
||||
}
|
||||
|
||||
if (!changed) return value
|
||||
if (next.length === 0) return ''
|
||||
if (isArray) return parsed.serialized ? JSON.stringify(next) : next
|
||||
return parsed.serialized ? JSON.stringify(next[0]) : next[0]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TableSchema } from '@/lib/table/types'
|
||||
import {
|
||||
buildForkBlockIdResolver,
|
||||
deriveForkBlockId,
|
||||
} from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import { remapForkTableWorkflowGroups } from '@/ee/workspace-forking/lib/remap/remap-table-groups'
|
||||
|
||||
describe('remapForkTableWorkflowGroups', () => {
|
||||
it('remaps a manual group workflowId and outputs[].blockId to child ids', () => {
|
||||
const map = new Map([['src-wf', 'child-wf']])
|
||||
const schema: TableSchema = {
|
||||
columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }],
|
||||
workflowGroups: [
|
||||
{
|
||||
id: 'g1',
|
||||
workflowId: 'src-wf',
|
||||
outputs: [{ blockId: 'src-block', path: 'out', columnName: 'col_1' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = remapForkTableWorkflowGroups(schema, map)
|
||||
const group = result.workflowGroups?.[0]
|
||||
expect(group?.workflowId).toBe('child-wf')
|
||||
expect(group?.outputs[0].blockId).toBe(deriveForkBlockId('child-wf', 'src-block'))
|
||||
expect(group?.outputs[0].columnName).toBe('col_1')
|
||||
expect(result.columns[0].id).toBe('col_1')
|
||||
expect(result.columns[0].workflowGroupId).toBe('g1')
|
||||
})
|
||||
|
||||
// Promote threads its persisted-pair resolver: a paired block resolves to the pair's target id
|
||||
// (on push, the parent's ORIGINAL id - never the derive); an unpaired block falls back to the
|
||||
// derive, matching the workflow write path.
|
||||
it('prefers a provided block-id resolver (persisted pair) over the derive, deriving unpaired blocks', () => {
|
||||
const map = new Map([['src-wf', 'child-wf']])
|
||||
const schema: TableSchema = {
|
||||
columns: [],
|
||||
workflowGroups: [
|
||||
{
|
||||
id: 'g1',
|
||||
workflowId: 'src-wf',
|
||||
outputs: [
|
||||
{ blockId: 'src-block', path: 'out', columnName: 'col_1' },
|
||||
{ blockId: 'src-unpaired', path: 'out2', columnName: 'col_2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const resolver = buildForkBlockIdResolver(true, {
|
||||
parentToChild: new Map([
|
||||
['src-block', { targetBlockId: 'original-parent-block', targetWorkflowId: 'child-wf' }],
|
||||
]),
|
||||
childToParent: new Map(),
|
||||
})
|
||||
const result = remapForkTableWorkflowGroups(schema, map, resolver)
|
||||
const outputs = result.workflowGroups?.[0].outputs
|
||||
expect(outputs?.[0].blockId).toBe('original-parent-block')
|
||||
expect(outputs?.[1].blockId).toBe(deriveForkBlockId('child-wf', 'src-unpaired'))
|
||||
})
|
||||
|
||||
it('drops a group whose backing workflow was not copied and clears its column wiring', () => {
|
||||
const schema: TableSchema = {
|
||||
columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }],
|
||||
workflowGroups: [
|
||||
{
|
||||
id: 'g1',
|
||||
workflowId: 'missing-wf',
|
||||
outputs: [{ blockId: 'b', path: 'p', columnName: 'col_1' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = remapForkTableWorkflowGroups(schema, new Map())
|
||||
expect(result.workflowGroups).toHaveLength(0)
|
||||
expect(result.columns[0].workflowGroupId).toBeUndefined()
|
||||
expect(result.columns[0].id).toBe('col_1')
|
||||
})
|
||||
|
||||
it('leaves enrichment groups (empty workflowId) untouched', () => {
|
||||
const schema: TableSchema = {
|
||||
columns: [],
|
||||
workflowGroups: [
|
||||
{
|
||||
id: 'g1',
|
||||
workflowId: '',
|
||||
enrichmentId: 'enr',
|
||||
outputs: [{ blockId: '', path: '', columnName: 'col_1', outputId: 'o' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = remapForkTableWorkflowGroups(schema, new Map([['src-wf', 'child-wf']]))
|
||||
expect(result.workflowGroups?.[0]).toEqual(schema.workflowGroups?.[0])
|
||||
})
|
||||
|
||||
it('returns the schema unchanged when there are no groups', () => {
|
||||
const schema: TableSchema = { columns: [{ id: 'col_1', name: 'A', type: 'string' }] }
|
||||
expect(remapForkTableWorkflowGroups(schema, new Map())).toBe(schema)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TableSchema } from '@/lib/table/types'
|
||||
import {
|
||||
deriveForkBlockId,
|
||||
type ForkBlockIdResolver,
|
||||
} from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
|
||||
/**
|
||||
* Remap the workflow/block references embedded in a copied table's schema so its
|
||||
* workflow groups keep working in the child workspace. `workflowGroups[].workflowId`
|
||||
* is rewritten through the source→child workflow identity map, and each
|
||||
* `outputs[].blockId` is rewritten through `resolveBlockId` - which MUST be the
|
||||
* same resolver that assigns the target workflows' block ids, or the outputs
|
||||
* point at nonexistent blocks. Fork-create omits it and defaults to the
|
||||
* deterministic {@link deriveForkBlockId} (a fresh child has no persisted
|
||||
* pairs, matching `copyWorkflowStateIntoTarget`); promote passes its
|
||||
* persisted-pair resolver (a push keeps the parent's ORIGINAL block ids, which
|
||||
* never equal the derive). Manual groups whose backing workflow was not
|
||||
* copied are dropped, and any columns wired to a dropped group have their
|
||||
* `workflowGroupId` cleared. Enrichment groups (empty `workflowId`) and column
|
||||
* ids are left untouched.
|
||||
*/
|
||||
export function remapForkTableWorkflowGroups(
|
||||
schema: TableSchema,
|
||||
workflowIdMap: Map<string, string>,
|
||||
resolveBlockId: ForkBlockIdResolver = deriveForkBlockId
|
||||
): TableSchema {
|
||||
const groups = schema.workflowGroups ?? []
|
||||
if (groups.length === 0) return schema
|
||||
|
||||
const droppedGroupIds = new Set<string>()
|
||||
const remappedGroups = groups.flatMap((group) => {
|
||||
if (!group.workflowId) return [group]
|
||||
const childWorkflowId = workflowIdMap.get(group.workflowId)
|
||||
if (!childWorkflowId) {
|
||||
droppedGroupIds.add(group.id)
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
...group,
|
||||
workflowId: childWorkflowId,
|
||||
outputs: group.outputs.map((output) => ({
|
||||
...output,
|
||||
blockId: output.blockId
|
||||
? resolveBlockId(childWorkflowId, output.blockId)
|
||||
: output.blockId,
|
||||
})),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const columns =
|
||||
droppedGroupIds.size === 0
|
||||
? schema.columns
|
||||
: schema.columns.map((column) =>
|
||||
column.workflowGroupId && droppedGroupIds.has(column.workflowGroupId)
|
||||
? { ...column, workflowGroupId: undefined }
|
||||
: column
|
||||
)
|
||||
|
||||
return { ...schema, columns, workflowGroups: remappedGroups }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getSocketServerUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('WorkspaceForkSocket')
|
||||
|
||||
async function postToRealtime(path: string, workflowId: string): Promise<void> {
|
||||
const response = await fetch(`${getSocketServerUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET },
|
||||
body: JSON.stringify({ workflowId }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path} responded ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify connected canvas clients that a fork promote/rollback force-replaced a
|
||||
* workflow's state. This mirrors a mothership edit rather than a passive ping:
|
||||
*
|
||||
* - `workflow-updated` makes each client reload the workflow from the API the same
|
||||
* way it reacts to an external full-state edit (copilot / state route), deferring
|
||||
* while a local diff or unsaved operations are pending so it never clobbers
|
||||
* in-flight work. Without this the canvas keeps the stale state and a later local
|
||||
* edit would overwrite the freshly-synced state.
|
||||
* - `workflow-deployed` refreshes the deployment indicator (the promote/rollback also
|
||||
* changed which version is deployed).
|
||||
*
|
||||
* Best-effort and independent: each notification is attempted regardless of the
|
||||
* other, and a failure only warns - it never blocks the promote/rollback.
|
||||
*/
|
||||
export async function notifyForkWorkflowChanged(workflowId: string): Promise<void> {
|
||||
const results = await Promise.allSettled([
|
||||
postToRealtime('/api/workflow-updated', workflowId),
|
||||
postToRealtime('/api/workflow-deployed', workflowId),
|
||||
])
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn('Fork sync socket notification failed', { workflowId, error: result.reason })
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user