d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { remapConditionBlockIds } from '@/lib/workflows/condition-ids'
|
|
import {
|
|
type CanonicalModeOverrides,
|
|
resolveCanonicalMode,
|
|
} from '@/lib/workflows/subblocks/visibility'
|
|
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
|
|
|
const logger = createLogger('WorkflowRemapInternalIds')
|
|
|
|
/**
|
|
* Untrusted shape of a persisted block subBlocks JSON column. Callers narrow
|
|
* `type`/`value` with runtime checks before mutating; the index signature exists
|
|
* because the raw record is handed back to drizzle without knowing which subBlock
|
|
* keys it contains.
|
|
*/
|
|
export type SubBlockRecord = Record<
|
|
string,
|
|
{ type?: unknown; value?: unknown; [key: string]: unknown }
|
|
>
|
|
|
|
type VariableAssignment = Record<string, unknown> & { variableId?: unknown }
|
|
|
|
const DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS = new Set(
|
|
SYSTEM_SUBBLOCK_IDS.filter((id) => id !== 'triggerCredentials')
|
|
)
|
|
|
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
}
|
|
|
|
/** Coerce a subblock value that holds a JSON array (stored as an array or a JSON string). */
|
|
export function coerceObjectArray(value: unknown): { array: unknown[] | null; wasString: boolean } {
|
|
if (Array.isArray(value)) return { array: value, wasString: false }
|
|
if (typeof value === 'string' && value.trim()) {
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
if (Array.isArray(parsed)) return { array: parsed, wasString: true }
|
|
} catch {}
|
|
}
|
|
return { array: null, wasString: false }
|
|
}
|
|
|
|
export function isSystemSubBlockKey(key: string, ids: Set<string> | string[]): boolean {
|
|
const idList = Array.isArray(ids) ? ids : Array.from(ids)
|
|
return idList.some((id) => key === id || key.startsWith(`${id}_`))
|
|
}
|
|
|
|
/** Strip trigger-runtime and non-credential system subblocks for a fresh copy. */
|
|
export function sanitizeSubBlocksForDuplicate(subBlocks: SubBlockRecord): SubBlockRecord {
|
|
const sanitized: SubBlockRecord = {}
|
|
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
|
if (isSystemSubBlockKey(key, TRIGGER_RUNTIME_SUBBLOCK_IDS)) continue
|
|
if (isSystemSubBlockKey(key, DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS)) continue
|
|
sanitized[key] = subBlock
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
function remapVariableAssignment(value: unknown, varIdMap: Map<string, string>): unknown {
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => remapVariableAssignment(item, varIdMap))
|
|
}
|
|
if (!isRecord(value)) {
|
|
return value
|
|
}
|
|
const assignment = value as VariableAssignment
|
|
const next: Record<string, unknown> = {}
|
|
for (const [key, nestedValue] of Object.entries(assignment)) {
|
|
next[key] = remapVariableAssignment(nestedValue, varIdMap)
|
|
}
|
|
if (typeof assignment.variableId === 'string') {
|
|
const newVarId = varIdMap.get(assignment.variableId)
|
|
if (newVarId) {
|
|
next.variableId = newVarId
|
|
} else {
|
|
logger.warn('Skipping unknown variable reference during copy', {
|
|
variableId: assignment.variableId,
|
|
})
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
|
|
function remapVariableInputValue(value: unknown, varIdMap: Map<string, string>): unknown {
|
|
if (value == null) {
|
|
return value
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return remapVariableAssignment(value, varIdMap)
|
|
}
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return value
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(trimmed)
|
|
} catch {
|
|
throw new Error('Variables input assignments could not be parsed for copy')
|
|
}
|
|
if (Array.isArray(parsed)) {
|
|
return remapVariableAssignment(parsed, varIdMap)
|
|
}
|
|
throw new Error('Variables input assignments must be an array')
|
|
}
|
|
throw new Error('Variables input assignments must be an array')
|
|
}
|
|
|
|
/**
|
|
* Remap old variable IDs to new variable IDs inside block subBlocks, targeting
|
|
* `variables-input` subblocks whose value is an array of variable assignments.
|
|
*/
|
|
export function remapVariableIdsInSubBlocks(
|
|
subBlocks: SubBlockRecord,
|
|
varIdMap: Map<string, string>
|
|
): SubBlockRecord {
|
|
const updated: SubBlockRecord = {}
|
|
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
|
if (subBlock && typeof subBlock === 'object' && subBlock.type === 'variables-input') {
|
|
updated[key] = {
|
|
...subBlock,
|
|
value: remapVariableInputValue(subBlock.value, varIdMap),
|
|
}
|
|
} else {
|
|
updated[key] = subBlock
|
|
}
|
|
}
|
|
return updated
|
|
}
|
|
|
|
/**
|
|
* Rewrite cross-workflow references through a workflow id map. Only SELECTOR-sourced (structured)
|
|
* references are remapped/cleared: the basic `workflow-selector` value, the multi-select
|
|
* `workflowSelector` list (logs block), the workspace-event trigger's multi-select `workflowIds`
|
|
* dropdown (its options are workspace workflow ids), and `workflow_input` sub-workflow tools
|
|
* nested in a `tool-input` array (an agent picking another workflow as a tool - its
|
|
* `params.workflowId` comes from the workflow picker, never free-form input).
|
|
*
|
|
* The advanced, free-form MANUAL fields (`manualWorkflowId`, comma-separated `manualWorkflowIds`)
|
|
* are user-owned and pass through VERBATIM - mirroring `manualCredential` in the fork remap - so a
|
|
* hand-typed value (an env ref `{{VAR}}`, a `<block.output>` tag, a literal id, or arbitrary text)
|
|
* is never rewritten or cleared. The `workflowIds` handling is gated on subblock TYPE `dropdown`
|
|
* for the same reason: the legacy logs block's `workflowIds` is a free-form `short-input`
|
|
* (user-owned, verbatim), and only the workspace-event trigger uses a `workflowIds` dropdown.
|
|
*
|
|
* `clearUnmapped` controls the cross-workspace case for those selector references: fork/promote pass
|
|
* `true` so a selector pointing at a workflow that wasn't copied is cleared/dropped rather than left
|
|
* pointing at the source workspace (a silent cross-workspace execution). Same-workspace duplication
|
|
* leaves it `false` to preserve references to untouched sibling workflows.
|
|
*/
|
|
export function remapWorkflowReferencesInSubBlocks(
|
|
subBlocks: SubBlockRecord,
|
|
workflowIdMap: Map<string, string> | undefined,
|
|
options?: { clearUnmapped?: boolean; canonicalModes?: CanonicalModeOverrides }
|
|
): SubBlockRecord {
|
|
if (!workflowIdMap?.size) return subBlocks
|
|
const clearUnmapped = options?.clearUnmapped ?? false
|
|
const remapScalar = (value: string): string => {
|
|
const mapped = workflowIdMap.get(value)
|
|
if (mapped) return mapped
|
|
return clearUnmapped ? '' : value
|
|
}
|
|
// The `workflowId` canonical pair: basic `workflow-selector` + advanced `manualWorkflowId`. Capture
|
|
// each key (by type/baseKey, regardless of value) and its ORIGINAL value so the inputMapping wipe
|
|
// below can decide on the ACTIVE mode's disposition via `resolveCanonicalMode`. Only the basic
|
|
// selector is ever remapped; the advanced manual member is captured for mode resolution only.
|
|
let basicId: string | undefined
|
|
let basicValue = ''
|
|
let advancedId: string | undefined
|
|
let advancedValue = ''
|
|
const updated: SubBlockRecord = {}
|
|
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
|
if (subBlock && typeof subBlock === 'object') {
|
|
const baseKey = key.replace(/_\d+$/, '')
|
|
if (subBlock.type === 'workflow-selector' && basicId === undefined) {
|
|
basicId = key
|
|
basicValue = typeof subBlock.value === 'string' ? subBlock.value : ''
|
|
} else if (baseKey === 'manualWorkflowId' && advancedId === undefined) {
|
|
advancedId = key
|
|
advancedValue = typeof subBlock.value === 'string' ? subBlock.value : ''
|
|
}
|
|
// Remap only the SELECTOR member; the manual `manualWorkflowId` passes through verbatim.
|
|
if (
|
|
subBlock.type === 'workflow-selector' &&
|
|
typeof subBlock.value === 'string' &&
|
|
subBlock.value
|
|
) {
|
|
updated[key] = { ...subBlock, value: remapScalar(subBlock.value) }
|
|
continue
|
|
}
|
|
// Remap only the STRUCTURED multi-workflow lists: the logs block's `workflowSelector` and
|
|
// the workspace-event trigger's `workflowIds` dropdown. The latter is gated on TYPE
|
|
// `dropdown` so the legacy logs block's `workflowIds` short-input (manual, user-owned)
|
|
// passes through verbatim, as does the manual comma-separated `manualWorkflowIds`.
|
|
if (
|
|
baseKey === 'workflowSelector' ||
|
|
(subBlock.type === 'dropdown' && baseKey === 'workflowIds')
|
|
) {
|
|
const remapped = remapWorkflowIdList(subBlock.value, workflowIdMap, clearUnmapped)
|
|
if (remapped !== subBlock.value) {
|
|
updated[key] = { ...subBlock, value: remapped }
|
|
continue
|
|
}
|
|
}
|
|
if (subBlock.type === 'tool-input') {
|
|
const remapped = remapWorkflowInputTools(subBlock.value, workflowIdMap, clearUnmapped)
|
|
if (remapped !== subBlock.value) {
|
|
updated[key] = { ...subBlock, value: remapped }
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
updated[key] = subBlock
|
|
}
|
|
|
|
if (basicId !== undefined || advancedId !== undefined) {
|
|
const isEmptyValue = (value: unknown) => value === '' || value == null
|
|
const values: Record<string, unknown> = {}
|
|
if (basicId !== undefined) values[basicId] = basicValue
|
|
if (advancedId !== undefined) values[advancedId] = advancedValue
|
|
const activeMode = resolveCanonicalMode(
|
|
{ canonicalId: 'workflowId', basicId, advancedIds: advancedId ? [advancedId] : [] },
|
|
values,
|
|
options?.canonicalModes
|
|
)
|
|
const activeKey = activeMode === 'advanced' ? advancedId : basicId
|
|
const originalActive = activeKey === basicId ? basicValue : advancedValue
|
|
const postActive = activeKey !== undefined ? updated[activeKey]?.value : undefined
|
|
if (activeKey !== undefined && !isEmptyValue(originalActive) && isEmptyValue(postActive)) {
|
|
for (const [key, subBlock] of Object.entries(updated)) {
|
|
if (key.replace(/_\d+$/, '') !== 'inputMapping') continue
|
|
if (!subBlock || typeof subBlock !== 'object') continue
|
|
if (subBlock.value === '' || subBlock.value == null) continue
|
|
updated[key] = { ...subBlock, value: '' }
|
|
}
|
|
}
|
|
}
|
|
return updated
|
|
}
|
|
|
|
/**
|
|
* Rewrite a multi-workflow value (comma-separated string or array of workflow ids)
|
|
* through a workflow id map. Unmapped ids are dropped when `clearUnmapped` is set
|
|
* (cross-workspace) and preserved otherwise. Returns the original reference when
|
|
* nothing changed.
|
|
*/
|
|
function remapWorkflowIdList(
|
|
value: unknown,
|
|
workflowIdMap: Map<string, string>,
|
|
clearUnmapped: boolean
|
|
): unknown {
|
|
const remapId = (id: string): string | null => {
|
|
const mapped = workflowIdMap.get(id)
|
|
if (mapped) return mapped
|
|
return clearUnmapped ? null : id
|
|
}
|
|
if (Array.isArray(value)) {
|
|
let changed = false
|
|
const next: unknown[] = []
|
|
for (const item of value) {
|
|
if (typeof item !== 'string' || !item) {
|
|
next.push(item)
|
|
continue
|
|
}
|
|
const mapped = remapId(item)
|
|
if (mapped === null) {
|
|
changed = true
|
|
continue
|
|
}
|
|
if (mapped !== item) changed = true
|
|
next.push(mapped)
|
|
}
|
|
return changed ? next : value
|
|
}
|
|
if (typeof value === 'string' && value) {
|
|
const next: string[] = []
|
|
for (const id of value.split(',').map((entry) => entry.trim())) {
|
|
if (!id) continue
|
|
const mapped = remapId(id)
|
|
if (mapped !== null) next.push(mapped)
|
|
}
|
|
const joined = next.join(',')
|
|
return joined === value ? value : joined
|
|
}
|
|
return value
|
|
}
|
|
|
|
/**
|
|
* Rewrite `workflow_input` tools' `params.workflowId` through a workflow id map.
|
|
* When `clearUnmapped` is set, a tool pointing at a workflow that wasn't copied is
|
|
* dropped (it can't be referenced cross-workspace).
|
|
*/
|
|
function remapWorkflowInputTools(
|
|
value: unknown,
|
|
workflowIdMap: Map<string, string>,
|
|
clearUnmapped: boolean
|
|
): unknown {
|
|
const { array, wasString } = coerceObjectArray(value)
|
|
if (!array) return value
|
|
let changed = false
|
|
const next = array.flatMap((tool) => {
|
|
if (!isRecord(tool) || tool.type !== 'workflow_input' || !isRecord(tool.params)) return [tool]
|
|
const workflowId = tool.params.workflowId
|
|
if (typeof workflowId !== 'string') return [tool]
|
|
const mapped = workflowIdMap.get(workflowId)
|
|
if (mapped) {
|
|
if (mapped === workflowId) return [tool]
|
|
changed = true
|
|
return [{ ...tool, params: { ...tool.params, workflowId: mapped } }]
|
|
}
|
|
if (clearUnmapped) {
|
|
changed = true
|
|
return []
|
|
}
|
|
return [tool]
|
|
})
|
|
if (!changed) return value
|
|
return wasString ? JSON.stringify(next) : next
|
|
}
|
|
|
|
/**
|
|
* Remap condition/router block IDs within subBlocks when a block is copied with
|
|
* a new ID. Returns a new object without mutating the input.
|
|
*/
|
|
export function remapConditionIdsInSubBlocks(
|
|
subBlocks: SubBlockRecord,
|
|
oldBlockId: string,
|
|
newBlockId: string
|
|
): SubBlockRecord {
|
|
const updated: SubBlockRecord = {}
|
|
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
|
if (
|
|
subBlock &&
|
|
typeof subBlock === 'object' &&
|
|
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
|
|
typeof subBlock.value === 'string'
|
|
) {
|
|
try {
|
|
const parsed = JSON.parse(subBlock.value)
|
|
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
|
|
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
|
|
continue
|
|
}
|
|
} catch {}
|
|
}
|
|
updated[key] = subBlock
|
|
}
|
|
return updated
|
|
}
|