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,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 = `}?context=workspace)`
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(
|
||||
`}?context=workspace)`
|
||||
)
|
||||
})
|
||||
|
||||
it('remaps a serve-url key given in raw (unencoded) form', () => {
|
||||
const input = ``
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(
|
||||
`})`
|
||||
)
|
||||
})
|
||||
|
||||
it('remaps an s3/blob-prefixed serve url, preserving the prefix', () => {
|
||||
const input = `})`
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(
|
||||
`})`
|
||||
)
|
||||
})
|
||||
|
||||
it('remaps a view-url file id', () => {
|
||||
const input = ''
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe('')
|
||||
})
|
||||
|
||||
it('remaps both the workspace id and file id in an in-app files path', () => {
|
||||
const input = ''
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe('')
|
||||
})
|
||||
|
||||
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 = ''
|
||||
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 = ''
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves an unmapped storage key / file id unchanged', () => {
|
||||
const input =
|
||||
' '
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves an external / data url unchanged', () => {
|
||||
const input = ' '
|
||||
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)',
|
||||
`})`,
|
||||
'link [W](sim:workflow/wf-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 = ''
|
||||
expect(rewriteForkContentRefs(input, maps())).toBe(input)
|
||||
})
|
||||
|
||||
it('does nothing when no maps are supplied', () => {
|
||||
const input = `[S](sim:skill/skill-src) `
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user