chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,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'
}