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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,88 @@
/**
* @vitest-environment node
*/
import { isValidUuid } from '@sim/utils/id'
import { describe, expect, it } from 'vitest'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
EMPTY_FORK_BLOCK_MAP,
type ForkBlockMap,
} from '@/ee/workspace-forking/lib/remap/block-identity'
describe('deriveForkBlockId', () => {
const targetA = 'wf-target-a'
const targetB = 'wf-target-b'
const block1 = 'block-1'
const block2 = 'block-2'
it('is deterministic for the same (targetWorkflowId, sourceBlockId)', () => {
expect(deriveForkBlockId(targetA, block1)).toBe(deriveForkBlockId(targetA, block1))
})
it('yields different ids for the same source block in different target workflows', () => {
expect(deriveForkBlockId(targetA, block1)).not.toBe(deriveForkBlockId(targetB, block1))
})
it('yields different ids for different source blocks in the same target workflow', () => {
expect(deriveForkBlockId(targetA, block1)).not.toBe(deriveForkBlockId(targetA, block2))
})
it('produces a valid UUID string (v5)', () => {
const id = deriveForkBlockId(targetA, block1)
expect(isValidUuid(id)).toBe(true)
expect(id[14]).toBe('5')
})
it('does not collide the colon separator (a:bc vs ab:c)', () => {
expect(deriveForkBlockId('a', 'bc')).not.toBe(deriveForkBlockId('ab', 'c'))
})
})
describe('buildForkBlockIdResolver', () => {
const parentWf = 'wf-parent'
const childWf = 'wf-child'
const parentBlock = 'block-parent'
// The pair the fork created: child block derived from the parent block.
const childBlock = deriveForkBlockId(childWf, parentBlock)
const seededMap: ForkBlockMap = {
parentToChild: new Map([
[parentBlock, { targetBlockId: childBlock, targetWorkflowId: childWf }],
]),
childToParent: new Map([
[childBlock, { targetBlockId: parentBlock, targetWorkflowId: parentWf }],
]),
}
it('push maps a child block back to the parent ORIGINAL id (keeps the webhook URL stable)', () => {
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve(parentWf, childBlock)).toBe(parentBlock)
// The bug this fixes: without the map, push would re-derive and re-key the parent block.
expect(pushResolve(parentWf, childBlock)).not.toBe(deriveForkBlockId(parentWf, childBlock))
})
it('pull maps a parent block to its existing child id', () => {
const pullResolve = buildForkBlockIdResolver(true, seededMap)
expect(pullResolve(childWf, parentBlock)).toBe(childBlock)
})
it('derives (does NOT reuse) when the target workflow was re-created (different id)', () => {
// Parent workflow archived + re-created as wf-parent-2: the pair points at the old
// workflow, so reusing parentBlock there would collide on the global block PK. Derive.
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve('wf-parent-2', childBlock)).toBe(
deriveForkBlockId('wf-parent-2', childBlock)
)
expect(pushResolve('wf-parent-2', childBlock)).not.toBe(parentBlock)
})
it('derives a fresh id for a source block with no recorded pair (added since last sync)', () => {
const pushResolve = buildForkBlockIdResolver(false, seededMap)
expect(pushResolve(parentWf, 'block-new')).toBe(deriveForkBlockId(parentWf, 'block-new'))
})
it('derives everything when the map is empty (fork creation)', () => {
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
expect(resolve(childWf, parentBlock)).toBe(deriveForkBlockId(childWf, parentBlock))
})
})
@@ -0,0 +1,100 @@
import { createHash } from 'node:crypto'
/**
* Fixed namespace UUID for fork block-identity derivation. Changing this value
* would re-key every forked workflow's block ids, breaking webhook URLs and
* external block references (table workflow groups, chat output configs) across
* promotes - so it must never change.
*/
const FORK_BLOCK_NAMESPACE = '6f1c0e2a-9b3d-5e47-8a1c-2d4f6b8e0c13'
function uuidToBytes(uuid: string): Buffer {
return Buffer.from(uuid.replace(/-/g, ''), 'hex')
}
/**
* Deterministic UUIDv5 (SHA-1) of `name` within `namespace`. The same inputs
* always yield the same UUID, which is how fork block identity stays stable.
*
* SHA-1 is mandated by RFC 4122 for UUIDv5 and is used here only for deterministic id derivation,
* never for secrecy or integrity — not a security use of the algorithm. Swapping it would change
* every derived id, breaking webhook URLs and stored block-id references across existing forks
* (see {@link FORK_BLOCK_NAMESPACE}).
*/
function uuidV5(name: string, namespace: string): string {
const hash = createHash('sha1')
hash.update(uuidToBytes(namespace)) // lgtm[js/weak-cryptographic-algorithm]
hash.update(Buffer.from(name, 'utf8')) // lgtm[js/weak-cryptographic-algorithm]
const bytes = hash.digest().subarray(0, 16)
bytes[6] = (bytes[6] & 0x0f) | 0x50
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = bytes.toString('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/**
* Derive the target block id for a source block copied into a target workflow.
*
* Identity is deterministic in `(targetWorkflowId, sourceBlockId)`, so a logical
* block keeps the same target id across every promote. This is what keeps trigger
* webhook URLs consistent and keeps external block-id references (table workflow
* groups, chat output configs, sim_trigger_state) valid across promotes. A source
* block that no longer exists simply has no derived target, so the target block
* disappears on the next force-replace.
*/
export function deriveForkBlockId(targetWorkflowId: string, sourceBlockId: string): string {
return uuidV5(`${targetWorkflowId}:${sourceBlockId}`, FORK_BLOCK_NAMESPACE)
}
/** A persisted counterpart: the target block id plus the workflow it belongs to. */
export interface ForkBlockMapEntry {
targetBlockId: string
/** The target-side workflow the pair belongs to (childWorkflowId for parentToChild). */
targetWorkflowId: string
}
/** Persisted block-identity pairs for an edge, indexed for both promote directions. */
export interface ForkBlockMap {
/** parent block id -> { child block, child workflow } (pull/create resolve source=parent). */
parentToChild: ReadonlyMap<string, ForkBlockMapEntry>
/** child block id -> { parent block, parent workflow } (push resolves source=child). */
childToParent: ReadonlyMap<string, ForkBlockMapEntry>
}
/** An empty map - fork creation has no prior pairs, so every block id is derived fresh. */
export const EMPTY_FORK_BLOCK_MAP: ForkBlockMap = {
parentToChild: new Map(),
childToParent: new Map(),
}
/** Resolve a source block to its target block id for a promote (map-or-derive). */
export type ForkBlockIdResolver = (targetWorkflowId: string, sourceBlockId: string) => string
/**
* Build the block-id resolver a promote uses to assign target block ids. It reuses the
* persisted counterpart when one exists AND that pair belongs to the workflow being written,
* else falls back to {@link deriveForkBlockId} (blocks added since the last sync; fork
* creation, which has no map). `sourceIsParent` is true on pull/create (source = parent) and
* false on push (source = child); it selects the lookup direction.
*
* The workflow guard is what makes a re-created target safe: if the original target workflow
* was archived and the promote creates a new one, the recorded pair points at the OLD
* workflow, so it no longer matches and we derive a fresh id - never reusing the archived
* workflow's block id (which would collide on the global `workflow_blocks` primary key).
*
* For a stable workflow this still maps each child block back to the parent's ORIGINAL id on
* push, keeping its trigger webhook URL fixed. The SAME resolver must back
* `copyWorkflowStateIntoTarget` (which writes the blocks) and `collectForkDependentReconfigs`
* (which keys the modal's override by target block id), or the two would disagree.
*/
export function buildForkBlockIdResolver(
sourceIsParent: boolean,
map: ForkBlockMap
): ForkBlockIdResolver {
const existing = sourceIsParent ? map.parentToChild : map.childToParent
return (targetWorkflowId, sourceBlockId) => {
const entry = existing.get(sourceBlockId)
if (entry && entry.targetWorkflowId === targetWorkflowId) return entry.targetBlockId
return deriveForkBlockId(targetWorkflowId, sourceBlockId)
}
}
@@ -0,0 +1,43 @@
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
import {
clearDependentsOnRemap,
remapForkSubBlocks,
type SubBlockTransform,
} from '@/ee/workspace-forking/lib/remap/remap-references'
/**
* Resolves a source resource reference to its copied child id, or null when the
* resource was not copied into the fork. Credentials are never copied (always
* null), so credential references are cleared.
*/
export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string | null
/**
* A `copyWorkflowStateIntoTarget` transform for the initial fork. Runs the shared
* fork remapper in `create` mode: copyable resources the user selected are
* rewritten to their child ids; references to resources that were not copied (and
* all credential references) are cleared so the child workflow's subblocks start
* empty; env-var `{{KEY}}` references are preserved (name-based, they resolve once
* the child defines the key).
*/
export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): SubBlockTransform {
return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged) => {
// Every resolution at fork-create IS a copy (the resolver is the copy id map), so all
// remapped keys carry copy provenance - copy-faithful dependents (column picks) survive.
// `blockType`/`canonicalModes` activate the mode policy: active basic remaps, active
// advanced (manual) passes through with its dependents, dormant members clear.
const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create', {
blockType,
canonicalModes,
isCopiedTarget: (kind, sourceId) => resolveCopied(kind, sourceId) != null,
})
if (result.canonicalModes) onCanonicalModesChanged?.(result.canonicalModes)
return clearDependentsOnRemap(
result.subBlocks,
blockType,
result.remappedKeys,
result.canonicalModes ?? canonicalModes,
result.copyRemappedKeys
)
}
}
@@ -0,0 +1,67 @@
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
type ForkRemapKind,
scanWorkflowReferences,
} from '@/ee/workspace-forking/lib/remap/remap-references'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
/** A block reduced to what the reference scanner reads (incl. canonical context for detection). */
interface ScannerBlock {
id: string
name: string
type: string
subBlocks: unknown
canonicalModes?: CanonicalModeOverrides
}
/**
* Unique source ids of one remap kind referenced across a set of blocks - both top-level
* selectors and nested tool params. Used at fork/promote time to decide which KB documents
* a copy must create placeholders for (so their references remap to the copied doc instead
* of being cleared). The resolver is irrelevant here (every reference is detected regardless
* of mapping), so a null resolver is passed.
*/
export function collectReferencedResourceIds(
blocks: ScannerBlock[],
kind: ForkRemapKind
): Set<string> {
const ids = new Set<string>()
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
if (reference.kind === kind) ids.add(reference.sourceId)
}
return ids
}
/**
* Map a workflow state's blocks to the `{ id, name, subBlocks }` shape the reference scanner
* consumes. Confines the `subBlocks as unknown` widening (the stored subblock record is opaque
* to the scanner, which re-narrows per subblock type) to one spot shared by every fork caller
* that scans a source workflow's references.
*/
export function toScannerBlocks(state: WorkflowState): ScannerBlock[] {
return Object.values(state.blocks).map((block) => ({
id: block.id,
name: block.name,
type: block.type,
subBlocks: block.subBlocks as unknown,
canonicalModes: block.data?.canonicalModes,
}))
}
/**
* Unique knowledge-document ids referenced across a set of source workflow states. Fork and
* promote use this to decide which KB documents a copy must pre-create placeholders for, so a
* `document-selector` reference remaps to the copied doc instead of being cleared.
*/
export function collectReferencedDocumentIds(states: Iterable<WorkflowState>): Set<string> {
const ids = new Set<string>()
for (const state of states) {
for (const docId of collectReferencedResourceIds(
toScannerBlocks(state),
'knowledge-document'
)) {
ids.add(docId)
}
}
return ids
}
@@ -0,0 +1,194 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
type ForkContentRefMaps,
rewriteForkContentRefs,
rewriteForkResourceUrls,
} from '@/ee/workspace-forking/lib/remap/remap-content-refs'
const SRC_KEY = 'workspace/SRC/1700000000000-deadbeef-photo.png'
const DST_KEY = 'workspace/DST/1700000000001-cafebabe-photo.png'
const maps = (): ForkContentRefMaps => ({
workspaceId: { from: 'SRC', to: 'DST' },
fileKeys: new Map([[SRC_KEY, DST_KEY]]),
fileIds: new Map([['file-src', 'file-dst']]),
workflows: new Map([['wf-src', 'wf-dst']]),
knowledgeBases: new Map([['kb-src', 'kb-dst']]),
tables: new Map([['tbl-src', 'tbl-dst']]),
skills: new Map([['skill-src', 'skill-dst']]),
folders: new Map([['fld-src', 'fld-dst']]),
})
describe('rewriteForkContentRefs - sim: links', () => {
it('remaps each mapped sim: link kind by its id map', () => {
const input = [
'see [F](sim:file/file-src)',
'[W](sim:workflow/wf-src)',
'[K](sim:knowledge/kb-src)',
'[T](sim:table/tbl-src)',
'[S](sim:skill/skill-src)',
'[D](sim:folder/fld-src)',
].join(' ')
expect(rewriteForkContentRefs(input, maps())).toBe(
[
'see [F](sim:file/file-dst)',
'[W](sim:workflow/wf-dst)',
'[K](sim:knowledge/kb-dst)',
'[T](sim:table/tbl-dst)',
'[S](sim:skill/skill-dst)',
'[D](sim:folder/fld-dst)',
].join(' ')
)
})
it('leaves an unmapped id unchanged (graceful broken link)', () => {
const input = '[F](sim:file/unknown-file) and [I](sim:integration/gmail_v2)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('leaves a kind with no supplied map unchanged', () => {
const input = '[W](sim:workflow/wf-src)'
expect(rewriteForkContentRefs(input, { fileIds: new Map() })).toBe(input)
})
})
describe('rewriteForkContentRefs - embedded urls', () => {
it('remaps a serve-url storage key (encoded form output)', () => {
const input = `![a](/api/files/serve/${encodeURIComponent(SRC_KEY)}?context=workspace)`
expect(rewriteForkContentRefs(input, maps())).toBe(
`![a](/api/files/serve/${encodeURIComponent(DST_KEY)}?context=workspace)`
)
})
it('remaps a serve-url key given in raw (unencoded) form', () => {
const input = `![a](/api/files/serve/${SRC_KEY})`
expect(rewriteForkContentRefs(input, maps())).toBe(
`![a](/api/files/serve/${encodeURIComponent(DST_KEY)})`
)
})
it('remaps an s3/blob-prefixed serve url, preserving the prefix', () => {
const input = `![a](/api/files/serve/s3/${encodeURIComponent(SRC_KEY)})`
expect(rewriteForkContentRefs(input, maps())).toBe(
`![a](/api/files/serve/s3/${encodeURIComponent(DST_KEY)})`
)
})
it('remaps a view-url file id', () => {
const input = '![a](/api/files/view/file-src)'
expect(rewriteForkContentRefs(input, maps())).toBe('![a](/api/files/view/file-dst)')
})
it('remaps both the workspace id and file id in an in-app files path', () => {
const input = '![a](/workspace/SRC/files/file-src)'
expect(rewriteForkContentRefs(input, maps())).toBe('![a](/workspace/DST/files/file-dst)')
})
it('leaves a foreign-workspace in-app file path unchanged (both-or-nothing)', () => {
// The ws id is not the mapped source, so emitting the child file id under OTHER would 404.
const input = '![a](/workspace/OTHER/files/file-src)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('leaves an in-app file path unchanged when the file id is unmapped (both-or-nothing)', () => {
const input = '![a](/workspace/SRC/files/unknown-file)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('leaves an unmapped storage key / file id unchanged', () => {
const input =
'![a](/api/files/serve/workspace%2FSRC%2Funknown.png) ![b](/api/files/view/unknown-id)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('leaves an external / data url unchanged', () => {
const input = '![a](https://cdn.example.com/x.png) ![b](data:image/png;base64,AAAA)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
})
describe('rewriteForkContentRefs - mixed and edge cases', () => {
it('rewrites multiple references of different shapes in one string', () => {
const input = [
'intro [S](sim:skill/skill-src)',
`![img](/api/files/serve/${encodeURIComponent(SRC_KEY)})`,
'link [W](sim:workflow/wf-src)',
'![v](/api/files/view/file-src)',
].join('\n')
const output = rewriteForkContentRefs(input, maps())
expect(output).toContain('sim:skill/skill-dst')
expect(output).toContain('sim:workflow/wf-dst')
expect(output).toContain(encodeURIComponent(DST_KEY))
expect(output).toContain('/api/files/view/file-dst')
})
it('returns the input unchanged when there are no references', () => {
const input = '# Heading\n\nNo references here, just text.'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('returns the input unchanged for an empty string', () => {
expect(rewriteForkContentRefs('', maps())).toBe('')
})
it('leaves a malformed (un-decodable) serve key unchanged', () => {
const input = '![a](/api/files/serve/%E0%A4%A)'
expect(rewriteForkContentRefs(input, maps())).toBe(input)
})
it('does nothing when no maps are supplied', () => {
const input = `[S](sim:skill/skill-src) ![a](/api/files/serve/${SRC_KEY})`
expect(rewriteForkContentRefs(input, {})).toBe(input)
})
})
describe('rewriteForkResourceUrls - table cell resource chip urls', () => {
it('rewrites the workspace id + resource id for each section when the resource is mapped', () => {
expect(rewriteForkResourceUrls('/workspace/SRC/w/wf-src', maps())).toBe(
'/workspace/DST/w/wf-dst'
)
expect(rewriteForkResourceUrls('/workspace/SRC/tables/tbl-src', maps())).toBe(
'/workspace/DST/tables/tbl-dst'
)
expect(rewriteForkResourceUrls('/workspace/SRC/knowledge/kb-src', maps())).toBe(
'/workspace/DST/knowledge/kb-dst'
)
expect(rewriteForkResourceUrls('/workspace/SRC/files/file-src', maps())).toBe(
'/workspace/DST/files/file-dst'
)
})
it('leaves an unmapped resource id unchanged (both-or-nothing -> graceful plain link)', () => {
expect(rewriteForkResourceUrls('/workspace/SRC/w/wf-unknown', maps())).toBe(
'/workspace/SRC/w/wf-unknown'
)
})
it('leaves a foreign / unknown workspace id unchanged', () => {
expect(rewriteForkResourceUrls('/workspace/OTHER/w/wf-src', maps())).toBe(
'/workspace/OTHER/w/wf-src'
)
})
it('leaves a non-matching string (unknown section / no resource path) unchanged', () => {
const input = 'text /workspace/ and /workspace/SRC/settings/x and /workspace/SRC/w/'
expect(rewriteForkResourceUrls(input, maps())).toBe(input)
})
it('does nothing without a workspaceId map', () => {
const input = '/workspace/SRC/w/wf-src'
expect(rewriteForkResourceUrls(input, { workflows: new Map([['wf-src', 'wf-dst']]) })).toBe(
input
)
})
it('rewrites a URL embedded mid-text', () => {
const input = 'See [chip](/workspace/SRC/knowledge/kb-src) here'
expect(rewriteForkResourceUrls(input, maps())).toBe(
'See [chip](/workspace/DST/knowledge/kb-dst) here'
)
})
})
@@ -0,0 +1,127 @@
/**
* Pure rewriter for the in-content references embedded in copied free-text (skill bodies,
* markdown file blobs) at fork/sync time. It rewrites the two reference shapes a copy must
* keep pointing at the right workspace:
*
* - `sim:<kind>/<id>` deep links (the `@`-mention / chip scheme) - the id is remapped through
* the matching fork id map by kind (file, folder, table, knowledge, workflow, skill).
* - Embedded file/image URLs - `/api/files/serve/<key>` (workspace storage key), `/api/files/view/<id>`
* (workspace file id), and the in-app `/workspace/<id>/files/<id>` path - remapped through the file
* key / file id / workspace id maps.
*
* A reference whose target has no mapping is LEFT UNCHANGED (a graceful broken link), never deleted,
* so a copied document is never silently corrupted. Pure and isomorphic (no DOM/Node/DB).
*/
/** Per-kind source->target id maps a content copy threads in (any subset may be supplied). */
export interface ForkContentRefMaps {
/** Workspace id rewrite for the in-app `/workspace/<id>/files/...` path. */
workspaceId?: { from: string; to: string }
/** source workspace-file storage key -> child storage key (serve-url embeds). */
fileKeys?: ReadonlyMap<string, string>
/** source workspace-file id -> child id (view-url + in-app-path embeds). */
fileIds?: ReadonlyMap<string, string>
/** source workflow id -> child id (`sim:workflow/<id>`). */
workflows?: ReadonlyMap<string, string>
/** source knowledge-base id -> child id (`sim:knowledge/<id>`). */
knowledgeBases?: ReadonlyMap<string, string>
/** source table id -> child id (`sim:table/<id>`). */
tables?: ReadonlyMap<string, string>
/** source skill id -> child id (`sim:skill/<id>`). */
skills?: ReadonlyMap<string, string>
/** source folder id -> child id (`sim:folder/<id>`). */
folders?: ReadonlyMap<string, string>
}
/** `sim:<kind>/<id>` token; the id charset matches generateId/generateShortId so it stops at delimiters. */
const SIM_LINK_RE = /sim:([a-z_]+)\/([A-Za-z0-9_-]+)/g
/** `/api/files/serve/[s3/|blob/]<key>` (key may be raw or percent-encoded, ends at a delimiter). */
const SERVE_URL_RE = /\/api\/files\/serve\/(s3\/|blob\/)?([^\s)"'<>?]+)/g
/** `/api/files/view/<workspaceFileId>`. */
const VIEW_URL_RE = /\/api\/files\/view\/([A-Za-z0-9_-]+)/g
/** In-app `/workspace/<workspaceId>/files/<workspaceFileId>` embed path. */
const INAPP_FILE_RE = /\/workspace\/([A-Za-z0-9-]+)\/files\/([A-Za-z0-9_-]+)/g
export function rewriteForkContentRefs(content: string, maps: ForkContentRefMaps): string {
if (!content) return content
const idMapForSimKind: Record<string, ReadonlyMap<string, string> | undefined> = {
file: maps.fileIds,
folder: maps.folders,
table: maps.tables,
knowledge: maps.knowledgeBases,
workflow: maps.workflows,
skill: maps.skills,
}
let result = content.replace(SIM_LINK_RE, (full, kind: string, id: string) => {
const target = idMapForSimKind[kind]?.get(id)
return target ? `sim:${kind}/${target}` : full
})
result = result.replace(SERVE_URL_RE, (full, prefix: string | undefined, encodedKey: string) => {
if (!maps.fileKeys) return full
let key: string
try {
key = decodeURIComponent(encodedKey)
} catch {
return full
}
const target = maps.fileKeys.get(key)
return target ? `/api/files/serve/${prefix ?? ''}${encodeURIComponent(target)}` : full
})
result = result.replace(VIEW_URL_RE, (full, id: string) => {
const target = maps.fileIds?.get(id)
return target ? `/api/files/view/${target}` : full
})
// Both-or-nothing: rewrite only when the workspace id is the mapped source AND the file id
// resolves, so we never emit a child-workspace path with an unmapped (guaranteed-404) id -
// matching the serve/view branches and rewriteForkResourceUrls. A foreign workspace id or an
// unmapped file id leaves the original path untouched (graceful).
result = result.replace(INAPP_FILE_RE, (full, wsId: string, fileId: string) => {
if (maps.workspaceId?.from !== wsId) return full
const mappedFile = maps.fileIds?.get(fileId)
return mappedFile ? `/workspace/${maps.workspaceId.to}/files/${mappedFile}` : full
})
return result
}
/**
* In-app `/workspace/<wsId>/(w|tables|knowledge|files)/<resourceId>` deep link - the form a TABLE
* CELL renders as a resource chip (`resolveSimResourceKind`), but ONLY when `wsId` matches the
* current workspace. The id charset stops at delimiters.
*/
const RESOURCE_URL_RE =
/\/workspace\/([A-Za-z0-9-]+)\/(w|tables|knowledge|files)\/([A-Za-z0-9_-]+)/g
/**
* Rewrite the in-workspace resource deep links a copied table cell renders as a resource chip, so
* the chip keeps resolving after a cross-workspace copy (a cell chip renders only when the URL's
* workspace id is the current workspace, so a stale source id silently degrades to a plain link).
* Repoints both the workspace id and the resource id at the child copy, per section: `w` ->
* workflows, `tables` -> tables, `knowledge` -> knowledge bases, `files` -> file ids.
*
* Both-or-nothing: a match is rewritten ONLY when its workspace id is the mapped source AND its
* resource id is in that section's map - otherwise it is left UNCHANGED. Emitting a child-workspace
* URL with an unmapped id would render a "Not found" chip (worse than the graceful plain link an
* unchanged URL degrades to). Distinct from {@link rewriteForkContentRefs}: table cells do NOT
* specially render the `sim:` / serve / view forms that skill + markdown bodies do.
*/
export function rewriteForkResourceUrls(content: string, maps: ForkContentRefMaps): string {
if (!content || !maps.workspaceId) return content
const { from, to } = maps.workspaceId
const idMapForSection: Record<string, ReadonlyMap<string, string> | undefined> = {
w: maps.workflows,
tables: maps.tables,
knowledge: maps.knowledgeBases,
files: maps.fileIds,
}
return content.replace(RESOURCE_URL_RE, (full, wsId: string, section: string, id: string) => {
if (wsId !== from) return full
const mappedId = idMapForSection[section]?.get(id)
return mappedId ? `/workspace/${to}/${section}/${mappedId}` : full
})
}
@@ -0,0 +1,52 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { remapForkFileUploadValue } from '@/ee/workspace-forking/lib/remap/remap-files'
const map = (entries: Record<string, string>) => (key: string) => entries[key] ?? null
describe('remapForkFileUploadValue', () => {
it('rewrites a copied single object key, preserving other fields', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf', type: 'application/pdf', size: 10 }
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual({ key: 'child/a.pdf', name: 'a.pdf', type: 'application/pdf', size: 10 })
})
it('clears a single object whose file was not copied', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf' }
expect(remapForkFileUploadValue(value, map({}))).toBe('')
})
it('remaps copied items and drops uncopied ones in an array', () => {
const value = [
{ key: 'src/a.pdf', name: 'a.pdf' },
{ key: 'src/b.pdf', name: 'b.pdf' },
]
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual([{ key: 'child/a.pdf', name: 'a.pdf' }])
})
it('handles a JSON-stringified value and re-serializes', () => {
const value = JSON.stringify({ key: 'src/a.pdf', name: 'a.pdf' })
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toBe(JSON.stringify({ key: 'child/a.pdf', name: 'a.pdf' }))
})
it('falls back to the path field when there is no key', () => {
const value = { path: 'src/a.pdf', name: 'a.pdf' }
const result = remapForkFileUploadValue(value, map({ 'src/a.pdf': 'child/a.pdf' }))
expect(result).toEqual({ path: 'child/a.pdf', name: 'a.pdf' })
})
it('returns the value unchanged when no items match', () => {
const value = { key: 'src/a.pdf', name: 'a.pdf' }
const sameKey = map({ 'src/a.pdf': 'src/a.pdf' })
expect(remapForkFileUploadValue(value, sameKey)).toBe(value)
})
it('returns empty/unparseable values untouched', () => {
expect(remapForkFileUploadValue('', map({}))).toBe('')
expect(remapForkFileUploadValue(null, map({}))).toBe(null)
})
})
@@ -0,0 +1,101 @@
/**
* `file-upload` subblock remapping for fork/promote.
*
* A `file-upload` value is a workspace-file reference (or array of them) stored as
* objects `{ key, name, ... }` where `key` is the object-storage key (NOT the
* `workspace_files.id`). Forking copies the blob to a new key; this rewrites each
* reference's key to the copied key, preserving the rest of the object. References
* whose file was not copied are dropped (the field is emptied) rather than left
* pointing at another workspace's blob. External `file-selector` references
* (provider file ids, credential-scoped) are NOT handled here - they carry over
* unchanged.
*/
function parseMaybeJson(value: unknown): { value: unknown; serialized: boolean } {
if (typeof value !== 'string') return { value, serialized: false }
const trimmed = value.trim()
const looksJson =
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
if (!looksJson) return { value, serialized: false }
try {
return { value: JSON.parse(trimmed), serialized: true }
} catch {
return { value, serialized: false }
}
}
/** The field a file-upload item uses as its storage key, and that key's value. */
function fileItemKeyField(item: unknown): { field: 'key' | 'path' | 'name'; key: string } | null {
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
const record = item as Record<string, unknown>
for (const field of ['key', 'path', 'name'] as const) {
const value = record[field]
if (typeof value === 'string' && value.trim().length > 0) return { field, key: value }
}
return null
}
/**
* Enumerate the workspace-file storage keys referenced by a `file-upload` subblock
* value (single object, array, or JSON-string form). Used at promote time to emit each
* workspace file as a `file` reference (keyed by storage key) so it surfaces in the
* scan / unmapped set and can be copied into the target. Deduplicated, order-preserving.
*/
export function collectForkFileUploadKeys(value: unknown): string[] {
const parsed = parseMaybeJson(value)
const items = Array.isArray(parsed.value)
? (parsed.value as unknown[])
: parsed.value
? [parsed.value]
: []
const keys: string[] = []
const seen = new Set<string>()
for (const item of items) {
const keyInfo = fileItemKeyField(item)
if (!keyInfo || seen.has(keyInfo.key)) continue
seen.add(keyInfo.key)
keys.push(keyInfo.key)
}
return keys
}
/**
* Remap a `file-upload` subblock value. `resolveFileKey(sourceKey)` returns the
* copied target storage key, or null when the file was not copied (drop the ref).
*/
export function remapForkFileUploadValue(
value: unknown,
resolveFileKey: (sourceKey: string) => string | null
): unknown {
const parsed = parseMaybeJson(value)
const isArray = Array.isArray(parsed.value)
const items = isArray ? (parsed.value as unknown[]) : parsed.value ? [parsed.value] : []
if (items.length === 0) return value
const next: unknown[] = []
let changed = false
for (const item of items) {
const keyInfo = fileItemKeyField(item)
if (!keyInfo) {
next.push(item)
continue
}
const targetKey = resolveFileKey(keyInfo.key)
if (targetKey == null) {
changed = true
continue
}
if (targetKey === keyInfo.key) {
next.push(item)
continue
}
changed = true
next.push({ ...(item as Record<string, unknown>), [keyInfo.field]: targetKey })
}
if (!changed) return value
if (next.length === 0) return ''
if (isArray) return parsed.serialized ? JSON.stringify(next) : next
return parsed.serialized ? JSON.stringify(next[0]) : next[0]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { TableSchema } from '@/lib/table/types'
import {
buildForkBlockIdResolver,
deriveForkBlockId,
} from '@/ee/workspace-forking/lib/remap/block-identity'
import { remapForkTableWorkflowGroups } from '@/ee/workspace-forking/lib/remap/remap-table-groups'
describe('remapForkTableWorkflowGroups', () => {
it('remaps a manual group workflowId and outputs[].blockId to child ids', () => {
const map = new Map([['src-wf', 'child-wf']])
const schema: TableSchema = {
columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }],
workflowGroups: [
{
id: 'g1',
workflowId: 'src-wf',
outputs: [{ blockId: 'src-block', path: 'out', columnName: 'col_1' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, map)
const group = result.workflowGroups?.[0]
expect(group?.workflowId).toBe('child-wf')
expect(group?.outputs[0].blockId).toBe(deriveForkBlockId('child-wf', 'src-block'))
expect(group?.outputs[0].columnName).toBe('col_1')
expect(result.columns[0].id).toBe('col_1')
expect(result.columns[0].workflowGroupId).toBe('g1')
})
// Promote threads its persisted-pair resolver: a paired block resolves to the pair's target id
// (on push, the parent's ORIGINAL id - never the derive); an unpaired block falls back to the
// derive, matching the workflow write path.
it('prefers a provided block-id resolver (persisted pair) over the derive, deriving unpaired blocks', () => {
const map = new Map([['src-wf', 'child-wf']])
const schema: TableSchema = {
columns: [],
workflowGroups: [
{
id: 'g1',
workflowId: 'src-wf',
outputs: [
{ blockId: 'src-block', path: 'out', columnName: 'col_1' },
{ blockId: 'src-unpaired', path: 'out2', columnName: 'col_2' },
],
},
],
}
const resolver = buildForkBlockIdResolver(true, {
parentToChild: new Map([
['src-block', { targetBlockId: 'original-parent-block', targetWorkflowId: 'child-wf' }],
]),
childToParent: new Map(),
})
const result = remapForkTableWorkflowGroups(schema, map, resolver)
const outputs = result.workflowGroups?.[0].outputs
expect(outputs?.[0].blockId).toBe('original-parent-block')
expect(outputs?.[1].blockId).toBe(deriveForkBlockId('child-wf', 'src-unpaired'))
})
it('drops a group whose backing workflow was not copied and clears its column wiring', () => {
const schema: TableSchema = {
columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }],
workflowGroups: [
{
id: 'g1',
workflowId: 'missing-wf',
outputs: [{ blockId: 'b', path: 'p', columnName: 'col_1' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, new Map())
expect(result.workflowGroups).toHaveLength(0)
expect(result.columns[0].workflowGroupId).toBeUndefined()
expect(result.columns[0].id).toBe('col_1')
})
it('leaves enrichment groups (empty workflowId) untouched', () => {
const schema: TableSchema = {
columns: [],
workflowGroups: [
{
id: 'g1',
workflowId: '',
enrichmentId: 'enr',
outputs: [{ blockId: '', path: '', columnName: 'col_1', outputId: 'o' }],
},
],
}
const result = remapForkTableWorkflowGroups(schema, new Map([['src-wf', 'child-wf']]))
expect(result.workflowGroups?.[0]).toEqual(schema.workflowGroups?.[0])
})
it('returns the schema unchanged when there are no groups', () => {
const schema: TableSchema = { columns: [{ id: 'col_1', name: 'A', type: 'string' }] }
expect(remapForkTableWorkflowGroups(schema, new Map())).toBe(schema)
})
})
@@ -0,0 +1,62 @@
import type { TableSchema } from '@/lib/table/types'
import {
deriveForkBlockId,
type ForkBlockIdResolver,
} from '@/ee/workspace-forking/lib/remap/block-identity'
/**
* Remap the workflow/block references embedded in a copied table's schema so its
* workflow groups keep working in the child workspace. `workflowGroups[].workflowId`
* is rewritten through the source→child workflow identity map, and each
* `outputs[].blockId` is rewritten through `resolveBlockId` - which MUST be the
* same resolver that assigns the target workflows' block ids, or the outputs
* point at nonexistent blocks. Fork-create omits it and defaults to the
* deterministic {@link deriveForkBlockId} (a fresh child has no persisted
* pairs, matching `copyWorkflowStateIntoTarget`); promote passes its
* persisted-pair resolver (a push keeps the parent's ORIGINAL block ids, which
* never equal the derive). Manual groups whose backing workflow was not
* copied are dropped, and any columns wired to a dropped group have their
* `workflowGroupId` cleared. Enrichment groups (empty `workflowId`) and column
* ids are left untouched.
*/
export function remapForkTableWorkflowGroups(
schema: TableSchema,
workflowIdMap: Map<string, string>,
resolveBlockId: ForkBlockIdResolver = deriveForkBlockId
): TableSchema {
const groups = schema.workflowGroups ?? []
if (groups.length === 0) return schema
const droppedGroupIds = new Set<string>()
const remappedGroups = groups.flatMap((group) => {
if (!group.workflowId) return [group]
const childWorkflowId = workflowIdMap.get(group.workflowId)
if (!childWorkflowId) {
droppedGroupIds.add(group.id)
return []
}
return [
{
...group,
workflowId: childWorkflowId,
outputs: group.outputs.map((output) => ({
...output,
blockId: output.blockId
? resolveBlockId(childWorkflowId, output.blockId)
: output.blockId,
})),
},
]
})
const columns =
droppedGroupIds.size === 0
? schema.columns
: schema.columns.map((column) =>
column.workflowGroupId && droppedGroupIds.has(column.workflowGroupId)
? { ...column, workflowGroupId: undefined }
: column
)
return { ...schema, columns, workflowGroups: remappedGroups }
}