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
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:
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user