d25d482dc2
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
437 lines
17 KiB
TypeScript
437 lines
17 KiB
TypeScript
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|