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

357 lines
14 KiB
TypeScript

import type {
ForkCopyableKind,
ForkCopyableUnmapped,
PromoteCopyResources,
} from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
type SerializableForkContentRefMaps,
serializeContentRefMaps,
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
import { type BlobCopyTask, planForkFileCopies } from '@/ee/workspace-forking/lib/copy/copy-files'
import type { ForkContentPlan } from '@/ee/workspace-forking/lib/copy/copy-resources'
import {
copyForkResourceContainers,
planForkMappedKbDocumentCopies,
} from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import {
deleteEdgeMappingsByChildResources,
type ForkMappingUpsert,
resourceTypeToForkKind,
upsertEdgeMappings,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import type {
ForkReferenceResolver,
ForkRemapKind,
} from '@/ee/workspace-forking/lib/remap/remap-references'
/**
* The source ids selected for copy at promote, validated against the plan's copyable
* candidates. Exactly the sync-copyable kinds (`forkCopyableKindSchema`): workflow-publishing
* MCP servers are fork-create-only (never a promote copy candidate), so they have no slot here.
*/
export interface PromoteCopySelection {
customTools: string[]
skills: string[]
tables: string[]
knowledgeBases: string[]
/** Workspace files to copy, identified by storage key (not `workspace_files.id`). */
files: string[]
/** External MCP servers to copy as config rows (OAuth tokens never copied - re-auth). */
mcpServers: string[]
}
/**
* Each copyable kind to its key in {@link PromoteCopySelection}. Keyed on `ForkCopyableKind`
* (the wire contract enum) so TS fails to compile if the copyable enum grows a kind without a
* selection key here, keeping the two in lockstep.
*/
export const FORK_COPYABLE_KIND_TO_SELECTION_KEY: Record<
ForkCopyableKind,
keyof PromoteCopySelection
> = {
'knowledge-base': 'knowledgeBases',
table: 'tables',
'custom-tool': 'customTools',
skill: 'skills',
file: 'files',
'mcp-server': 'mcpServers',
}
/**
* Intersect the user's requested copy with the plan's actual copyable candidates (referenced or
* not, always unmapped + still existing in the source), so a crafted request can never copy an
* arbitrary resource. Returns the validated selection plus the set of `${kind}:${sourceId}`
* references the copy will resolve, for the pre-copy sync gate - an unreferenced candidate's key
* simply matches no reference there, which is harmless.
*/
export function buildPromoteCopySelection(
requested: PromoteCopyResources | undefined,
copyableUnmapped: ForkCopyableUnmapped[]
): { selection: PromoteCopySelection; willResolve: Set<string> } {
const allowed = new Map<string, Set<string>>()
for (const candidate of copyableUnmapped) {
const set = allowed.get(candidate.kind)
if (set) set.add(candidate.sourceId)
else allowed.set(candidate.kind, new Set([candidate.sourceId]))
}
const selection: PromoteCopySelection = {
customTools: [],
skills: [],
tables: [],
knowledgeBases: [],
files: [],
mcpServers: [],
}
const willResolve = new Set<string>()
const apply = (
kind: keyof typeof FORK_COPYABLE_KIND_TO_SELECTION_KEY,
ids: string[] | undefined
) => {
const allowedIds = allowed.get(kind)
if (!allowedIds || !ids) return
const key = FORK_COPYABLE_KIND_TO_SELECTION_KEY[kind]
for (const id of ids) {
if (!allowedIds.has(id)) continue
selection[key].push(id)
willResolve.add(`${kind}:${id}`)
}
}
apply('knowledge-base', requested?.knowledgeBases)
apply('table', requested?.tables)
apply('custom-tool', requested?.customTools)
apply('skill', requested?.skills)
apply('file', requested?.files)
apply('mcp-server', requested?.mcpServers)
return { selection, willResolve }
}
/** Whether any resource is selected for copy. */
export function hasPromoteCopySelection(selection: PromoteCopySelection): boolean {
return (
selection.customTools.length > 0 ||
selection.skills.length > 0 ||
selection.tables.length > 0 ||
selection.knowledgeBases.length > 0 ||
selection.files.length > 0 ||
selection.mcpServers.length > 0
)
}
/**
* Layer the just-copied resources' source->target ids on top of the plan's resolver, so the
* synced workflows' references to those resources resolve to the new copies. The base resolver
* (persisted mappings + env identity) is consulted for everything else.
*/
export function augmentForkResolver(
base: ForkReferenceResolver,
extra: Map<ForkRemapKind, Map<string, string>>
): ForkReferenceResolver {
return (kind, sourceId) => extra.get(kind)?.get(sourceId) ?? base(kind, sourceId)
}
export interface PromoteCopyResult {
contentPlan: ForkContentPlan
/** Copied source resource id -> new target id, by remap kind, for resolver augmentation. */
copyIdMapByKind: Map<ForkRemapKind, Map<string, string>>
/**
* Serialized in-content reference maps for the post-commit copy to rewrite copied skill bodies
* (their `sim:` links + embedded URLs) off the locked promote tx - carried through the durable
* content-copy payload, mirroring fork.
*/
contentRefMaps: SerializableForkContentRefMaps
/**
* File blob duplications for copied workspace files, run post-commit by the durable content-copy
* runner (no object-storage I/O inside the locked promote tx). Empty when no files were copied.
*/
blobTasks: BlobCopyTask[]
}
/**
* Copy the selected unmapped resources (referenced or not) a sync brings into the target (reusing
* the fork copy pipeline), then persist the source<->target id map in the direction the edge expects: a pull
* fills the existing `(parent, child=null)` row (fill-null), a push replaces any prior
* `(parent, child)` row keyed on the source child resource (delete-then-insert). This covers:
* - the user-selected copyable containers (KB / table / custom-tool / skill) and workspace files,
* - documents referenced under a copied knowledge base (auto-placed under that copied KB),
* - documents referenced under an ALREADY-mapped (existing) KB - copied into that existing KB so
* the `document-selector` reference remaps instead of being cleared.
*
* The heavy content (table rows, KB documents + embeddings, file blobs) is returned as a content
* plan + blob tasks for a post-commit, best-effort fill; no object-storage I/O runs inside the
* locked promote tx. Always safe to call (a no-op when nothing is selected and nothing references
* a mapped-KB document).
*/
export async function copyPromoteUnmappedResources(params: {
tx: DbOrTx
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
direction: 'push' | 'pull'
userId: string
now: Date
selection: PromoteCopySelection
workflowIdMap: Map<string, string>
/** source folder id -> target folder id, so copied skill/markdown bodies rewrite `sim:folder/<id>`. */
folderIdMap: Map<string, string>
/** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */
resolver: ForkReferenceResolver
/**
* The SAME block-id resolver the sync's workflow writes use (persisted pairs preferred over
* derive), so copied tables' workflow-group `outputs[].blockId` point at the blocks the sync
* actually writes - on push the parent keeps its ORIGINAL block ids, never the derive.
*/
resolveBlockId: ForkBlockIdResolver
/**
* Knowledge-document ids the synced workflows reference, already scanned once in the promote
* plan and threaded in so the copy doesn't re-scan every source state inside the locked tx.
* `copyForkResourceContainers` / `planForkMappedKbDocumentCopies` place only those whose parent
* KB is in this copy (or already mapped), so an extra id is FK-safe and simply skipped.
*/
referencedDocumentIds: string[]
}): Promise<PromoteCopyResult> {
const {
tx,
edge,
sourceWorkspaceId,
targetWorkspaceId,
direction,
userId,
now,
selection,
workflowIdMap,
folderIdMap,
resolver,
resolveBlockId,
referencedDocumentIds,
} = params
const result = await copyForkResourceContainers({
tx,
sourceWorkspaceId,
childWorkspaceId: targetWorkspaceId,
userId,
now,
selection: {
customTools: selection.customTools,
skills: selection.skills,
// External MCP servers copy as config rows (like fork); workflow-publishing MCP servers
// are fork-create-only shells (the shared pipeline still takes the slot - pass it empty).
mcpServers: selection.mcpServers,
workflowMcpServers: [],
tables: selection.tables,
knowledgeBases: selection.knowledgeBases,
},
workflowIdMap,
referencedDocumentIds,
// A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs
// rewritten through the same plan resolver that remaps subblock-value env refs.
resolveEnvName: (key) => resolver('env-var', key),
resolveBlockId,
})
// Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob
// duplications deferred to the post-commit runner.
const fileResult =
selection.files.length > 0
? await planForkFileCopies({
tx,
sourceWorkspaceId,
childWorkspaceId: targetWorkspaceId,
userId,
fileKeys: selection.files,
now,
})
: {
keyMap: new Map<string, string>(),
idMap: new Map<string, string>(),
blobTasks: [] as BlobCopyTask[],
}
// U-docs: documents referenced under an already-mapped (not copied this sync) KB. Skip any doc
// already placed under a copied KB above (its parent KB is in this copy), so a doc is never
// copied twice.
const containerDocMap = result.idMap.get('knowledge_document') ?? new Map<string, string>()
const mappedKbDocs = await planForkMappedKbDocumentCopies({
tx,
resolver,
referencedDocumentIds,
alreadyCopiedSourceDocIds: new Set(containerDocMap.keys()),
})
result.contentPlan.documents.push(...mappedKbDocs.documents)
// Persist every copied resource's mapping (containers + files + U-docs) so a re-sync resolves
// the copy instead of re-copying it. Files map by storage key; U-docs add knowledge_document rows.
const fileMappingEntries: ForkMappingUpsert[] = Array.from(
fileResult.keyMap,
([source, child]) => ({
resourceType: 'file' as const,
parentResourceId: source,
childResourceId: child,
})
)
await persistPromoteCopiedMappings(tx, edge.childWorkspaceId, userId, direction, [
...result.mappingEntries,
...fileMappingEntries,
...mappedKbDocs.mappingEntries,
])
const copyIdMapByKind = new Map<ForkRemapKind, Map<string, string>>()
for (const [resourceType, sourceToTarget] of result.idMap) {
const kind = resourceTypeToForkKind(resourceType)
if (!kind) continue
copyIdMapByKind.set(kind, sourceToTarget)
}
if (fileResult.keyMap.size > 0) copyIdMapByKind.set('file', fileResult.keyMap)
// Merge the container's copied-KB document map with the U-docs map so every copied document
// (under a copied KB or into an existing one) remaps its `document-selector` reference.
const documentIdMap = new Map<string, string>([...containerDocMap, ...mappedKbDocs.docIdMap])
if (documentIdMap.size > 0) copyIdMapByKind.set('knowledge-document', documentIdMap)
// Serialized maps for the post-commit content rewrite (run off the locked promote tx). Mirrors
// fork: workspace + workflow + folder ids plus this copy's own file/skill/table/KB maps, so a
// copied skill body / markdown blob's `sim:` links + embedded file URLs resolve to the new target
// copies instead of the source.
const contentRefMaps = serializeContentRefMaps({
workspaceId: { from: sourceWorkspaceId, to: targetWorkspaceId },
workflows: workflowIdMap,
folders: folderIdMap,
fileKeys: fileResult.keyMap,
fileIds: fileResult.idMap,
skills: result.idMap.get('skill'),
tables: result.idMap.get('table'),
knowledgeBases: result.idMap.get('knowledge_base'),
})
return {
contentPlan: result.contentPlan,
copyIdMapByKind,
contentRefMaps,
blobTasks: fileResult.blobTasks,
}
}
/**
* Persist the copied resources' id mappings for the edge. The copy returns entries oriented
* source(parent)->target(child); a pull matches that orientation directly (fill-null upsert), a
* push swaps it (the parent side is the new TARGET) and first drops any prior row keyed on the
* source child resource so a changed target can't leak a second mapping.
*/
export async function persistPromoteCopiedMappings(
tx: DbOrTx,
childWorkspaceId: string,
userId: string,
direction: 'push' | 'pull',
entries: ForkMappingUpsert[]
): Promise<void> {
if (entries.length === 0) return
if (direction === 'pull') {
await upsertEdgeMappings(tx, childWorkspaceId, userId, entries)
return
}
// Push: re-key on the source child resource. Skip any entry with a null child id (copy entries
// always carry one; the guard narrows the type so neither the swap nor the delete needs a cast).
// After the swap every childResourceId is the original (non-null) parent id, keyed for the
// delete-then-insert that prevents a changed target from leaking a second mapping.
const swapped: ForkMappingUpsert[] = []
const deleteKeys: Array<{
resourceType: ForkMappingUpsert['resourceType']
childResourceId: string
}> = []
for (const entry of entries) {
if (entry.childResourceId == null) continue
swapped.push({
resourceType: entry.resourceType,
parentResourceId: entry.childResourceId,
childResourceId: entry.parentResourceId,
})
deleteKeys.push({ resourceType: entry.resourceType, childResourceId: entry.parentResourceId })
}
if (swapped.length === 0) return
await deleteEdgeMappingsByChildResources(tx, childWorkspaceId, deleteKeys)
await upsertEdgeMappings(tx, childWorkspaceId, userId, swapped)
}