mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-29 18:47:25 +08:00
feat(workspaces): fork + push/pull (#5210)
* feat(workspaces): fork + push/pull * type fix * fix tests * progress on ux * remove modal section * improve UI of modal * update more ui * make rollback part of the footer * track skipped count correctly * address comments * make it workspace admin level * update skipped count * address more comments * deal with unbounded memory possibility * fix deleted kb article bug * no deployed workflow case * UI/UX cleanup * fix oauth dropdown case * fix oauth selector issue * infra work + activity log * consolidate migration * update modal state * more UI simplification * grammar * update audit report ui * perf improvements * fix tool input scenarios and add dependsOn UI handling * minor comments * fix webhook stability issues + drift detection removal * make dependsOn subblock mapping cleanly stored * fix: harden fork dependent-value mapping (clear stale rows, identity-guard first-sync fallback, perf + cleanup) * address comments * update comment * enforce admin perms for activity api * fix required + dependsOn combo
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { db } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -7,6 +8,7 @@ import { processOutboxEvents } from '@/lib/core/outbox/service'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
|
||||
import { reapStaleBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
|
||||
|
||||
const logger = createLogger('OutboxProcessorAPI')
|
||||
|
||||
@@ -33,12 +35,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
minRemainingMs: 95_000,
|
||||
})
|
||||
|
||||
logger.info('Outbox processing completed', { requestId, ...result })
|
||||
// Reap fork background-work rows stuck `processing` past their TTL (worker crash /
|
||||
// restart has no in-task hook). Independent of the outbox; a failure here must not
|
||||
// fail the outbox run, so it's guarded separately.
|
||||
let reapedBackgroundWork = 0
|
||||
try {
|
||||
reapedBackgroundWork = await reapStaleBackgroundWork(db)
|
||||
} catch (error) {
|
||||
logger.error('Background-work reap failed', { requestId, error: toError(error).message })
|
||||
}
|
||||
|
||||
logger.info('Outbox processing completed', { requestId, ...result, reapedBackgroundWork })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requestId,
|
||||
result,
|
||||
reapedBackgroundWork,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Outbox processing failed', { requestId, error: toError(error).message })
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { db } from '@sim/db'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getWorkspaceBackgroundWorkContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getWorkspaceBackgroundWorkContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const access = await checkWorkspaceAccess(id, session.user.id)
|
||||
if (!access.exists) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
if (!access.canAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const rows = await listSurfacedBackgroundWork(db, id)
|
||||
return NextResponse.json({
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId,
|
||||
workflowId: row.workflowId,
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
error: row.error,
|
||||
metadata: row.metadata ?? null,
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
import { db } from '@sim/db'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { loadTargetDraftSubBlocks } from '@/lib/workspaces/fork/copy/copy-workflows'
|
||||
import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
|
||||
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import { loadForkBlockMap } from '@/lib/workspaces/fork/mapping/block-map-store'
|
||||
import {
|
||||
collectForkDependentReconfigs,
|
||||
collectForkResourceUsages,
|
||||
} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
|
||||
import {
|
||||
forkDependentValueKey,
|
||||
loadForkDependentValues,
|
||||
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
|
||||
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
|
||||
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
|
||||
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkDiffContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId, direction } = parsed.data.query
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(
|
||||
auth.sourceWorkspaceId
|
||||
)
|
||||
const plan = await computeForkPromotePlan({
|
||||
executor: db,
|
||||
edge: auth.edge,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
direction,
|
||||
deployedSourceWorkflows: deployedWorkflows,
|
||||
sourceStates,
|
||||
})
|
||||
|
||||
// Resolve dependent-reconfig target block ids through the SAME persisted block map the
|
||||
// sync will use, so a re-pick the modal keys by target block id lands on the block the
|
||||
// promote actually writes (on push that's the parent's original id, not a derived one).
|
||||
const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId
|
||||
const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId)
|
||||
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
|
||||
|
||||
// Stored dependent values are the source of truth for what each selector is set to. Overlay
|
||||
// them as each field's currentValue so the modal pre-fills what the user actually saved. For
|
||||
// an edge that predates the store the fallback is the TARGET's own configured value (loaded
|
||||
// from its draft) - never the source's, which would overwrite the target's selection on the
|
||||
// first sync. Both the stored read and the draft read are scoped to the plan's replace
|
||||
// targets, the only workflows with dependents to reconfigure.
|
||||
const replaceTargetIds = plan.items
|
||||
.filter((item) => item.mode === 'replace')
|
||||
.map((item) => item.targetWorkflowId)
|
||||
const [storedValues, targetDraftByWorkflow] = await Promise.all([
|
||||
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
|
||||
loadTargetDraftSubBlocks(db, replaceTargetIds),
|
||||
])
|
||||
const storedByKey = new Map(
|
||||
storedValues.map((entry) => [
|
||||
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
|
||||
entry.value,
|
||||
])
|
||||
)
|
||||
|
||||
// Source block subBlocks keyed by their resolved target identity, so the first-sync draft
|
||||
// fallback can identity-check a nested tool against the SOURCE dependent tool it came from -
|
||||
// an index alone may point at a different tool in the target draft, whose value isn't the
|
||||
// dependent's. Read structurally (only each subblock's `value`), so the in-memory state's
|
||||
// blocks pass without a cast.
|
||||
const sourceBlocksByTarget = new Map<string, Map<string, Record<string, { value?: unknown }>>>()
|
||||
for (const item of plan.items) {
|
||||
if (item.mode !== 'replace') continue
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
const byBlock = new Map<string, Record<string, { value?: unknown }>>()
|
||||
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
|
||||
byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {})
|
||||
}
|
||||
sourceBlocksByTarget.set(item.targetWorkflowId, byBlock)
|
||||
}
|
||||
|
||||
const dependentReconfigs = collectForkDependentReconfigs(
|
||||
plan.items,
|
||||
sourceStates,
|
||||
resolveBlockId
|
||||
).map((field) => ({
|
||||
...field,
|
||||
currentValue:
|
||||
storedByKey.get(
|
||||
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
|
||||
) ??
|
||||
readTargetDraftDependentValue(
|
||||
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
|
||||
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
|
||||
field.subBlockKey
|
||||
),
|
||||
}))
|
||||
|
||||
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
|
||||
kind: reference.kind,
|
||||
sourceId: reference.sourceId,
|
||||
required: reference.required,
|
||||
blockName: reference.blockName,
|
||||
})
|
||||
|
||||
// Orient the mapping around the workspace the modal is open in (`id`): show the
|
||||
// caller's workflow name first, the sync partner's second, so renames are legible.
|
||||
const currentIsSource = auth.sourceWorkspaceId === id
|
||||
const workflows = [
|
||||
...plan.items.map((item) => {
|
||||
if (item.mode === 'create') {
|
||||
// The target inherits the source's name, so both sides read the same.
|
||||
return {
|
||||
action: 'create' as const,
|
||||
currentName: item.sourceMeta.name,
|
||||
otherName: item.sourceMeta.name,
|
||||
}
|
||||
}
|
||||
const targetName = item.targetName ?? item.sourceMeta.name
|
||||
return {
|
||||
action: 'update' as const,
|
||||
currentName: currentIsSource ? item.sourceMeta.name : targetName,
|
||||
otherName: currentIsSource ? targetName : item.sourceMeta.name,
|
||||
}
|
||||
}),
|
||||
...plan.archivedTargets.map((target) => ({
|
||||
action: 'archive' as const,
|
||||
currentName: target.name,
|
||||
otherName: target.name,
|
||||
})),
|
||||
]
|
||||
|
||||
return NextResponse.json({
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
willUpdate: plan.willUpdate,
|
||||
willCreate: plan.willCreate,
|
||||
willArchive: plan.willArchive,
|
||||
workflows,
|
||||
unmappedRequired: plan.unmappedRequired.map(toRef),
|
||||
unmappedOptional: plan.unmappedOptional.map(toRef),
|
||||
mcpReauthServerIds: plan.mcpReauthServerIds,
|
||||
inlineSecretSources: plan.inlineSecretSources,
|
||||
dependentReconfigs,
|
||||
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import { getForkParent } from '@/lib/workspaces/fork/lineage/lineage'
|
||||
import { getUndoableRunForTarget } from '@/lib/workspaces/fork/promote/promote-run-store'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkLineageContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
|
||||
await assertWorkspaceAdminAccess(workspaceId, session.user.id)
|
||||
|
||||
const [parent, run] = await Promise.all([
|
||||
getForkParent(workspaceId),
|
||||
getUndoableRunForTarget(db, workspaceId),
|
||||
])
|
||||
|
||||
let undoableRun: {
|
||||
otherWorkspaceId: string
|
||||
otherName: string
|
||||
direction: 'push' | 'pull'
|
||||
} | null = null
|
||||
if (run) {
|
||||
const [other] = await db
|
||||
.select({ name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, run.sourceWorkspaceId))
|
||||
.limit(1)
|
||||
undoableRun = {
|
||||
otherWorkspaceId: run.sourceWorkspaceId,
|
||||
otherName: other?.name ?? 'workspace',
|
||||
direction: run.direction,
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
workspaceId,
|
||||
parent,
|
||||
undoableRun,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
import { db } from '@sim/db'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getForkMappingContract,
|
||||
updateForkMappingContract,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import { acquireForkEdgeLock, setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
|
||||
import {
|
||||
applyForkMappingEntries,
|
||||
getForkMappingView,
|
||||
validateForkMappingTargets,
|
||||
} from '@/lib/workspaces/fork/mapping/mapping-service'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkMappingContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId, direction } = parsed.data.query
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
const { entries } = await getForkMappingView({
|
||||
edge: auth.edge,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
childWorkspaceId: auth.edge.childWorkspaceId,
|
||||
parentWorkspaceId: auth.edge.parentWorkspaceId,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateForkMappingContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId, direction, entries } = parsed.data.body
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries)
|
||||
|
||||
// Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted
|
||||
// then re-upserted parent-side) can't leave duplicate rows for the same source. Same
|
||||
// edge lock promote/rollback use, with a bounded wait.
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await setForkLockTimeout(tx)
|
||||
await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId)
|
||||
return applyForkMappingEntries(tx, auth.edge, session.user.id, direction, entries)
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true as const, updated })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { promoteForkContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { recordBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
|
||||
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import { promoteFork } from '@/lib/workspaces/fork/promote/promote'
|
||||
|
||||
const logger = createLogger('WorkspaceForkPromoteAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(promoteForkContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId, direction, dependentValues } = parsed.data.body
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
const result = await promoteFork({
|
||||
edge: auth.edge,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
direction,
|
||||
userId: session.user.id,
|
||||
dependentValues,
|
||||
requestId,
|
||||
})
|
||||
|
||||
const body = {
|
||||
promoteRunId: result.promoteRunId,
|
||||
updated: result.updated,
|
||||
created: result.created,
|
||||
archived: result.archived,
|
||||
redeployed: result.redeployed,
|
||||
deployFailed: result.deployFailed,
|
||||
unmappedRequired: result.unmappedRequired,
|
||||
needsConfiguration: result.needsConfiguration,
|
||||
clearedOptional: result.clearedOptional,
|
||||
}
|
||||
|
||||
if (result.blocked) {
|
||||
logger.info(`[${requestId}] Promote blocked (${result.blocked})`, {
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
targetWorkspaceId: auth.targetWorkspaceId,
|
||||
})
|
||||
return NextResponse.json(body)
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: auth.targetWorkspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.WORKSPACE_FORK_PROMOTED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: auth.targetWorkspaceId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: auth.target.name,
|
||||
description: `Promoted workflows from "${auth.source.name}" to "${auth.target.name}"`,
|
||||
metadata: {
|
||||
direction,
|
||||
sourceWorkspaceId: auth.sourceWorkspaceId,
|
||||
updated: result.updated,
|
||||
created: result.created,
|
||||
archived: result.archived,
|
||||
redeployed: result.redeployed,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
const otherName =
|
||||
otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name
|
||||
await recordBackgroundWork(db, {
|
||||
workspaceId: id,
|
||||
kind: 'fork_sync',
|
||||
status:
|
||||
result.deployFailed > 0 ||
|
||||
result.needsConfiguration.length > 0 ||
|
||||
result.clearedOptional.length > 0
|
||||
? 'completed_with_warnings'
|
||||
: 'completed',
|
||||
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
|
||||
metadata: {
|
||||
actorName: session.user.name ?? undefined,
|
||||
otherWorkspaceName: otherName,
|
||||
direction,
|
||||
updated: result.updated,
|
||||
created: result.created,
|
||||
archived: result.archived,
|
||||
redeployed: result.redeployed,
|
||||
deployFailed: result.deployFailed,
|
||||
updatedNames: result.updatedNames,
|
||||
createdNames: result.createdNames,
|
||||
archivedNames: result.archivedNames,
|
||||
needsConfiguration: result.needsConfiguration,
|
||||
clearedOptional: result.clearedOptional,
|
||||
},
|
||||
}).catch((error) =>
|
||||
logger.error(`[${requestId}] Failed to record sync activity`, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json(body)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import { db } from '@sim/db'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getForkResourcesContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import { listForkCopyableResources } from '@/lib/workspaces/fork/mapping/resources'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getForkResourcesContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
|
||||
await assertWorkspaceAdminAccess(id, session.user.id)
|
||||
|
||||
const resources = await listForkCopyableResources(db, id)
|
||||
return NextResponse.json(resources)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { rollbackForkContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { recordBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
|
||||
import { assertCanRollback } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import { rollbackFork } from '@/lib/workspaces/fork/promote/rollback'
|
||||
|
||||
const logger = createLogger('WorkspaceForkRollbackAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(rollbackForkContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId } = parsed.data.body
|
||||
|
||||
const target = await assertCanRollback(id, session.user.id)
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: id,
|
||||
otherWorkspaceId,
|
||||
userId: session.user.id,
|
||||
requestId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: id,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.WORKSPACE_FORK_ROLLED_BACK,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: target.name,
|
||||
description: `Rolled back the last promote into "${target.name}"`,
|
||||
metadata: { otherWorkspaceId, ...result },
|
||||
request: req,
|
||||
})
|
||||
|
||||
// Durable audit entry scoped to this workspace so the undo shows in its Manage Forks
|
||||
// → Activity log. Non-critical: a failure must not fail the (committed) rollback.
|
||||
const [other] = await db
|
||||
.select({ name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, otherWorkspaceId))
|
||||
.limit(1)
|
||||
const otherName = other?.name ?? 'the source workspace'
|
||||
await recordBackgroundWork(db, {
|
||||
workspaceId: id,
|
||||
kind: 'fork_rollback',
|
||||
status: result.skipped > 0 ? 'completed_with_warnings' : 'completed',
|
||||
message: `Undid the last sync from "${otherName}"`,
|
||||
metadata: {
|
||||
actorName: session.user.name ?? undefined,
|
||||
otherWorkspaceName: otherName,
|
||||
restored: result.restored,
|
||||
removed: result.archived,
|
||||
unarchived: result.unarchived,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
}).catch((error) =>
|
||||
logger.error(`[${requestId}] Failed to record rollback activity`, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { forkWorkspaceContract } from '@/lib/api/contracts/workspace-fork'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createFork } from '@/lib/workspaces/fork/create-fork'
|
||||
import { assertCanFork } from '@/lib/workspaces/fork/lineage/authz'
|
||||
|
||||
const logger = createLogger('WorkspaceForkAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const { id: sourceWorkspaceId } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { source, policy } = await assertCanFork(sourceWorkspaceId, session.user.id)
|
||||
|
||||
const parsed = await parseRequest(forkWorkspaceContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const copy = parsed.data.body.copy
|
||||
const result = await createFork({
|
||||
source,
|
||||
policy,
|
||||
userId: session.user.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
name: parsed.data.body.name,
|
||||
selection: {
|
||||
files: copy?.files ?? [],
|
||||
tables: copy?.tables ?? [],
|
||||
knowledgeBases: copy?.knowledgeBases ?? [],
|
||||
customTools: copy?.customTools ?? [],
|
||||
skills: copy?.skills ?? [],
|
||||
mcpServers: copy?.mcpServers ?? [],
|
||||
},
|
||||
requestId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: result.workspace.id,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.WORKSPACE_FORKED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: result.workspace.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: result.workspace.name,
|
||||
description: `Forked workspace from "${source.name}"`,
|
||||
metadata: {
|
||||
parentWorkspaceId: source.id,
|
||||
workflowsCopied: result.workflowsCopied,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Forked workspace ${sourceWorkspaceId} -> ${result.workspace.id}`)
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
}
|
||||
)
|
||||
+14
-8
@@ -6,6 +6,7 @@ import {
|
||||
resolveToolParamSync,
|
||||
} from '@/lib/workflows/tool-input/synthetic-subblocks'
|
||||
import { parseStoredToolInputValue } from '@/lib/workflows/tool-input/types'
|
||||
import { DependencyBlockTypeProvider } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type'
|
||||
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -17,6 +18,8 @@ interface ToolSubBlockRendererProps {
|
||||
toolIndex: number
|
||||
subBlock: BlockSubBlockConfig
|
||||
effectiveParamId: string
|
||||
/** The tool's block type (e.g. `gmail`), so its params' selectors resolve dependencies. */
|
||||
toolType: string
|
||||
toolParams: Record<string, string> | undefined
|
||||
onParamChange: (toolIndex: number, paramId: string, value: string) => void
|
||||
disabled: boolean
|
||||
@@ -44,6 +47,7 @@ export function ToolSubBlockRenderer({
|
||||
toolIndex,
|
||||
subBlock,
|
||||
effectiveParamId,
|
||||
toolType,
|
||||
toolParams,
|
||||
onParamChange,
|
||||
disabled,
|
||||
@@ -118,13 +122,15 @@ export function ToolSubBlockRenderer({
|
||||
}
|
||||
|
||||
return (
|
||||
<SubBlock
|
||||
blockId={blockId}
|
||||
config={config}
|
||||
isPreview={false}
|
||||
disabled={disabled}
|
||||
canonicalToggle={canonicalToggle}
|
||||
dependencyContext={toolParams}
|
||||
/>
|
||||
<DependencyBlockTypeProvider value={toolType}>
|
||||
<SubBlock
|
||||
blockId={blockId}
|
||||
config={config}
|
||||
isPreview={false}
|
||||
disabled={disabled}
|
||||
canonicalToggle={canonicalToggle}
|
||||
dependencyContext={toolParams}
|
||||
/>
|
||||
</DependencyBlockTypeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -2111,6 +2111,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
toolIndex={toolIndex}
|
||||
subBlock={sbWithTitle}
|
||||
effectiveParamId={effectiveParamId}
|
||||
toolType={tool.type}
|
||||
toolParams={tool.params}
|
||||
onParamChange={handleParamChange}
|
||||
disabled={disabled}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
const DependencyBlockTypeContext = createContext<string | null>(null)
|
||||
|
||||
/**
|
||||
* Provider set by tool-input param rendering (value = the tool's block type, e.g. `gmail`).
|
||||
*/
|
||||
export const DependencyBlockTypeProvider = DependencyBlockTypeContext.Provider
|
||||
|
||||
/**
|
||||
* The block type whose config should drive dependency (`dependsOn`) canonical resolution
|
||||
* for the current subblock. Null for normal blocks (resolve against the host block). Set
|
||||
* to the tool's type for tool-input params, so a nested tool's selector resolves its
|
||||
* parents against the TOOL's config (e.g. a Gmail tool's `credential` -> `oauthCredential`,
|
||||
* which the host Agent block's subblocks don't define) and can fetch its options.
|
||||
*/
|
||||
export const useDependencyBlockType = () => useContext(DependencyBlockTypeContext)
|
||||
+8
-1
@@ -15,6 +15,7 @@ import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { useDependencyBlockType } from './use-dependency-block-type'
|
||||
|
||||
/**
|
||||
* Centralized dependsOn gating for sub-block components.
|
||||
@@ -33,7 +34,13 @@ export function useDependsOnGate(
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const blockState = useWorkflowStore((state) => state.blocks[blockId])
|
||||
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
|
||||
|
||||
const dependencyBlockType = useDependencyBlockType()
|
||||
const blockConfig = dependencyBlockType
|
||||
? getBlock(dependencyBlockType)
|
||||
: blockState?.type
|
||||
? getBlock(blockState.type)
|
||||
: null
|
||||
const canonicalIndex = useMemo(
|
||||
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
|
||||
[blockConfig?.subBlocks]
|
||||
|
||||
+40
@@ -19,6 +19,8 @@ import {
|
||||
Mail,
|
||||
Pencil,
|
||||
Plus,
|
||||
Rocket,
|
||||
Shuffle,
|
||||
SquareArrowUpRight,
|
||||
Trash,
|
||||
Unlock,
|
||||
@@ -68,6 +70,10 @@ interface ContextMenuProps {
|
||||
onUploadLogo?: () => void
|
||||
showUploadLogo?: boolean
|
||||
disableUploadLogo?: boolean
|
||||
onFork?: () => void
|
||||
onSync?: () => void
|
||||
showFork?: boolean
|
||||
showSync?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,6 +124,10 @@ export function ContextMenu({
|
||||
onUploadLogo,
|
||||
showUploadLogo = false,
|
||||
disableUploadLogo = false,
|
||||
onFork,
|
||||
onSync,
|
||||
showFork = false,
|
||||
showSync = false,
|
||||
}: ContextMenuProps) {
|
||||
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
|
||||
const hasStatusSection =
|
||||
@@ -131,6 +141,7 @@ export function ContextMenu({
|
||||
(showLock && onToggleLock) ||
|
||||
(showUploadLogo && onUploadLogo)
|
||||
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
|
||||
const hasForkSection = (showFork && onFork) || (showSync && onSync)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
@@ -294,6 +305,35 @@ export function ContextMenu({
|
||||
)}
|
||||
|
||||
{(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
|
||||
hasForkSection && <DropdownMenuSeparator />}
|
||||
{showFork && onFork && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onFork()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Shuffle />
|
||||
Manage Forks
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showSync && onSync && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onSync()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<Rocket />
|
||||
Sync workspace
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{(hasNavigationSection ||
|
||||
hasStatusSection ||
|
||||
hasEditSection ||
|
||||
hasCopySection ||
|
||||
hasForkSection) &&
|
||||
(showLeave || showDelete) && <DropdownMenuSeparator />}
|
||||
{showLeave && onLeave && (
|
||||
<DropdownMenuItem
|
||||
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useState } from 'react'
|
||||
import { formatDateTime } from '@sim/utils/formatting'
|
||||
import { ChevronDown, Loader } from '@/components/emcn'
|
||||
import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useWorkspaceBackgroundWork } from '@/hooks/queries/background-work'
|
||||
|
||||
const HEADER_TEXT = 'font-medium text-[var(--text-tertiary)] text-caption'
|
||||
const ROW_TEXT = 'font-medium text-[var(--text-primary)] text-caption'
|
||||
|
||||
/** Fixed column widths shared by the header and rows so they stay aligned. */
|
||||
const COL = {
|
||||
BY: 'w-[132px]',
|
||||
CHEVRON: 'w-[14px]',
|
||||
} as const
|
||||
|
||||
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
|
||||
|
||||
/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */
|
||||
function countList(pairs: Array<[number | undefined, string]>): string {
|
||||
return pairs
|
||||
.filter(([n]) => (n ?? 0) > 0)
|
||||
.map(([n, verb]) => `${n} ${verb}`)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
/** A named, collapsible group (one resource kind or change action) of a job's report. */
|
||||
interface ReportGroup {
|
||||
label: string
|
||||
names: string[]
|
||||
}
|
||||
|
||||
/** A job's expanded report: collapsible named groups plus plain notes (counts / warnings). */
|
||||
interface JobReport {
|
||||
groups: ReportGroup[]
|
||||
notes: Array<{ value: string; warning?: boolean }>
|
||||
}
|
||||
|
||||
/** The audit-row title, derived per kind from the job's metadata. */
|
||||
function jobTitle(job: BackgroundWorkItem): string {
|
||||
const m = job.metadata
|
||||
switch (job.kind) {
|
||||
case 'fork_content_copy':
|
||||
return m?.childWorkspaceName
|
||||
? `Forked into "${m.childWorkspaceName}"`
|
||||
: (job.message ?? 'Fork')
|
||||
case 'fork_sync':
|
||||
if (!m?.otherWorkspaceName) return job.message ?? 'Sync'
|
||||
return m.direction === 'pull'
|
||||
? `Pulled from "${m.otherWorkspaceName}"`
|
||||
: `Pushed to "${m.otherWorkspaceName}"`
|
||||
case 'fork_rollback':
|
||||
return m?.otherWorkspaceName
|
||||
? `Undid sync from "${m.otherWorkspaceName}"`
|
||||
: (job.message ?? 'Rollback')
|
||||
default:
|
||||
return job.message ?? 'Activity'
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a job's report (collapsible named groups + plain notes) from its metadata. */
|
||||
function jobReport(job: BackgroundWorkItem): JobReport {
|
||||
const m = job.metadata
|
||||
const groups: ReportGroup[] = []
|
||||
const notes: JobReport['notes'] = []
|
||||
if (!m) return { groups, notes }
|
||||
|
||||
const addGroup = (label: string, names: string[] | undefined) => {
|
||||
if (names && names.length > 0) groups.push({ label, names })
|
||||
}
|
||||
|
||||
if (job.kind === 'fork_sync') {
|
||||
addGroup('Updated', m.updatedNames)
|
||||
addGroup('Created', m.createdNames)
|
||||
addGroup('Archived', m.archivedNames)
|
||||
// Pre-names entries fall back to the count summary (redeployed mirrors updated).
|
||||
if (groups.length === 0) {
|
||||
const counts = countList([
|
||||
[m.updated, 'updated'],
|
||||
[m.created, 'created'],
|
||||
[m.archived, 'archived'],
|
||||
])
|
||||
if (counts) notes.push({ value: counts })
|
||||
}
|
||||
if (m.needsConfiguration && m.needsConfiguration.length > 0) {
|
||||
for (const item of m.needsConfiguration) {
|
||||
notes.push({
|
||||
value: `${item.workflowName} — re-check ${item.blocks.join(', ')}`,
|
||||
warning: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (m.clearedOptional && m.clearedOptional.length > 0) {
|
||||
for (const item of m.clearedOptional) {
|
||||
notes.push({
|
||||
value: `${item.workflowName} — optional cleared in ${item.blocks.join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (m.deployFailed && m.deployFailed > 0) {
|
||||
notes.push({ value: `${plural(m.deployFailed, 'workflow')} failed to deploy`, warning: true })
|
||||
}
|
||||
return { groups, notes }
|
||||
}
|
||||
|
||||
if (job.kind === 'fork_rollback') {
|
||||
const counts = countList([
|
||||
[m.restored, 'restored'],
|
||||
[m.unarchived, 'unarchived'],
|
||||
[m.removed, 'removed'],
|
||||
[m.skipped, 'skipped'],
|
||||
])
|
||||
if (counts) notes.push({ value: counts })
|
||||
return { groups, notes }
|
||||
}
|
||||
|
||||
// fork_content_copy: a named breakdown of everything copied, by kind.
|
||||
addGroup('Workflows', m.workflowNames)
|
||||
addGroup('Knowledge bases', m.knowledgeBaseNames)
|
||||
addGroup('Tables', m.tableNames)
|
||||
addGroup('Files', m.fileNames)
|
||||
addGroup('Custom tools', m.customToolNames)
|
||||
addGroup('Skills', m.skillNames)
|
||||
addGroup('MCP servers', m.mcpServerNames)
|
||||
// Pre-names entries fall back to the per-kind counts.
|
||||
if (groups.length === 0) {
|
||||
const counts = [
|
||||
[m.workflowsCopied, 'workflow'],
|
||||
[m.knowledgeBases, 'knowledge base'],
|
||||
[m.tables, 'table'],
|
||||
[m.files, 'file'],
|
||||
]
|
||||
.filter(([n]) => ((n as number | undefined) ?? 0) > 0)
|
||||
.map(([n, noun]) => plural(n as number, noun as string))
|
||||
.join(' · ')
|
||||
if (counts) notes.push({ value: counts })
|
||||
}
|
||||
if (m.failed && m.failed > 0) {
|
||||
notes.push({ value: `${plural(m.failed, 'resource')} failed to copy`, warning: true })
|
||||
}
|
||||
return { groups, notes }
|
||||
}
|
||||
|
||||
/** Status indicator: the platform loader while active, a colored dot once terminal. */
|
||||
function JobStatusIndicator({ status }: { status: BackgroundWorkItem['status'] }) {
|
||||
if (status === 'pending' || status === 'processing') {
|
||||
return <Loader animate className='size-[12px] shrink-0 text-[var(--text-tertiary)]' />
|
||||
}
|
||||
const color =
|
||||
status === 'failed'
|
||||
? 'bg-[var(--text-error)]'
|
||||
: status === 'completed_with_warnings'
|
||||
? 'bg-[var(--badge-amber-text)]'
|
||||
: 'bg-[var(--indicator-active)]'
|
||||
const label =
|
||||
status === 'failed'
|
||||
? 'Failed'
|
||||
: status === 'completed_with_warnings'
|
||||
? 'Completed with warnings'
|
||||
: 'Done'
|
||||
return <span className={cn('size-[6px] shrink-0 rounded-full', color)} title={label} />
|
||||
}
|
||||
|
||||
/** A collapsed report group ("Label N ⌄") that expands to its scrollable name list. */
|
||||
function ReportGroupRow({ group }: { group: ReportGroup }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className='flex w-full items-center gap-2 text-left text-[var(--text-secondary)] text-caption transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>{group.label}</span>
|
||||
<span className='shrink-0 text-[var(--text-muted)]'>{group.names.length}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className='max-h-44 overflow-y-auto pb-1'>
|
||||
{group.names.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className='truncate text-[var(--text-tertiary)] text-caption leading-6'
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One audit-log row: status + title + actor; expands to the timestamp + report. */
|
||||
function ForkJobRow({ job }: { job: BackgroundWorkItem }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const report = jobReport(job)
|
||||
const title = jobTitle(job)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='flex h-[36px] cursor-pointer items-center px-4 transition-colors duration-100 hover-hover:bg-[var(--surface-6)] dark:hover-hover:bg-[var(--border)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setExpanded((value) => !value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
|
||||
<JobStatusIndicator status={job.status} />
|
||||
<span className={cn('min-w-0 truncate', ROW_TEXT)} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(COL.BY, 'shrink-0 truncate text-[var(--text-tertiary)]', ROW_TEXT)}
|
||||
title={job.metadata?.actorName ?? undefined}
|
||||
>
|
||||
{job.metadata?.actorName || '—'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
COL.CHEVRON,
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className='flex flex-col gap-1.5 px-4 pb-3 pl-[42px]'>
|
||||
<span className='text-[var(--text-muted)] text-caption'>
|
||||
{formatDateTime(new Date(job.startedAt))}
|
||||
</span>
|
||||
{report.groups.map((group) => (
|
||||
<ReportGroupRow key={group.label} group={group} />
|
||||
))}
|
||||
{report.notes.map((note) => (
|
||||
<span
|
||||
key={note.value}
|
||||
className={cn(
|
||||
'text-caption',
|
||||
note.warning ? 'text-[var(--text-error)]' : 'text-[var(--text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{note.value}
|
||||
</span>
|
||||
))}
|
||||
{job.error ? (
|
||||
<span className='text-[var(--text-error)] text-caption'>{job.error}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
/** Audit-log table of fork jobs, mirroring the deployment-versions table chrome. */
|
||||
function ForkJobsTable({ jobs }: { jobs: BackgroundWorkItem[] }) {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-sm border border-[var(--border)]'>
|
||||
<div className='flex h-[30px] items-center bg-[var(--surface-1)] px-4'>
|
||||
<span className={cn('flex-1', HEADER_TEXT)}>Activity</span>
|
||||
<span className={cn(COL.BY, 'shrink-0', HEADER_TEXT)}>By</span>
|
||||
<span className={cn(COL.CHEVRON, 'shrink-0')} />
|
||||
</div>
|
||||
<div className='bg-[var(--surface-2)]'>
|
||||
{jobs.map((job) => (
|
||||
<ForkJobRow key={job.id} job={job} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ForkActivityPanelProps {
|
||||
/** The triggering operation is currently running (mutation in flight). */
|
||||
pending?: boolean
|
||||
pendingLabel?: string
|
||||
/** Poll the durable fork-job audit trail for this workspace. */
|
||||
backgroundWorkspaceId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Activity" tab for Manage Forks: a durable audit log of every fork, sync, and
|
||||
* rollback as its own row (status, title, actor), each expanding to the timestamp and a
|
||||
* collapsible per-kind breakdown of what changed. A loader shows while the current
|
||||
* action runs.
|
||||
*/
|
||||
export function ForkActivityPanel({
|
||||
pending = false,
|
||||
pendingLabel = 'Working…',
|
||||
backgroundWorkspaceId,
|
||||
}: ForkActivityPanelProps) {
|
||||
const { data: jobs = [] } = useWorkspaceBackgroundWork(backgroundWorkspaceId)
|
||||
|
||||
if (!pending && jobs.length === 0) {
|
||||
return (
|
||||
<div className='px-2 py-8 text-center text-[var(--text-muted)] text-small'>
|
||||
Nothing here yet. Forks, syncs, and rollbacks will appear here.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-5 px-2'>
|
||||
{pending ? (
|
||||
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
|
||||
<Loader animate className='size-[14px] shrink-0 text-[var(--text-tertiary)]' />
|
||||
<span>{pendingLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{jobs.length > 0 ? <ForkJobsTable jobs={jobs} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Checkbox,
|
||||
ChevronDown,
|
||||
Chip,
|
||||
ChipConfirmModal,
|
||||
ChipCopyInput,
|
||||
ChipInput,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalFooter,
|
||||
type ChipModalFooterSlotAction,
|
||||
ChipModalHeader,
|
||||
ChipModalTabs,
|
||||
Tooltip,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import type {
|
||||
ForkCopyableResource,
|
||||
GetForkResourcesResponse,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { ForkActivityPanel } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel'
|
||||
import {
|
||||
type ForkDirection,
|
||||
useForkResources,
|
||||
useForkWorkspace,
|
||||
useRollbackFork,
|
||||
} from '@/hooks/queries/workspace-fork'
|
||||
|
||||
interface ForkWorkspaceModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sourceWorkspaceId: string
|
||||
sourceWorkspaceName: string
|
||||
/** The last sync into this workspace that can be undone (drives the rollback action). */
|
||||
undoableRun: { otherWorkspaceId: string; otherName: string; direction: ForkDirection } | null
|
||||
/** Whether the user is under their workspace cap; creating a fork is gated on this. */
|
||||
canFork: boolean
|
||||
/** Sends the user to upgrade (billing) when they try to fork at the cap. */
|
||||
onUpgrade: () => void
|
||||
}
|
||||
|
||||
type ResourceKey = Exclude<keyof GetForkResourcesResponse, 'deployedWorkflowCount'>
|
||||
type ResourceSelection = Record<ResourceKey, Set<string>>
|
||||
|
||||
const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [
|
||||
{ key: 'files', label: 'Files' },
|
||||
{ key: 'tables', label: 'Tables' },
|
||||
{ key: 'knowledgeBases', label: 'Knowledge bases' },
|
||||
{ key: 'customTools', label: 'Custom tools' },
|
||||
{ key: 'skills', label: 'Skills' },
|
||||
{ key: 'mcpServers', label: 'MCP servers' },
|
||||
]
|
||||
|
||||
/** Show the inline search once a kind has more entries than fit comfortably. */
|
||||
const SEARCH_THRESHOLD = 8
|
||||
|
||||
const emptySelection = (): ResourceSelection => ({
|
||||
files: new Set(),
|
||||
tables: new Set(),
|
||||
knowledgeBases: new Set(),
|
||||
customTools: new Set(),
|
||||
skills: new Set(),
|
||||
mcpServers: new Set(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One expandable resource kind in the fork picker: a tri-state "select all" header
|
||||
* (count of selected / total) plus, when expanded, a searchable scrollable list of
|
||||
* individual resources so the user can copy a specific subset.
|
||||
*/
|
||||
function ResourceKindRow({
|
||||
label,
|
||||
items,
|
||||
selected,
|
||||
onToggleAll,
|
||||
onToggleItem,
|
||||
disabled,
|
||||
}: {
|
||||
label: string
|
||||
items: ForkCopyableResource[]
|
||||
selected: Set<string>
|
||||
onToggleAll: (selectAll: boolean) => void
|
||||
onToggleItem: (id: string, checked: boolean) => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const fieldId = useId()
|
||||
|
||||
const total = items.length
|
||||
const selectedCount = selected.size
|
||||
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const trimmed = query.trim().toLowerCase()
|
||||
if (!trimmed) return items
|
||||
return items.filter((item) => item.label.toLowerCase().includes(trimmed))
|
||||
}, [items, query])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
aria-label={`Copy all ${label}`}
|
||||
checked={headerState}
|
||||
onCheckedChange={() => onToggleAll(headerState !== true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
expanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className='ml-6 flex flex-col gap-1'>
|
||||
{total > SEARCH_THRESHOLD ? (
|
||||
<ChipInput
|
||||
icon={Search}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={`Search ${label.toLowerCase()}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
<div className='flex max-h-44 flex-col gap-0.5 overflow-y-auto'>
|
||||
{filtered.map((item) => {
|
||||
const isChecked = selected.has(item.id)
|
||||
const itemId = `${fieldId}-${item.id}`
|
||||
return (
|
||||
<label
|
||||
key={item.id}
|
||||
htmlFor={itemId}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={itemId}
|
||||
size='sm'
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className='truncate'>{item.label}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 ? (
|
||||
<p className='py-1 text-[var(--text-secondary)] text-xs'>No matches</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Names and creates a fork of the current workspace, lets the user pick which
|
||||
* resources to copy (whole kinds or a specific subset), then navigates into the new
|
||||
* fork. Unselected resources leave the corresponding workflow subblocks empty.
|
||||
*/
|
||||
export function ForkWorkspaceModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
sourceWorkspaceId,
|
||||
sourceWorkspaceName,
|
||||
undoableRun,
|
||||
canFork,
|
||||
onUpgrade,
|
||||
}: ForkWorkspaceModalProps) {
|
||||
const router = useRouter()
|
||||
const forkWorkspace = useForkWorkspace()
|
||||
const rollback = useRollbackFork()
|
||||
const resources = useForkResources(sourceWorkspaceId, open)
|
||||
const [name, setName] = useState('')
|
||||
const [selected, setSelected] = useState<ResourceSelection>(emptySelection)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'config' | 'activity'>('config')
|
||||
const [forkedWorkspace, setForkedWorkspace] = useState<{ id: string; name: string } | null>(null)
|
||||
const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(`${sourceWorkspaceName} (fork)`)
|
||||
setSelected(emptySelection())
|
||||
setError(null)
|
||||
setActiveTab('config')
|
||||
setForkedWorkspace(null)
|
||||
setConfirmRollbackOpen(false)
|
||||
}
|
||||
}, [open, sourceWorkspaceName])
|
||||
|
||||
const isForking = forkWorkspace.isPending
|
||||
|
||||
const availableKinds = useMemo(
|
||||
() => RESOURCE_KINDS.filter((kind) => (resources.data?.[kind.key].length ?? 0) > 0),
|
||||
[resources.data]
|
||||
)
|
||||
|
||||
// A fork always produces a usable workspace: deployed workflows are copied, and
|
||||
// when the source has none, create-fork seeds a blank starter workflow (plus any
|
||||
// selected resources). So forking is never blocked - we just set expectations when
|
||||
// there are no deployed workflows to carry over.
|
||||
const noDeployedWorkflows =
|
||||
Boolean(resources.data) && (resources.data?.deployedWorkflowCount ?? 0) === 0
|
||||
|
||||
const handleSubmit = () => {
|
||||
// At a workspace cap, creating a fork is the only gated action - send the user to
|
||||
// upgrade rather than blocking the whole modal (rollback / Activity stay reachable).
|
||||
if (!canFork) {
|
||||
onUpgrade()
|
||||
return
|
||||
}
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || isForking) return
|
||||
setError(null)
|
||||
const copy = resources.data
|
||||
? Object.fromEntries(RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])]))
|
||||
: undefined
|
||||
forkWorkspace.mutate(
|
||||
{ workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Forked into "${result.workspace.name}"`)
|
||||
setForkedWorkspace({ id: result.workspace.id, name: result.workspace.name })
|
||||
setActiveTab('activity')
|
||||
},
|
||||
onError: (err) => setError(err.message || 'Failed to fork workspace'),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const openFork = () => {
|
||||
if (!forkedWorkspace) return
|
||||
onOpenChange(false)
|
||||
router.push(`/workspace/${forkedWorkspace.id}/w`)
|
||||
}
|
||||
|
||||
// Rollback undoes the last sync INTO this workspace, restoring each affected workflow
|
||||
// to its prior deployed version. Lives in the Activity tab's footer.
|
||||
const runRollback = async () => {
|
||||
if (!undoableRun) return
|
||||
try {
|
||||
await rollback.mutateAsync({
|
||||
workspaceId: sourceWorkspaceId,
|
||||
body: { otherWorkspaceId: undoableRun.otherWorkspaceId },
|
||||
})
|
||||
toast.success(`Undid sync from "${undoableRun.otherName}"`)
|
||||
setConfirmRollbackOpen(false)
|
||||
setActiveTab('activity')
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Undo failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const rollbackDisabled = rollback.isPending || !undoableRun
|
||||
const rollbackTooltip = undoableRun
|
||||
? `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`
|
||||
: 'No sync to roll back yet.'
|
||||
const rollbackChip = (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className={rollbackDisabled ? 'inline-flex cursor-not-allowed' : 'inline-flex'}>
|
||||
<Chip
|
||||
variant='destructive'
|
||||
flush
|
||||
onClick={() => setConfirmRollbackOpen(true)}
|
||||
disabled={rollbackDisabled}
|
||||
className={rollbackDisabled ? 'pointer-events-none' : undefined}
|
||||
>
|
||||
Rollback
|
||||
</Chip>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{rollbackTooltip}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
const rollbackAction: ChipModalFooterSlotAction[] =
|
||||
activeTab === 'activity' && undoableRun ? [{ custom: rollbackChip }] : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Manage Forks'>
|
||||
<ChipModalHeader onClose={() => onOpenChange(false)}>Manage Forks</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalTabs
|
||||
tabs={[
|
||||
{ value: 'config', label: 'Fork' },
|
||||
{ value: 'activity', label: 'Activity' },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={(value) => setActiveTab(value as 'config' | 'activity')}
|
||||
className='mx-2'
|
||||
/>
|
||||
{activeTab === 'activity' ? (
|
||||
<ForkActivityPanel
|
||||
pending={isForking || rollback.isPending}
|
||||
pendingLabel={rollback.isPending ? 'Undoing last sync…' : 'Creating fork…'}
|
||||
backgroundWorkspaceId={sourceWorkspaceId}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex flex-col gap-7 px-2'>
|
||||
<SettingsSection label='Forking from'>
|
||||
<ChipCopyInput value={sourceWorkspaceName} aria-label='Forking from' />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
label='Name'
|
||||
headerAccessory={
|
||||
<span className='text-[var(--text-error)]' title='Required'>
|
||||
*
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ChipInput
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder='Workspace name'
|
||||
maxLength={100}
|
||||
autoComplete='off'
|
||||
disabled={isForking}
|
||||
aria-label='Workspace name'
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{availableKinds.length > 0 ? (
|
||||
<SettingsSection label='Copy resources'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{availableKinds.map((kind) => (
|
||||
<ResourceKindRow
|
||||
key={kind.key}
|
||||
label={kind.label}
|
||||
items={resources.data?.[kind.key] ?? []}
|
||||
selected={selected[kind.key]}
|
||||
onToggleAll={(selectAll) =>
|
||||
setSelected((prev) => ({
|
||||
...prev,
|
||||
[kind.key]: selectAll
|
||||
? new Set((resources.data?.[kind.key] ?? []).map((item) => item.id))
|
||||
: new Set<string>(),
|
||||
}))
|
||||
}
|
||||
onToggleItem={(id, checked) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev[kind.key])
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
return { ...prev, [kind.key]: next }
|
||||
})
|
||||
}
|
||||
disabled={isForking}
|
||||
/>
|
||||
))}
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
Unselected resources leave their workflow fields empty in the fork.
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{noDeployedWorkflows ? (
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
No deployed workflows to copy — your fork will start with a blank workflow.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ChipModalError>{error ?? undefined}</ChipModalError>
|
||||
</>
|
||||
)}
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
cancelDisabled={isForking}
|
||||
secondaryActions={rollbackAction.length > 0 ? rollbackAction : undefined}
|
||||
primaryAction={
|
||||
activeTab === 'activity'
|
||||
? forkedWorkspace
|
||||
? { label: 'Open fork', onClick: openFork }
|
||||
: { label: 'Done', onClick: () => onOpenChange(false) }
|
||||
: {
|
||||
label: isForking ? 'Forking...' : 'Fork',
|
||||
onClick: handleSubmit,
|
||||
// At the cap the button stays clickable (no name needed) so it can route to upgrade.
|
||||
disabled: isForking || (canFork && !name.trim()),
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ChipModal>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={confirmRollbackOpen}
|
||||
onOpenChange={setConfirmRollbackOpen}
|
||||
srTitle='Undo last sync'
|
||||
title='Undo last sync'
|
||||
text={[
|
||||
'This restores each affected workflow to its ',
|
||||
{ text: 'prior deployed version', bold: true },
|
||||
' and removes workflows the sync created. Continue?',
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Rollback',
|
||||
onClick: () => void runRollback(),
|
||||
pending: rollback.isPending,
|
||||
pendingLabel: 'Rolling back...',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ChipCombobox, type ComboboxOption, Loader } from '@/components/emcn'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
|
||||
|
||||
interface DependentFieldSelectorProps {
|
||||
selectorKey: SelectorKey
|
||||
/** Full selector context, including the newly-chosen parent value. */
|
||||
context: Record<string, string>
|
||||
/** False until the parent (credential/KB) target is chosen. */
|
||||
enabled: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A controlled, standalone selector for the sync modal's pre-sync reconfigure: fetches
|
||||
* options via the shared selector data layer (the same `useSelectorOptions` registry the
|
||||
* canvas selectors use) without the canvas store/blockId coupling. Mirrors
|
||||
* {@link ConnectorSelectorField}.
|
||||
*/
|
||||
export function DependentFieldSelector({
|
||||
selectorKey,
|
||||
context,
|
||||
enabled,
|
||||
value,
|
||||
onChange,
|
||||
title,
|
||||
}: DependentFieldSelectorProps) {
|
||||
const selectorContext = useMemo<SelectorContext>(() => {
|
||||
const ctx: SelectorContext = {}
|
||||
Object.assign(ctx, context)
|
||||
return ctx
|
||||
}, [context])
|
||||
|
||||
const { data: options = [], isLoading } = useSelectorOptions(selectorKey, {
|
||||
context: selectorContext,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => options.map((option) => ({ label: option.label, value: option.id })),
|
||||
[options]
|
||||
)
|
||||
|
||||
if (isLoading && enabled) {
|
||||
return (
|
||||
<div className='flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-medium text-[var(--text-muted)] text-small dark:bg-[var(--surface-4)]'>
|
||||
<Loader className='size-3.5' animate />
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipCombobox
|
||||
className='w-full'
|
||||
options={comboboxOptions}
|
||||
value={value || undefined}
|
||||
onChange={(next) => onChange(next)}
|
||||
searchable
|
||||
searchPlaceholder={`Search ${title.toLowerCase()}...`}
|
||||
placeholder={`Select ${title.toLowerCase()}`}
|
||||
disabled={!enabled}
|
||||
emptyMessage={`No ${title.toLowerCase()} found`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
'use client'
|
||||
|
||||
import { type Dispatch, type SetStateAction, useMemo, useState } from 'react'
|
||||
import { ChevronDown } from '@/components/emcn'
|
||||
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { DependentFieldSelector } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector'
|
||||
import {
|
||||
dependentKey,
|
||||
effectiveDependentValue,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
/** Stable empty array so a workflow with no dependents reuses one reference (no per-map alloc). */
|
||||
const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
|
||||
|
||||
interface ReconfigBlock {
|
||||
targetBlockId: string
|
||||
blockName: string
|
||||
fields: ForkDependentReconfig[]
|
||||
}
|
||||
|
||||
/** Group a workflow's dependent fields by their block, sorted by block name. */
|
||||
function groupByBlock(fields: ForkDependentReconfig[]): ReconfigBlock[] {
|
||||
const byBlock = new Map<string, ReconfigBlock>()
|
||||
for (const field of fields) {
|
||||
let block = byBlock.get(field.targetBlockId)
|
||||
if (!block) {
|
||||
block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] }
|
||||
byBlock.set(field.targetBlockId, block)
|
||||
}
|
||||
block.fields.push(field)
|
||||
}
|
||||
return Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
|
||||
}
|
||||
|
||||
interface ResourceReconfigureProps {
|
||||
/** Every workflow this resource is used in (from the diff's `resourceUsages`). */
|
||||
workflows: ForkResourceUsage['workflows']
|
||||
/** This resource's dependent fields across all its workflows (from `dependentReconfigs`). */
|
||||
dependents: ForkDependentReconfig[]
|
||||
/** The chosen target id (credential/KB/table) the selectors query against. */
|
||||
parentTargetValue: string
|
||||
/** True when the target was changed in-session: start blank (the old value won't resolve). */
|
||||
parentChanged: boolean
|
||||
/**
|
||||
* The target workspace the dependent selectors query against (direction-aware: the parent on
|
||||
* push, the child on pull). Workspace-scoped selectors like `table.columns` and sim workflow
|
||||
* pickers gate on it - the canvas supplies it from the active workspace, so the modal must too.
|
||||
*/
|
||||
workspaceId: string
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on per-resource reconfigure listing: every workflow the resource is used in, each a
|
||||
* chevron row that expands to its blocks + dependent selectors so the user can (re)configure
|
||||
* them at any time - not only right after a target swap. A workflow with nothing configurable
|
||||
* (a secret/file, or a credential with no dependent selector here) renders greyed and
|
||||
* non-expandable with a tooltip, so the usage is still visible.
|
||||
*/
|
||||
export function ResourceReconfigure({
|
||||
workflows,
|
||||
dependents,
|
||||
parentTargetValue,
|
||||
parentChanged,
|
||||
workspaceId,
|
||||
reconfig,
|
||||
setReconfig,
|
||||
}: ResourceReconfigureProps) {
|
||||
// Group each workflow's dependents into blocks once per (workflows, dependents) change, so
|
||||
// the grouping doesn't re-run on every parent re-render (setTargets / setReconfig fire often
|
||||
// during the editing step). Bucket dependents by target workflow in a single pass first, so
|
||||
// the per-workflow lookup is O(1) instead of a fresh `.filter` per workflow (O(W x D)).
|
||||
const workflowBlocks = useMemo(() => {
|
||||
const dependentsByWorkflow = new Map<string, ForkDependentReconfig[]>()
|
||||
for (const dependent of dependents) {
|
||||
const list = dependentsByWorkflow.get(dependent.targetWorkflowId)
|
||||
if (list) list.push(dependent)
|
||||
else dependentsByWorkflow.set(dependent.targetWorkflowId, [dependent])
|
||||
}
|
||||
return workflows.map((workflow) => ({
|
||||
workflowId: workflow.workflowId,
|
||||
workflowName: workflow.workflowName,
|
||||
blocks: groupByBlock(dependentsByWorkflow.get(workflow.workflowId) ?? EMPTY_DEPENDENTS),
|
||||
}))
|
||||
}, [workflows, dependents])
|
||||
|
||||
if (workflows.length === 0) return null
|
||||
return (
|
||||
<div className='mt-4'>
|
||||
<SettingsSection label='Workflows'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{workflowBlocks.map((workflow) => (
|
||||
<ReconfigWorkflowRow
|
||||
key={workflow.workflowId}
|
||||
workflowName={workflow.workflowName}
|
||||
blocks={workflow.blocks}
|
||||
parentTargetValue={parentTargetValue}
|
||||
parentChanged={parentChanged}
|
||||
workspaceId={workspaceId}
|
||||
reconfig={reconfig}
|
||||
setReconfig={setReconfig}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReconfigWorkflowRowProps {
|
||||
workflowName: string
|
||||
blocks: ReconfigBlock[]
|
||||
parentTargetValue: string
|
||||
parentChanged: boolean
|
||||
workspaceId: string
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}
|
||||
|
||||
/** One workflow row: a chevron header (greyed + non-expandable when nothing to configure). */
|
||||
function ReconfigWorkflowRow({
|
||||
workflowName,
|
||||
blocks,
|
||||
parentTargetValue,
|
||||
parentChanged,
|
||||
workspaceId,
|
||||
reconfig,
|
||||
setReconfig,
|
||||
}: ReconfigWorkflowRowProps) {
|
||||
// Auto-open a row that has a required dependent so it's visible without hunting through
|
||||
// chevrons (a required field is what gates Sync). Deterministic from the block config at mount
|
||||
// (lazy initializer, no effect/flicker); the user can still collapse it, and it won't reopen on
|
||||
// re-render - only if the row remounts (its workflow changes).
|
||||
const [open, setOpen] = useState(() =>
|
||||
blocks.some((block) => block.fields.some((field) => field.required))
|
||||
)
|
||||
const configurable = blocks.length > 0
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1', configurable && open && 'pb-2')}>
|
||||
{/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A greyed,
|
||||
non-expandable row uses a native title tooltip to explain why. */}
|
||||
<button
|
||||
type='button'
|
||||
disabled={!configurable}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
title={configurable ? undefined : 'Used here, but nothing to configure for this resource'}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 text-left text-sm transition-colors',
|
||||
configurable
|
||||
? 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
: 'cursor-default text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>{workflowName}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
configurable ? open && 'rotate-180' : 'opacity-40'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{configurable && open
|
||||
? blocks.map((block) => (
|
||||
<BlockReconfig
|
||||
key={block.targetBlockId}
|
||||
block={block}
|
||||
parentTargetValue={parentTargetValue}
|
||||
parentChanged={parentChanged}
|
||||
workspaceId={workspaceId}
|
||||
reconfig={reconfig}
|
||||
setReconfig={setReconfig}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BlockReconfigProps {
|
||||
block: ReconfigBlock
|
||||
parentTargetValue: string
|
||||
parentChanged: boolean
|
||||
workspaceId: string
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}
|
||||
|
||||
/** One block card: its dependent selectors, chained so a parent feeds its in-block children. */
|
||||
function BlockReconfig({
|
||||
block,
|
||||
parentTargetValue,
|
||||
parentChanged,
|
||||
workspaceId,
|
||||
reconfig,
|
||||
setReconfig,
|
||||
}: BlockReconfigProps) {
|
||||
// A field's effective value: the user's re-pick, else the stored value (stable parent) - but
|
||||
// blank after a parent change, since the old value no longer resolves. Shared with the modal.
|
||||
const effectiveValue = (field: ForkDependentReconfig) =>
|
||||
effectiveDependentValue(field, reconfig, parentChanged)
|
||||
|
||||
// Chain re-picks: a field that provides a SelectorContext key feeds its effective value to
|
||||
// its in-block descendants (a spreadsheet drives the sheet selector). Track only WHICH keys
|
||||
// an in-block field provides (a Set) - the readiness check below tests membership, never a value.
|
||||
const providedValues: Record<string, string> = {}
|
||||
const providedContextKeys = new Set<string>()
|
||||
for (const field of block.fields) {
|
||||
if (field.providesContextKey) {
|
||||
providedContextKeys.add(field.providesContextKey)
|
||||
const value = effectiveValue(field)
|
||||
if (value) providedValues[field.providesContextKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='ml-2 flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] p-3'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-small'>{block.blockName}</span>
|
||||
{block.fields.map((field) => {
|
||||
// Disabled until the parent target is set AND every in-block parent it depends on has
|
||||
// a value, so a child never queries a stale upstream value.
|
||||
const ready = field.consumesContextKeys.every(
|
||||
(key) => !providedContextKeys.has(key) || providedValues[key] !== undefined
|
||||
)
|
||||
return (
|
||||
<div key={dependentKey(field)} className='flex flex-col gap-1'>
|
||||
<span className='text-[var(--text-tertiary)] text-caption'>
|
||||
{field.title}
|
||||
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
|
||||
</span>
|
||||
<DependentFieldSelector
|
||||
selectorKey={field.selectorKey as SelectorKey}
|
||||
context={{
|
||||
...field.context,
|
||||
...providedValues,
|
||||
// Target workspace, for workspace-scoped selectors like table.columns.
|
||||
workspaceId,
|
||||
[field.parentContextKey]: parentTargetValue,
|
||||
}}
|
||||
enabled={parentTargetValue !== '' && ready}
|
||||
value={effectiveValue(field)}
|
||||
onChange={(value) =>
|
||||
setReconfig((prev) => {
|
||||
const nextState = { ...prev, [dependentKey(field)]: value }
|
||||
// A changed parent invalidates its children's stale re-picks.
|
||||
const providedKey = field.providesContextKey
|
||||
if (providedKey) {
|
||||
for (const sibling of block.fields) {
|
||||
if (sibling.consumesContextKeys.includes(providedKey)) {
|
||||
delete nextState[dependentKey(sibling)]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextState
|
||||
})
|
||||
}
|
||||
title={field.title}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
/** Stable key for a per-target dependent re-pick (target workflow + block + subblock). */
|
||||
export function dependentKey(dependent: ForkDependentReconfig): string {
|
||||
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
|
||||
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old
|
||||
* stored value was for the previous parent and won't resolve against the new one. Shared by the
|
||||
* modal (gate + payload) and the per-block selector so the rule can't drift between them.
|
||||
*/
|
||||
export function effectiveDependentValue(
|
||||
field: ForkDependentReconfig,
|
||||
reconfig: Record<string, string>,
|
||||
parentChanged: boolean
|
||||
): string {
|
||||
const repicked = reconfig[dependentKey(field)]
|
||||
if (repicked !== undefined) return repicked
|
||||
return parentChanged ? '' : field.currentValue
|
||||
}
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
ChipCombobox,
|
||||
ChipConfirmModal,
|
||||
ChipDropdown,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalFooter,
|
||||
type ChipModalFooterSlotAction,
|
||||
ChipModalHeader,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import type {
|
||||
ForkDependentReconfig,
|
||||
ForkLineageNodeApi,
|
||||
ForkMappingEntry,
|
||||
ForkResourceUsage,
|
||||
ForkWorkflowChange,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { ResourceReconfigure } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure'
|
||||
import {
|
||||
dependentKey,
|
||||
effectiveDependentValue,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value'
|
||||
import {
|
||||
type ForkDirection,
|
||||
useForkDiff,
|
||||
useForkMapping,
|
||||
usePromoteFork,
|
||||
useUpdateForkMapping,
|
||||
} from '@/hooks/queries/workspace-fork'
|
||||
|
||||
interface PromoteWorkspaceModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workspaceId: string
|
||||
parent: ForkLineageNodeApi | null
|
||||
}
|
||||
|
||||
const entryKey = (entry: ForkMappingEntry) => `${entry.kind}:${entry.sourceId}`
|
||||
|
||||
/**
|
||||
* Whether a mapping entry needs an in-place reconfigure: its effective target was changed
|
||||
* in-session, or it's an unconfirmed suggestion (accepting it as-is still remaps + clears
|
||||
* the dependents). Pure over (entry, in-session targets) so both the inline render and the
|
||||
* Sync gate / override collection share one predicate instead of drifting copies.
|
||||
*/
|
||||
function shouldReconfigureEntry(entry: ForkMappingEntry, targets: Record<string, string>): boolean {
|
||||
const next = targets[entryKey(entry)] ?? entry.targetId ?? ''
|
||||
if (next === '') return false
|
||||
return entry.suggested || next !== (entry.targetId ?? '')
|
||||
}
|
||||
|
||||
/** Shared empty owners map for the pull direction so the options mapper never re-allocates. */
|
||||
const EMPTY_TARGET_OWNERS: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
/**
|
||||
* Stable empty arrays so an entry with no usages/dependents keeps a constant prop reference,
|
||||
* letting ResourceReconfigure's grouping memo skip recompute across the editing step's frequent
|
||||
* re-renders.
|
||||
*/
|
||||
const EMPTY_USAGES: ForkResourceUsage['workflows'] = []
|
||||
const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
|
||||
|
||||
/**
|
||||
* Targets already taken by OTHER sources in the same kind, each mapped to the owning
|
||||
* source's label (for a hint). Used to disable those targets on PUSH: a push row is unique
|
||||
* on the parent (target) side, so a parent target can back only one source - a second source
|
||||
* picking it would be silently dropped on save. Pull is the inverse (many parent sources may
|
||||
* share one fork target, which resolves correctly), so pull passes the empty map and never
|
||||
* disables. Excludes `exclude` so a source never disables its own current selection.
|
||||
*/
|
||||
function takenTargetOwners(
|
||||
items: ForkMappingEntry[],
|
||||
targets: Record<string, string>,
|
||||
exclude: ForkMappingEntry
|
||||
): Map<string, string> {
|
||||
const owners = new Map<string, string>()
|
||||
for (const item of items) {
|
||||
if (entryKey(item) === entryKey(exclude)) continue
|
||||
const target = targets[entryKey(item)] ?? item.targetId ?? ''
|
||||
if (target !== '') owners.set(target, item.sourceLabel)
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
/** Section label + display order per mapping kind (one mapping step per kind). */
|
||||
const MAPPING_SECTION: Record<ForkMappingEntry['kind'], { label: string; order: number }> = {
|
||||
credential: { label: 'Credentials', order: 0 },
|
||||
'env-var': { label: 'Secrets', order: 1 },
|
||||
table: { label: 'Tables', order: 2 },
|
||||
'knowledge-base': { label: 'Knowledge bases', order: 3 },
|
||||
'knowledge-document': { label: 'Knowledge documents', order: 4 },
|
||||
file: { label: 'Files', order: 5 },
|
||||
'mcp-server': { label: 'MCP servers', order: 6 },
|
||||
'custom-tool': { label: 'Custom tools', order: 7 },
|
||||
skill: { label: 'Skills', order: 8 },
|
||||
}
|
||||
|
||||
interface EdgeOption {
|
||||
value: string
|
||||
label: string
|
||||
otherWorkspaceId: string
|
||||
direction: ForkDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork sync surface. Along the parent edge it force pushes/pulls: the overview
|
||||
* picks a direction and lists each resource kind's mapping status, then Sync.
|
||||
* "Edit mappings" steps through every kind (Back/Next, each source a
|
||||
* settings-style section + full-width target) to set or review targets before
|
||||
* landing back on Sync - which always confirms the overwrite first. The durable record of
|
||||
* every sync is the Activity log in Manage Forks, so this modal just closes on
|
||||
* success.
|
||||
*/
|
||||
export function PromoteWorkspaceModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
parent,
|
||||
}: PromoteWorkspaceModalProps) {
|
||||
// Sync is only ever performed along the parent edge (from a fork toward its
|
||||
// parent). Child edges are intentionally not exposed here - a parent manages its
|
||||
// forks (read-only list) rather than pushing/pulling into them.
|
||||
const edgeOptions = useMemo<EdgeOption[]>(() => {
|
||||
if (!parent) return []
|
||||
return [
|
||||
{
|
||||
value: `push:${parent.id}`,
|
||||
label: `Push to ${parent.name}`,
|
||||
otherWorkspaceId: parent.id,
|
||||
direction: 'push',
|
||||
},
|
||||
{
|
||||
value: `pull:${parent.id}`,
|
||||
label: `Pull from ${parent.name}`,
|
||||
otherWorkspaceId: parent.id,
|
||||
direction: 'pull',
|
||||
},
|
||||
]
|
||||
}, [parent])
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState('')
|
||||
// User's IN-SESSION mapping overrides only - NOT the source of truth. The
|
||||
// displayed/persisted target falls back to each entry's stored `targetId`
|
||||
// (see `targetFor`), so a reopened edge shows its remembered mappings even
|
||||
// though React Query's structural sharing keeps `entries` referentially stable
|
||||
// (a target-seeding effect gated on `entries` would never re-run there).
|
||||
const [targets, setTargets] = useState<Record<string, string>>({})
|
||||
// In-session re-picks for dependent fields whose parent the user swapped, keyed by
|
||||
// `dependentKey`. Folded into the full effective set sent on sync, which promote persists as
|
||||
// the stored mapping - so the selection survives every future sync without re-picking.
|
||||
const [reconfig, setReconfig] = useState<Record<string, string>>({})
|
||||
// Wizard step: 0 is the overview; 1..N edit one resource kind each, entered via
|
||||
// "Edit mappings". Backing out of step 1 returns to the overview.
|
||||
const [step, setStep] = useState(0)
|
||||
const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedKey(edgeOptions[0]?.value ?? '')
|
||||
}
|
||||
}, [open, edgeOptions])
|
||||
|
||||
// Restart at the overview and drop in-session overrides whenever it (re)opens or
|
||||
// the direction changes - the mapping set, and therefore the steps, depend on the
|
||||
// direction.
|
||||
useEffect(() => {
|
||||
setStep(0)
|
||||
setTargets({})
|
||||
setReconfig({})
|
||||
}, [open, selectedKey])
|
||||
|
||||
const selected = edgeOptions.find((option) => option.value === selectedKey)
|
||||
const otherWorkspaceId = selected?.otherWorkspaceId
|
||||
const direction = selected?.direction ?? 'push'
|
||||
|
||||
const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled: open })
|
||||
const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled: open })
|
||||
const updateMapping = useUpdateForkMapping()
|
||||
const promote = usePromoteFork()
|
||||
|
||||
const entries = useMemo(() => mapping.data?.entries ?? [], [mapping.data])
|
||||
const dependentReconfigs = useMemo(
|
||||
() => diff.data?.dependentReconfigs ?? [],
|
||||
[diff.data?.dependentReconfigs]
|
||||
)
|
||||
const resourceUsages = useMemo(() => diff.data?.resourceUsages ?? [], [diff.data?.resourceUsages])
|
||||
|
||||
// Group dependents by their parent (kind:sourceId) once, so each mapping entry below gets a
|
||||
// STABLE `dependents` array reference - a fresh `.filter` per render would defeat
|
||||
// ResourceReconfigure's grouping memo.
|
||||
const dependentsByParent = useMemo(() => {
|
||||
const map = new Map<string, ForkDependentReconfig[]>()
|
||||
for (const dependent of dependentReconfigs) {
|
||||
const key = `${dependent.parentKind}:${dependent.parentSourceId}`
|
||||
const list = map.get(key)
|
||||
if (list) list.push(dependent)
|
||||
else map.set(key, [dependent])
|
||||
}
|
||||
return map
|
||||
}, [dependentReconfigs])
|
||||
|
||||
// Effective target for an entry: the user's in-session override if present,
|
||||
// else the persisted mapping from the server. Read directly from `entries` so
|
||||
// a reopened edge reflects stored mappings without a seeding effect.
|
||||
const targetFor = (entry: ForkMappingEntry) => targets[entryKey(entry)] ?? entry.targetId ?? ''
|
||||
|
||||
const requiredComplete = entries.every((entry) => !entry.required || targetFor(entry) !== '')
|
||||
|
||||
// Every workflow a mapping entry's resource is used in, for the always-on reconfigure
|
||||
// listing rendered beneath that mapping (so the credential/KB stays in context).
|
||||
const usagesForEntry = (entry: ForkMappingEntry): ForkResourceUsage['workflows'] =>
|
||||
resourceUsages.find(
|
||||
(usage) => usage.parentKind === entry.kind && usage.parentSourceId === entry.sourceId
|
||||
)?.workflows ?? EMPTY_USAGES
|
||||
|
||||
// This entry's dependent fields (its credential/KB's selectors), from the memoized grouping.
|
||||
const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] =>
|
||||
dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS
|
||||
|
||||
// Group mappings by resource type - one step per kind, required types first.
|
||||
const groupedEntries = useMemo(() => {
|
||||
const groups = new Map<ForkMappingEntry['kind'], ForkMappingEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const list = groups.get(entry.kind)
|
||||
if (list) list.push(entry)
|
||||
else groups.set(entry.kind, [entry])
|
||||
}
|
||||
return Array.from(groups, ([kind, items]) => ({
|
||||
kind,
|
||||
label: MAPPING_SECTION[kind].label,
|
||||
items: items.slice().sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)),
|
||||
})).sort((a, b) => MAPPING_SECTION[a.kind].order - MAPPING_SECTION[b.kind].order)
|
||||
}, [entries])
|
||||
|
||||
// The mapping entry each dependent hangs off, indexed by `kind:sourceId` (matching `entryKey`)
|
||||
// so the per-field lookups below are O(1) instead of rescanning `entries` for every dependent -
|
||||
// and several times per field across the Sync gate, the value helper, and the payload build.
|
||||
const entriesByParent = useMemo(() => {
|
||||
const map = new Map<string, ForkMappingEntry>()
|
||||
for (const entry of entries) map.set(entryKey(entry), entry)
|
||||
return map
|
||||
}, [entries])
|
||||
|
||||
// The mapping entry a dependent field hangs off (its credential/KB), for change + target lookups.
|
||||
const entryForDependent = (field: ForkDependentReconfig) =>
|
||||
entriesByParent.get(`${field.parentKind}:${field.parentSourceId}`)
|
||||
|
||||
// The value sent + displayed for a dependent (delegates to the shared rule): the user's
|
||||
// re-pick, else the stored value - blank when this field's parent target changed in-session.
|
||||
// Callers that already resolved the parent pass it in to skip a second lookup.
|
||||
const dependentValueFor = (
|
||||
field: ForkDependentReconfig,
|
||||
parent = entryForDependent(field)
|
||||
): string =>
|
||||
effectiveDependentValue(
|
||||
field,
|
||||
reconfig,
|
||||
parent ? shouldReconfigureEntry(parent, targets) : false
|
||||
)
|
||||
|
||||
// Every required dependent whose parent IS mapped must have a value before sync. A dependent
|
||||
// whose parent target is still empty can't be picked yet (its selector is disabled) and is
|
||||
// gated by `requiredComplete` on the parent instead, so it's skipped here.
|
||||
const reconfigComplete = dependentReconfigs.every((field) => {
|
||||
if (!field.required) return true
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent || targetFor(parent) === '') return true
|
||||
return dependentValueFor(field, parent) !== ''
|
||||
})
|
||||
|
||||
// Per-kind status for the overview listing: "Fully mapped" or "n/total mapped",
|
||||
// flagged when a REQUIRED target is still missing (which blocks Sync). Reads the
|
||||
// effective (override-or-persisted) target so it reflects both remembered mappings
|
||||
// and in-session edits.
|
||||
const kindSummaries = groupedEntries.map((group) => {
|
||||
const total = group.items.length
|
||||
const mapped = group.items.filter((entry) => targetFor(entry) !== '').length
|
||||
const requiredPending = group.items.some((entry) => entry.required && targetFor(entry) === '')
|
||||
return { kind: group.kind, label: group.label, total, mapped, requiredPending }
|
||||
})
|
||||
|
||||
// Step 0 is the overview; each subsequent step edits one resource kind, entered via
|
||||
// "Edit mappings". Reconfigure cards render inline under the changed mapping (not as
|
||||
// their own steps) so the credential/KB context stays visible. `safeStep` guards
|
||||
// against a group count that shrank on refetch.
|
||||
const stepCount = 1 + groupedEntries.length
|
||||
const safeStep = Math.min(step, Math.max(0, stepCount - 1))
|
||||
const isLastStep = safeStep >= stepCount - 1
|
||||
const currentGroup = safeStep >= 1 ? (groupedEntries[safeStep - 1] ?? null) : null
|
||||
// Gate Sync on the diff being loaded too, not just the mapping: until `diff.data` arrives
|
||||
// `dependentReconfigs` is empty, so `reconfigComplete` is vacuously true and `runPromote` would
|
||||
// omit `dependentValues` - i.e. Sync before the diff loads would bypass dependent gating.
|
||||
const syncDisabled =
|
||||
submitting ||
|
||||
!otherWorkspaceId ||
|
||||
!requiredComplete ||
|
||||
!reconfigComplete ||
|
||||
mapping.isLoading ||
|
||||
!diff.data
|
||||
const headsUp =
|
||||
(diff.data?.mcpReauthServerIds.length ?? 0) > 0 ||
|
||||
(diff.data?.inlineSecretSources.length ?? 0) > 0
|
||||
|
||||
const runPromote = async () => {
|
||||
if (!otherWorkspaceId) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await updateMapping.mutateAsync({
|
||||
workspaceId,
|
||||
body: {
|
||||
otherWorkspaceId,
|
||||
direction,
|
||||
entries: entries.map((entry) => ({
|
||||
resourceType: entry.resourceType,
|
||||
sourceId: entry.sourceId,
|
||||
targetId: targetFor(entry) || null,
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
// Send the full stored mapping for every dependent whose parent is mapped (its effective
|
||||
// value - re-pick, stored, or blank-after-change). Promote persists this verbatim as the
|
||||
// stored mapping and applies it; fields whose parent isn't mapped yet are omitted (they
|
||||
// can't be configured). This is the whole "what's in the mapping goes in" contract.
|
||||
const dependentValues = dependentReconfigs.flatMap((field) => {
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent || targetFor(parent) === '') return []
|
||||
return [
|
||||
{
|
||||
workflowId: field.targetWorkflowId,
|
||||
blockId: field.targetBlockId,
|
||||
subBlockKey: field.subBlockKey,
|
||||
value: dependentValueFor(field, parent),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const result = await promote.mutateAsync({
|
||||
workspaceId,
|
||||
body: {
|
||||
otherWorkspaceId,
|
||||
direction,
|
||||
// Once the diff has loaded, ALWAYS send the full effective set - including `[]`, which
|
||||
// means "every dependent went away" and must reconcile/clear the live replace targets'
|
||||
// stored rows. Collapsing `[]` into omission would make the backend PRESERVE stale rows.
|
||||
// Only omit before the diff loads (set unknown), so the existing store is left untouched.
|
||||
...(diff.data ? { dependentValues } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.promoteRunId) {
|
||||
if (result.unmappedRequired.length > 0) {
|
||||
toast.error('Map all required credentials and secrets first')
|
||||
return
|
||||
}
|
||||
toast.error('Sync did not complete')
|
||||
return
|
||||
}
|
||||
|
||||
const target = parent?.name ?? 'the workspace'
|
||||
const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`
|
||||
const needsConfig = result.needsConfiguration
|
||||
const clearedOptional = result.clearedOptional
|
||||
// List the affected blocks, naming the workflow for a single one and falling back to
|
||||
// a count across many. Block names ("Gmail 2") are far more actionable than the
|
||||
// generic field titles ("Label") behind them.
|
||||
const formatWhere = (list: Array<{ workflowName: string; blocks: string[] }>) => {
|
||||
const totalBlocks = list.reduce((sum, workflow) => sum + workflow.blocks.length, 0)
|
||||
if (list.length === 1) return `${list[0].blocks.join(', ')} in ${list[0].workflowName}`
|
||||
return `${totalBlocks} block${totalBlocks === 1 ? '' : 's'} across ${list.length} workflows`
|
||||
}
|
||||
const optionalBlocks = clearedOptional.reduce(
|
||||
(sum, workflow) => sum + workflow.blocks.length,
|
||||
0
|
||||
)
|
||||
// Appended to a higher-priority warning so a cleared optional filter is never hidden.
|
||||
const optionalSuffix =
|
||||
optionalBlocks > 0
|
||||
? ` (+${optionalBlocks} block${optionalBlocks === 1 ? '' : 's'} with optional fields cleared)`
|
||||
: ''
|
||||
if (needsConfig.length > 0) {
|
||||
toast.warning(`${label}. Re-check ${formatWhere(needsConfig)}.${optionalSuffix}`)
|
||||
} else if (result.deployFailed > 0) {
|
||||
const n = result.deployFailed
|
||||
toast.warning(
|
||||
`${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.${optionalSuffix}`
|
||||
)
|
||||
} else if (clearedOptional.length > 0) {
|
||||
toast.warning(
|
||||
`${label}. Optional settings cleared — re-check ${formatWhere(clearedOptional)}.`
|
||||
)
|
||||
} else {
|
||||
toast.success(label)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Sync failed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const workflowChanges = useMemo<ForkWorkflowChange[]>(() => {
|
||||
const order: Record<ForkWorkflowChange['action'], number> = { update: 0, create: 1, archive: 2 }
|
||||
return [...(diff.data?.workflows ?? [])].sort(
|
||||
(a, b) => order[a.action] - order[b.action] || a.currentName.localeCompare(b.currentName)
|
||||
)
|
||||
}, [diff.data?.workflows])
|
||||
|
||||
// Right-cluster action sitting immediately left of the primary. The overview pairs
|
||||
// "Edit mappings" with Sync (entering the step walk); every editing step pairs Back
|
||||
// with Next (or with Sync on the last step). Back out of step 1 lands on the
|
||||
// overview, restoring the "Edit mappings · Sync" pair.
|
||||
const syncPrimaryAdjacent: ChipModalFooterSlotAction | undefined =
|
||||
safeStep === 0
|
||||
? groupedEntries.length > 0
|
||||
? { label: 'Edit mappings', onClick: () => setStep(1), disabled: submitting }
|
||||
: undefined
|
||||
: { label: 'Back', onClick: () => setStep(safeStep - 1), disabled: submitting }
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Sync workspace'>
|
||||
<ChipModalHeader onClose={() => onOpenChange(false)}>
|
||||
{currentGroup ? `Sync workspace: ${currentGroup.label}` : 'Sync workspace'}
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
{safeStep === 0 ? (
|
||||
<div className='flex flex-col gap-7 px-2'>
|
||||
<SettingsSection label='Sync'>
|
||||
<ChipDropdown
|
||||
value={selectedKey}
|
||||
onChange={setSelectedKey}
|
||||
options={edgeOptions}
|
||||
placeholder='Select action'
|
||||
align='start'
|
||||
fullWidth
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Always shown once the diff loads so the user sees the section even with nothing
|
||||
deployed - an empty change list means the source has no deployed workflows (every
|
||||
deployed workflow appears here, changed or not), so the muted state nudges a deploy. */}
|
||||
{diff.data ? (
|
||||
<SettingsSection label='Deployed Workflows'>
|
||||
{workflowChanges.length > 0 ? (
|
||||
<div className='flex max-h-40 flex-col gap-1 overflow-y-auto'>
|
||||
{workflowChanges.map((change, index) => {
|
||||
const renamed = change.currentName !== change.otherName
|
||||
return (
|
||||
<div
|
||||
key={`${change.action}:${change.currentName}:${index}`}
|
||||
className='flex min-w-0 items-center gap-1.5'
|
||||
>
|
||||
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
|
||||
{change.currentName}
|
||||
</span>
|
||||
{renamed ? (
|
||||
<>
|
||||
<ArrowRight className='size-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm'>
|
||||
{change.otherName}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-[var(--text-muted)] text-small'>
|
||||
{direction === 'push'
|
||||
? `No deployed workflows. Deploy workflows to push changes to ${parent?.name ?? 'the parent'}.`
|
||||
: `No deployed workflows in ${parent?.name ?? 'the parent'} to pull.`}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{headsUp ? (
|
||||
<SettingsSection label='Heads up'>
|
||||
{(diff.data?.mcpReauthServerIds.length ?? 0) > 0 ? (
|
||||
<div className='text-[var(--text-muted)] text-small'>
|
||||
{diff.data?.mcpReauthServerIds.length} MCP server(s) use OAuth and must be
|
||||
re-authorized in the target workspace.
|
||||
</div>
|
||||
) : null}
|
||||
{(diff.data?.inlineSecretSources.length ?? 0) > 0 ? (
|
||||
<div className='mt-1 text-[var(--text-muted)] text-small'>
|
||||
{diff.data?.inlineSecretSources.length} inline secret(s) can't be auto-mapped
|
||||
— set them in the target workspace.
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{kindSummaries.length > 0 ? (
|
||||
<SettingsSection label='Mappings'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{kindSummaries.map(({ kind, label, total, mapped, requiredPending }) => {
|
||||
const complete = mapped === total
|
||||
return (
|
||||
<div key={kind} className='flex items-center justify-between gap-2'>
|
||||
<span className='text-[var(--text-body)] text-small'>{label}</span>
|
||||
<Badge
|
||||
variant={
|
||||
complete ? 'green' : requiredPending ? 'amber' : 'gray-secondary'
|
||||
}
|
||||
size='sm'
|
||||
dot
|
||||
>
|
||||
{complete ? 'Fully mapped' : `${mapped}/${total} mapped`}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</div>
|
||||
) : currentGroup ? (
|
||||
<div className='flex flex-col gap-7 px-2'>
|
||||
{currentGroup.items.map((entry) => {
|
||||
// On push, a parent target can back only one source; disable any target
|
||||
// another source already took (named in the hint) so the user can't create a
|
||||
// mapping that would be silently dropped on save. Pull allows sharing a target.
|
||||
const takenOwners =
|
||||
direction === 'push'
|
||||
? takenTargetOwners(currentGroup.items, targets, entry)
|
||||
: EMPTY_TARGET_OWNERS
|
||||
return (
|
||||
<SettingsSection
|
||||
key={entryKey(entry)}
|
||||
label={entry.sourceLabel}
|
||||
headerAccessory={
|
||||
entry.required ? (
|
||||
<span className='text-[var(--text-error)]' title='Required to sync'>
|
||||
*
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ChipCombobox
|
||||
className='w-full'
|
||||
options={entry.candidates.map((candidate) => {
|
||||
const owner = takenOwners.get(candidate.id)
|
||||
return {
|
||||
label: owner
|
||||
? `${candidate.label} · mapped to ${owner}`
|
||||
: candidate.label,
|
||||
value: candidate.id,
|
||||
disabled: owner !== undefined,
|
||||
}
|
||||
})}
|
||||
value={targetFor(entry) || undefined}
|
||||
onChange={(value) => {
|
||||
setTargets((prev) => ({ ...prev, [entryKey(entry)]: value }))
|
||||
// Changing the parent invalidates any in-session re-picks of its
|
||||
// dependents - they were chosen against the old account and won't resolve
|
||||
// against the new one, so drop them; otherwise a stale re-pick (which
|
||||
// wins over the parent-changed check) would be sent to the new account.
|
||||
setReconfig((prev) => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
for (const dependent of dependentsForEntry(entry)) {
|
||||
const key = dependentKey(dependent)
|
||||
if (key in next) {
|
||||
delete next[key]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}}
|
||||
placeholder='Select target'
|
||||
/>
|
||||
{entry.candidatesTruncated ? (
|
||||
<div className='mt-1 text-[var(--text-muted)] text-small'>
|
||||
This workspace has more options than shown here. If you don't see the right
|
||||
one, narrow it down by name.
|
||||
</div>
|
||||
) : null}
|
||||
{/* Always-on: every workflow this resource is used in, each expandable to
|
||||
its blocks + dependent selectors (greyed when nothing to configure). */}
|
||||
<ResourceReconfigure
|
||||
workflows={usagesForEntry(entry)}
|
||||
dependents={dependentsForEntry(entry)}
|
||||
parentTargetValue={targetFor(entry)}
|
||||
parentChanged={shouldReconfigureEntry(entry, targets)}
|
||||
workspaceId={diff.data?.targetWorkspaceId ?? ''}
|
||||
reconfig={reconfig}
|
||||
setReconfig={setReconfig}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
hideCancel
|
||||
primaryAdjacentAction={syncPrimaryAdjacent}
|
||||
primaryAction={
|
||||
safeStep >= 1 && !isLastStep
|
||||
? { label: 'Next', onClick: () => setStep(safeStep + 1), disabled: submitting }
|
||||
: {
|
||||
label: submitting ? 'Working...' : 'Sync',
|
||||
onClick: () => setConfirmSyncOpen(true),
|
||||
disabled: syncDisabled,
|
||||
disabledTooltip: !requiredComplete
|
||||
? 'Map all required secrets first'
|
||||
: !reconfigComplete
|
||||
? 'Reconfigure all required fields first'
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ChipModal>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={confirmSyncOpen}
|
||||
onOpenChange={setConfirmSyncOpen}
|
||||
srTitle='Sync workspace'
|
||||
title='Overwrite target workspace'
|
||||
text={[
|
||||
'The target may have been modified since the last sync. Syncing will ',
|
||||
{ text: 'overwrite any changes', bold: true },
|
||||
' there. Continue?',
|
||||
]}
|
||||
confirm={{
|
||||
label: 'Sync',
|
||||
onClick: () => {
|
||||
setConfirmSyncOpen(false)
|
||||
void runPromote()
|
||||
},
|
||||
pending: submitting,
|
||||
pendingLabel: 'Syncing...',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { useWorkspaceOwnerBilling } from '@/hooks/queries/workspace'
|
||||
|
||||
const isBillingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const isForkingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_FORKING_ENABLED'))
|
||||
|
||||
/**
|
||||
* Client mirror of the server fork EE gate (`assertForkingEnabled`): on Sim Cloud
|
||||
* the active workspace's billed account (its owner's rolled-up plan) must be
|
||||
* Enterprise; on self-hosted it's the `NEXT_PUBLIC_FORKING_ENABLED` override. Used
|
||||
* to hide the fork UI (and skip the lineage query) for workspaces that cannot fork.
|
||||
*
|
||||
* Gating on the WORKSPACE's plan (not the viewer's) is what matches the server,
|
||||
* which checks the workspace org's plan: a viewer who belongs to a different
|
||||
* Enterprise org no longer sees fork UI on a non-Enterprise workspace, and a
|
||||
* member of an Enterprise workspace isn't denied it just because their own
|
||||
* highest plan is lower. The server gate remains the security boundary.
|
||||
*
|
||||
* Self-hosted relies on `NEXT_PUBLIC_FORKING_ENABLED` / `NEXT_PUBLIC_BILLING_ENABLED`
|
||||
* mirroring the server's `FORKING_ENABLED` / `BILLING_ENABLED`; set each pair
|
||||
* together or the UI and API will disagree.
|
||||
*/
|
||||
export function useForkingAvailable(workspaceId?: string): boolean {
|
||||
const { data } = useWorkspaceOwnerBilling(isBillingEnabledClient ? workspaceId : undefined)
|
||||
if (!isBillingEnabledClient) return isForkingEnabledClient
|
||||
return getSubscriptionAccessState(data).hasUsableEnterpriseAccess
|
||||
}
|
||||
+49
@@ -18,12 +18,17 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { ManageWorkspace, PanelLeft } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
|
||||
import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
|
||||
import { ForkWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
|
||||
import { PromoteWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal'
|
||||
import { useForkingAvailable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available'
|
||||
import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
|
||||
import { useForkLineage } from '@/hooks/queries/workspace-fork'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
|
||||
@@ -96,6 +101,8 @@ function WorkspaceHeaderImpl({
|
||||
}: WorkspaceHeaderProps) {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
|
||||
const [isForkModalOpen, setIsForkModalOpen] = useState(false)
|
||||
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null)
|
||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false)
|
||||
@@ -120,6 +127,14 @@ function WorkspaceHeaderImpl({
|
||||
}, [])
|
||||
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const forkingAvailable = useForkingAvailable(workspaceId)
|
||||
const { canAdmin } = useUserPermissionsContext()
|
||||
// Forking and sync rewrite workflow state and deployments en masse, so they are
|
||||
// workspace-admin only (org owners/admins derive workspace admin server-side via
|
||||
// the resolved viewer permission). Every fork route re-checks this; gating the
|
||||
// entry points here just keeps the UI honest. The server remains the boundary.
|
||||
const canUseForking = forkingAvailable && canAdmin
|
||||
const { data: forkLineage } = useForkLineage(workspaceId, canUseForking)
|
||||
|
||||
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
|
||||
const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null
|
||||
@@ -238,6 +253,16 @@ function WorkspaceHeaderImpl({
|
||||
onUploadLogo(capturedWorkspaceRef.current.id)
|
||||
}
|
||||
|
||||
// Always open Manage Forks - rollback and the durable Activity log live here and must
|
||||
// stay reachable at a workspace cap. Only creating a NEW fork is gated (in the modal).
|
||||
const handleForkAction = () => {
|
||||
setIsForkModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSyncAction = () => {
|
||||
setIsPromoteModalOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle leave workspace after confirmation
|
||||
*/
|
||||
@@ -635,6 +660,9 @@ function WorkspaceHeaderImpl({
|
||||
const contextCanAdmin = capturedPermissions === 'admin'
|
||||
const capturedWorkspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
|
||||
const isOwner = capturedWorkspace && sessionUserId === capturedWorkspace.ownerId
|
||||
// Only the active row can offer fork actions: its lineage/availability is the
|
||||
// data loaded for `workspaceId`. Sync needs a parent; Manage needs children.
|
||||
const showForkInContext = capturedWorkspaceRef.current?.id === workspaceId && canUseForking
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
@@ -646,9 +674,13 @@ function WorkspaceHeaderImpl({
|
||||
onDelete={handleDeleteAction}
|
||||
onLeave={handleLeaveAction}
|
||||
onUploadLogo={handleUploadLogoAction}
|
||||
onFork={handleForkAction}
|
||||
onSync={handleSyncAction}
|
||||
showRename={true}
|
||||
showUploadLogo={!!onUploadLogo}
|
||||
showLeave={!isOwner && !!onLeaveWorkspace}
|
||||
showFork={showForkInContext}
|
||||
showSync={showForkInContext && Boolean(forkLineage?.parent)}
|
||||
disableRename={!contextCanAdmin}
|
||||
disableDelete={!contextCanAdmin || workspaces.length <= 1}
|
||||
disableUploadLogo={!contextCanAdmin}
|
||||
@@ -673,6 +705,23 @@ function WorkspaceHeaderImpl({
|
||||
inviteDisabledReason={inviteDisabledReason}
|
||||
organizationId={activeWorkspaceFull?.organizationId ?? null}
|
||||
/>
|
||||
<ForkWorkspaceModal
|
||||
open={isForkModalOpen}
|
||||
onOpenChange={setIsForkModalOpen}
|
||||
sourceWorkspaceId={workspaceId}
|
||||
sourceWorkspaceName={activeWorkspace?.name || 'Workspace'}
|
||||
undoableRun={forkLineage?.undoableRun ?? null}
|
||||
canFork={canCreateWorkspace}
|
||||
onUpgrade={() => {
|
||||
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
|
||||
}}
|
||||
/>
|
||||
<PromoteWorkspaceModal
|
||||
open={isPromoteModalOpen}
|
||||
onOpenChange={setIsPromoteModalOpen}
|
||||
workspaceId={workspaceId}
|
||||
parent={forkLineage?.parent ?? null}
|
||||
/>
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { task } from '@trigger.dev/sdk'
|
||||
import {
|
||||
type ForkContentCopyPayload,
|
||||
runForkContentCopy,
|
||||
} from '@/lib/workspaces/fork/copy/content-copy-runner'
|
||||
|
||||
/**
|
||||
* Trigger.dev wrapper for the post-fork heavy-content copy (table rows, KB
|
||||
* documents + embeddings, file blobs). Backgrounding keeps the fork request fast
|
||||
* and lets the copy survive app deploys. `maxAttempts: 1` — the copy is
|
||||
* non-transactional best-effort (per-row inserts with fresh ids), so a blind
|
||||
* re-run would duplicate rows; a partial failure simply leaves the fork's content
|
||||
* incomplete (the workflows themselves committed synchronously).
|
||||
*/
|
||||
export const forkContentCopyTask = task({
|
||||
id: 'fork-content-copy',
|
||||
retry: { maxAttempts: 1 },
|
||||
queue: {
|
||||
name: 'fork-content-copy',
|
||||
concurrencyLimit: 10,
|
||||
},
|
||||
run: async (payload: ForkContentCopyPayload) => {
|
||||
await runForkContentCopy(payload)
|
||||
},
|
||||
})
|
||||
@@ -927,10 +927,12 @@ export type ChipModalFooterSlotAction = ChipModalFooterAction | ChipModalFooterC
|
||||
|
||||
export interface ChipModalFooterProps {
|
||||
/**
|
||||
* Dismiss handler for the always-present Cancel button. Like the header's
|
||||
* close (X), Cancel is structural — it always reads "Cancel" and there is no
|
||||
* prop to relabel or remove it. Its enabled state can be controlled via
|
||||
* {@link ChipModalFooterProps.cancelDisabled}.
|
||||
* Dismiss handler for the Cancel button. For standard form footers Cancel is
|
||||
* structural — it always reads "Cancel" and cannot be relabeled. Its enabled
|
||||
* state is controlled via {@link ChipModalFooterProps.cancelDisabled}. A
|
||||
* multi-step/wizard footer whose own Back navigation plus the header close (X)
|
||||
* already cover dismissal may suppress it via
|
||||
* {@link ChipModalFooterProps.hideCancel}.
|
||||
*/
|
||||
onCancel: () => void
|
||||
/**
|
||||
@@ -940,13 +942,33 @@ export interface ChipModalFooterProps {
|
||||
* @default false
|
||||
*/
|
||||
cancelDisabled?: boolean
|
||||
/**
|
||||
* Suppresses the Cancel button entirely. Reserve for multi-step/wizard footers
|
||||
* where the in-footer Back navigation plus the header close (X) already provide
|
||||
* dismissal, so a third dismiss affordance is redundant. Standard one-shot form
|
||||
* footers keep Cancel — do not hide it merely to declutter.
|
||||
* @default false
|
||||
*/
|
||||
hideCancel?: boolean
|
||||
/** Primary action, anchored bottom-right (e.g. Save, Create, Delete). */
|
||||
primaryAction: ChipModalFooterAction
|
||||
/**
|
||||
* An action rendered immediately to the LEFT of the {@link primaryAction},
|
||||
* inside the right-anchored cluster (after the structural Cancel). Use for the
|
||||
* trailing half of a paired control that reads as ONE unit with the primary —
|
||||
* canonically a wizard's `Back` sitting beside `Next`, or a "skip ahead"
|
||||
* shortcut beside the primary — where docking it to the far-left
|
||||
* {@link secondaryActions} slot would visually divorce it from the primary it
|
||||
* pairs with. Rendered as a bare {@link Chip} (same chrome as Cancel) so the
|
||||
* filled primary stays the sole emphasized control; accepts a
|
||||
* {@link ChipModalFooterCustomAction} for chip-chrome controls.
|
||||
*/
|
||||
primaryAdjacentAction?: ChipModalFooterSlotAction
|
||||
/**
|
||||
* Auxiliary actions docked to the far-left, opposite the Cancel/primary
|
||||
* cluster, rendered in order on the cluster's `gap-2` rhythm — e.g. Delete
|
||||
* in an edit flow, Back in a wizard, or chip-chrome controls (a date + time
|
||||
* picker pair in a scheduling footer) via
|
||||
* in an edit flow, a wizard's "skip ahead" shortcut, or chip-chrome controls
|
||||
* (a date + time picker pair in a scheduling footer) via
|
||||
* {@link ChipModalFooterCustomAction}. Like a `Resource` header's actions,
|
||||
* each entry is a constrained {@link ChipModalFooterSlotAction} — consumers
|
||||
* describe intent, never chrome.
|
||||
@@ -1013,7 +1035,9 @@ function renderFooterSlotAction(action: ChipModalFooterSlotAction): React.ReactN
|
||||
function ChipModalFooter({
|
||||
onCancel,
|
||||
cancelDisabled,
|
||||
hideCancel = false,
|
||||
primaryAction,
|
||||
primaryAdjacentAction,
|
||||
secondaryActions,
|
||||
}: ChipModalFooterProps) {
|
||||
const showsDisabledTooltip = Boolean(primaryAction.disabled && primaryAction.disabledTooltip)
|
||||
@@ -1041,9 +1065,12 @@ function ChipModalFooter({
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Chip flush onClick={onCancel} disabled={cancelDisabled}>
|
||||
Cancel
|
||||
</Chip>
|
||||
{hideCancel ? null : (
|
||||
<Chip flush onClick={onCancel} disabled={cancelDisabled}>
|
||||
Cancel
|
||||
</Chip>
|
||||
)}
|
||||
{primaryAdjacentAction ? renderFooterSlotAction(primaryAdjacentAction) : null}
|
||||
{showsDisabledTooltip ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type BackgroundWorkItem,
|
||||
getWorkspaceBackgroundWorkContract,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
export const backgroundWorkKeys = {
|
||||
all: ['backgroundWork'] as const,
|
||||
lists: () => [...backgroundWorkKeys.all, 'list'] as const,
|
||||
list: (workspaceId?: string) => [...backgroundWorkKeys.lists(), workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
async function fetchWorkspaceBackgroundWork(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<BackgroundWorkItem[]> {
|
||||
const data = await requestJson(getWorkspaceBackgroundWorkContract, {
|
||||
params: { id: workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.items
|
||||
}
|
||||
|
||||
const isActive = (item: BackgroundWorkItem) =>
|
||||
item.status === 'pending' || item.status === 'processing'
|
||||
|
||||
/**
|
||||
* Durable "background work in progress" status for a workspace (fork content copy +
|
||||
* any deployment side-effects). Poll-first per the best-practice for long jobs: the
|
||||
* status survives a reload (it's a DB row), and we only keep polling while something is
|
||||
* still running, then stop. Refetch on focus catches changes after the tab was away.
|
||||
*/
|
||||
export function useWorkspaceBackgroundWork(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: backgroundWorkKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchWorkspaceBackgroundWork(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 5_000,
|
||||
refetchInterval: (query) => ((query.state.data ?? []).some(isActive) ? 5_000 : false),
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
type ForkWorkspaceBody,
|
||||
forkWorkspaceContract,
|
||||
getForkDiffContract,
|
||||
getForkLineageContract,
|
||||
getForkMappingContract,
|
||||
getForkResourcesContract,
|
||||
type PromoteForkBody,
|
||||
promoteForkContract,
|
||||
type RollbackForkBody,
|
||||
rollbackForkContract,
|
||||
type UpdateForkMappingBody,
|
||||
updateForkMappingContract,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import type { WorkspacesResponse } from '@/lib/api/contracts/workspaces'
|
||||
import { backgroundWorkKeys } from '@/hooks/queries/background-work'
|
||||
import { deploymentKeys } from '@/hooks/queries/deployments'
|
||||
import { workspaceKeys } from '@/hooks/queries/workspace'
|
||||
|
||||
export type ForkDirection = 'push' | 'pull'
|
||||
|
||||
export const forkKeys = {
|
||||
all: ['workspace-fork'] as const,
|
||||
lineages: () => [...forkKeys.all, 'lineage'] as const,
|
||||
lineage: (workspaceId?: string) => [...forkKeys.lineages(), workspaceId ?? ''] as const,
|
||||
mappings: () => [...forkKeys.all, 'mapping'] as const,
|
||||
mapping: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) =>
|
||||
[...forkKeys.mappings(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const,
|
||||
diffs: () => [...forkKeys.all, 'diff'] as const,
|
||||
diff: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) =>
|
||||
[...forkKeys.diffs(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const,
|
||||
resourcesAll: () => [...forkKeys.all, 'resources'] as const,
|
||||
resources: (workspaceId?: string) => [...forkKeys.resourcesAll(), workspaceId ?? ''] as const,
|
||||
}
|
||||
|
||||
export function useForkResources(workspaceId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: forkKeys.resources(workspaceId),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkResourcesContract, { params: { id: workspaceId as string }, signal }),
|
||||
enabled: Boolean(workspaceId) && enabled,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useForkLineage(workspaceId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: forkKeys.lineage(workspaceId),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkLineageContract, { params: { id: workspaceId as string }, signal }),
|
||||
enabled: Boolean(workspaceId) && enabled,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useForkWorkspace() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: ForkWorkspaceBody }) =>
|
||||
requestJson(forkWorkspaceContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSuccess: (data) => {
|
||||
// Merge the new fork into the active list cache before invalidation so the
|
||||
// immediate navigation into it can't race a stale list and trip the
|
||||
// not-in-workspaces redirect (mirrors useCreateWorkspace).
|
||||
const newWorkspace = data.workspace
|
||||
queryClient.setQueryData<WorkspacesResponse>(workspaceKeys.list('active'), (previous) => {
|
||||
if (!previous) {
|
||||
return { workspaces: [newWorkspace], lastActiveWorkspaceId: null, creationPolicy: null }
|
||||
}
|
||||
if (previous.workspaces.some((w) => w.id === newWorkspace.id)) {
|
||||
return previous
|
||||
}
|
||||
return { ...previous, workspaces: [newWorkspace, ...previous.workspaces] }
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() })
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
|
||||
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useForkMapping(args: {
|
||||
workspaceId?: string
|
||||
otherWorkspaceId?: string
|
||||
direction: ForkDirection
|
||||
enabled?: boolean
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: forkKeys.mapping(args.workspaceId, args.otherWorkspaceId, args.direction),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkMappingContract, {
|
||||
params: { id: args.workspaceId as string },
|
||||
query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true),
|
||||
staleTime: 15 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateForkMapping() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: UpdateForkMappingBody }) =>
|
||||
requestJson(updateForkMappingContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useForkDiff(args: {
|
||||
workspaceId?: string
|
||||
otherWorkspaceId?: string
|
||||
direction: ForkDirection
|
||||
enabled?: boolean
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: forkKeys.diff(args.workspaceId, args.otherWorkspaceId, args.direction),
|
||||
queryFn: ({ signal }) =>
|
||||
requestJson(getForkDiffContract, {
|
||||
params: { id: args.workspaceId as string },
|
||||
query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction },
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true),
|
||||
staleTime: 10 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePromoteFork() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: PromoteForkBody }) =>
|
||||
requestJson(promoteForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSettled: () => {
|
||||
// A sync changes lineage (undoable run), mappings, and the diff - not the
|
||||
// workspace's copyable resource inventory, so leave `resources` cached.
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
|
||||
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
|
||||
// A sync rewrites the target workflows' drafts AND redeploys them. The promote
|
||||
// result doesn't expose the affected ids, so invalidate all deployment caches:
|
||||
// otherwise a target workflow whose deployed state was already cached compares its
|
||||
// fresh draft against the stale (pre-sync) deployed snapshot and falsely shows
|
||||
// "Update" instead of "Live".
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRollbackFork() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { workspaceId: string; body: RollbackForkBody }) =>
|
||||
requestJson(rollbackForkContract, { params: { id: vars.workspaceId }, body: vars.body }),
|
||||
onSettled: () => {
|
||||
// Rollback changes lineage, mappings, and the diff - not the copyable resource
|
||||
// inventory, so leave `resources` cached (mirrors usePromoteFork).
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.lineages() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.mappings() })
|
||||
queryClient.invalidateQueries({ queryKey: forkKeys.diffs() })
|
||||
queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() })
|
||||
// Rollback restores the target workflows' drafts + reactivates a prior deployment,
|
||||
// so the cached deployed snapshots are stale - refresh them so change detection
|
||||
// doesn't falsely show "Update" (mirrors usePromoteFork).
|
||||
queryClient.invalidateQueries({ queryKey: deploymentKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
forkMappableResourceTypeSchema,
|
||||
updateForkMappingBodySchema,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
describe('forkMappableResourceTypeSchema', () => {
|
||||
it('rejects the system-managed workflow type', () => {
|
||||
expect(forkMappableResourceTypeSchema.safeParse('workflow').success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts user-mappable resource types', () => {
|
||||
for (const type of [
|
||||
'oauth_credential',
|
||||
'service_account_credential',
|
||||
'env_var',
|
||||
'table',
|
||||
'knowledge_base',
|
||||
'knowledge_document',
|
||||
'file',
|
||||
'mcp_server',
|
||||
'custom_tool',
|
||||
'skill',
|
||||
]) {
|
||||
expect(forkMappableResourceTypeSchema.safeParse(type).success).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateForkMappingBodySchema', () => {
|
||||
const base = { otherWorkspaceId: 'ws-1', direction: 'push' as const }
|
||||
|
||||
it('rejects a body that maps a workflow-type entry', () => {
|
||||
const result = updateForkMappingBodySchema.safeParse({
|
||||
...base,
|
||||
entries: [{ resourceType: 'workflow', sourceId: 'wf-src', targetId: 'wf-tgt' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts mappable entries, including a cleared (null target) mapping', () => {
|
||||
const result = updateForkMappingBodySchema.safeParse({
|
||||
...base,
|
||||
entries: [
|
||||
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'API_KEY' },
|
||||
{ resourceType: 'oauth_credential', sourceId: 'cred-1', targetId: null },
|
||||
],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an entry with an empty sourceId', () => {
|
||||
const result = updateForkMappingBodySchema.safeParse({
|
||||
...base,
|
||||
entries: [{ resourceType: 'env_var', sourceId: '', targetId: 'API_KEY' }],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,477 @@
|
||||
import { z } from 'zod'
|
||||
import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import { workspaceSchema } from '@/lib/api/contracts/workspaces'
|
||||
|
||||
const workspaceIdParamsSchema = z.object({ id: nonEmptyIdSchema })
|
||||
|
||||
export const forkRemapKindSchema = z.enum([
|
||||
'credential',
|
||||
'env-var',
|
||||
'knowledge-base',
|
||||
'knowledge-document',
|
||||
'table',
|
||||
'file',
|
||||
'mcp-server',
|
||||
'custom-tool',
|
||||
'skill',
|
||||
])
|
||||
|
||||
export const forkResourceTypeSchema = z.enum([
|
||||
'workflow',
|
||||
'oauth_credential',
|
||||
'service_account_credential',
|
||||
'env_var',
|
||||
'table',
|
||||
'knowledge_base',
|
||||
'knowledge_document',
|
||||
'file',
|
||||
'mcp_server',
|
||||
'custom_tool',
|
||||
'skill',
|
||||
])
|
||||
|
||||
/**
|
||||
* Resource types a user may map via the mapping editor. Excludes `workflow`:
|
||||
* workflow identity is system-managed (seeded at fork, maintained by promote,
|
||||
* dissolved by rollback) and must never be written through the mapping editor, or
|
||||
* a crafted entry could repoint a promote at the wrong target workflow.
|
||||
*/
|
||||
export const forkMappableResourceTypeSchema = forkResourceTypeSchema.exclude(['workflow'])
|
||||
export type ForkMappableResourceType = z.infer<typeof forkMappableResourceTypeSchema>
|
||||
|
||||
export const forkDirectionSchema = z.enum(['push', 'pull'])
|
||||
|
||||
export const forkLineageNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
organizationId: z.string().nullable(),
|
||||
})
|
||||
|
||||
export const getForkLineageContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/fork/lineage',
|
||||
params: workspaceIdParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
workspaceId: z.string(),
|
||||
parent: forkLineageNodeSchema.nullable(),
|
||||
/** The most recent undoable promote into this workspace, for the rollback UI. */
|
||||
undoableRun: z
|
||||
.object({
|
||||
otherWorkspaceId: z.string(),
|
||||
otherName: z.string(),
|
||||
direction: forkDirectionSchema,
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type ForkLineageNodeApi = z.output<typeof forkLineageNodeSchema>
|
||||
export type GetForkLineageResponse = z.output<typeof getForkLineageContract.response.schema>
|
||||
|
||||
const forkResourceIdList = z.array(nonEmptyIdSchema).max(2000).optional()
|
||||
|
||||
export const forkResourceSelectionSchema = z.object({
|
||||
files: forkResourceIdList,
|
||||
tables: forkResourceIdList,
|
||||
knowledgeBases: forkResourceIdList,
|
||||
customTools: forkResourceIdList,
|
||||
skills: forkResourceIdList,
|
||||
mcpServers: forkResourceIdList,
|
||||
})
|
||||
|
||||
export const forkWorkspaceBodySchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name is too long').optional(),
|
||||
copy: forkResourceSelectionSchema.optional(),
|
||||
})
|
||||
export const forkWorkspaceContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/fork',
|
||||
params: workspaceIdParamsSchema,
|
||||
body: forkWorkspaceBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
// Full workspace row so the client can merge it into the workspace-list cache
|
||||
// (parity with create), not just the lineage node.
|
||||
workspace: workspaceSchema,
|
||||
workflowsCopied: z.number().int(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type ForkWorkspaceBody = z.input<typeof forkWorkspaceBodySchema>
|
||||
export type ForkWorkspaceResponse = z.output<typeof forkWorkspaceContract.response.schema>
|
||||
|
||||
export const forkCopyableResourceSchema = z.object({ id: z.string(), label: z.string() })
|
||||
export type ForkCopyableResource = z.output<typeof forkCopyableResourceSchema>
|
||||
export const getForkResourcesContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/fork/resources',
|
||||
params: workspaceIdParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
files: z.array(forkCopyableResourceSchema),
|
||||
tables: z.array(forkCopyableResourceSchema),
|
||||
knowledgeBases: z.array(forkCopyableResourceSchema),
|
||||
customTools: z.array(forkCopyableResourceSchema),
|
||||
skills: z.array(forkCopyableResourceSchema),
|
||||
mcpServers: z.array(forkCopyableResourceSchema),
|
||||
deployedWorkflowCount: z.number().int(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type GetForkResourcesResponse = z.output<typeof getForkResourcesContract.response.schema>
|
||||
|
||||
export const forkMappingCandidateSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
providerId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const forkMappingEntrySchema = z.object({
|
||||
kind: forkRemapKindSchema,
|
||||
resourceType: forkMappableResourceTypeSchema,
|
||||
sourceId: z.string(),
|
||||
sourceLabel: z.string(),
|
||||
targetId: z.string().nullable(),
|
||||
/** True when `targetId` is an unconfirmed auto-suggestion (no persisted mapping yet). */
|
||||
suggested: z.boolean(),
|
||||
required: z.boolean(),
|
||||
candidates: z.array(forkMappingCandidateSchema),
|
||||
/**
|
||||
* True when the target workspace has more candidates of this kind than the picker
|
||||
* loads, so the list shown is partial. The UI surfaces a "refine to find more"
|
||||
* notice; the chosen target is validated by exact id on save (never capped).
|
||||
*/
|
||||
candidatesTruncated: z.boolean(),
|
||||
})
|
||||
export type ForkMappingEntry = z.output<typeof forkMappingEntrySchema>
|
||||
|
||||
export const getForkMappingQuerySchema = z.object({
|
||||
otherWorkspaceId: workspaceIdSchema,
|
||||
direction: forkDirectionSchema.default('push'),
|
||||
})
|
||||
export const getForkMappingContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/fork/mapping',
|
||||
params: workspaceIdParamsSchema,
|
||||
query: getForkMappingQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
childWorkspaceId: z.string(),
|
||||
parentWorkspaceId: z.string(),
|
||||
sourceWorkspaceId: z.string(),
|
||||
targetWorkspaceId: z.string(),
|
||||
entries: z.array(forkMappingEntrySchema),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type GetForkMappingResponse = z.output<typeof getForkMappingContract.response.schema>
|
||||
|
||||
export const updateForkMappingBodySchema = z.object({
|
||||
otherWorkspaceId: workspaceIdSchema,
|
||||
direction: forkDirectionSchema,
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
resourceType: forkMappableResourceTypeSchema,
|
||||
sourceId: z.string().min(1),
|
||||
targetId: z.string().min(1).nullable(),
|
||||
})
|
||||
)
|
||||
.max(5000),
|
||||
})
|
||||
export const updateForkMappingContract = defineRouteContract({
|
||||
method: 'PUT',
|
||||
path: '/api/workspaces/[id]/fork/mapping',
|
||||
params: workspaceIdParamsSchema,
|
||||
body: updateForkMappingBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({ success: z.literal(true), updated: z.number().int() }),
|
||||
},
|
||||
})
|
||||
export type UpdateForkMappingBody = z.input<typeof updateForkMappingBodySchema>
|
||||
|
||||
export const forkUnmappedReferenceSchema = z.object({
|
||||
kind: forkRemapKindSchema,
|
||||
sourceId: z.string(),
|
||||
required: z.boolean(),
|
||||
blockName: z.string().optional(),
|
||||
})
|
||||
|
||||
export const forkWorkflowChangeSchema = z.object({
|
||||
action: z.enum(['update', 'create', 'archive']),
|
||||
/** Workflow name in the workspace the modal is open in. */
|
||||
currentName: z.string(),
|
||||
/** Workflow name in the sync-partner workspace (differs from `currentName` after a rename). */
|
||||
otherName: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* A configured selector field (Gmail label, Slack channel, KB document, ...) that
|
||||
* `dependsOn` a remappable parent resource - a credential or a knowledge base - so a
|
||||
* sync clears it whenever that parent's target changes. The modal renders a controlled
|
||||
* selector against the newly-chosen parent (using `selectorKey` + `context` + the new
|
||||
* parent value under `parentContextKey`) so the user re-picks the value in place,
|
||||
* pre-sync, instead of having it cleared and reconfigured after. `targetBlockId` is the
|
||||
* deterministic fork block id, matching the engine, so the re-pick lands on the right
|
||||
* block. Only fields the source actually had set are emitted (the active operation's),
|
||||
* so blocks aren't padded with every operation variant.
|
||||
*/
|
||||
export const forkDependentReconfigSchema = z.object({
|
||||
/** The remappable parent resource kind whose target swap clears this field. */
|
||||
parentKind: z.enum(['credential', 'knowledge-base', 'table']),
|
||||
/** Source id of that parent (matches a mapping entry's `sourceId`). */
|
||||
parentSourceId: z.string(),
|
||||
/** SelectorContext key the new parent value is supplied under (`oauthCredential` | `knowledgeBaseId` | `tableId`). */
|
||||
parentContextKey: z.string(),
|
||||
targetWorkflowId: z.string(),
|
||||
targetBlockId: z.string(),
|
||||
blockName: z.string(),
|
||||
subBlockKey: z.string(),
|
||||
selectorKey: z.string(),
|
||||
title: z.string(),
|
||||
/**
|
||||
* The field's stored value (from the persisted mapping), so the always-on reconfigure listing
|
||||
* pre-fills the selector with what the user last set. Empty string when unset; for an edge
|
||||
* that predates the store the TARGET's currently-configured value is the fallback (never the
|
||||
* source's, which would overwrite the target's own selection on the first sync). After a
|
||||
* parent target CHANGE the modal ignores this and starts blank, since the old value no longer
|
||||
* resolves against the new parent.
|
||||
*/
|
||||
currentValue: z.string(),
|
||||
/** Whether the field is required - a required empty field blocks Sync. */
|
||||
required: z.boolean(),
|
||||
/**
|
||||
* SelectorContext key this field's own value supplies to its in-block descendants
|
||||
* (e.g. a spreadsheet feeds `spreadsheetId` to the sheet selector). Lets the modal
|
||||
* chain re-picks: a re-picked parent updates its children's selector context.
|
||||
*/
|
||||
providesContextKey: z.string().optional(),
|
||||
/**
|
||||
* SelectorContext keys this field needs from in-block siblings (excluding the parent
|
||||
* the modal already supplies). The modal keeps the field disabled until each sibling
|
||||
* that provides one of these keys has been re-picked, so a child never queries a stale
|
||||
* upstream value.
|
||||
*/
|
||||
consumesContextKeys: z.array(z.string()),
|
||||
/** Source-derived selector context (sans the parent key the modal supplies). */
|
||||
context: z.record(z.string(), z.string()),
|
||||
})
|
||||
export type ForkDependentReconfig = z.output<typeof forkDependentReconfigSchema>
|
||||
|
||||
/**
|
||||
* Every workflow a mapped resource (any kind) is used in, for the always-on reconfigure
|
||||
* listing under each mapping entry. Joined to the entry by `(parentKind, parentSourceId)`;
|
||||
* the modal cross-references {@link forkDependentReconfigSchema} per workflow to decide which
|
||||
* rows are expandable (have configurable dependents) vs. greyed (used here, nothing to tune).
|
||||
*/
|
||||
export const forkResourceUsageSchema = z.object({
|
||||
parentKind: forkRemapKindSchema,
|
||||
parentSourceId: z.string(),
|
||||
workflows: z.array(
|
||||
z.object({
|
||||
/** Deterministic fork (target) workflow id - matches a dependent's `targetWorkflowId`. */
|
||||
workflowId: z.string(),
|
||||
workflowName: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
export type ForkResourceUsage = z.output<typeof forkResourceUsageSchema>
|
||||
|
||||
export const getForkDiffQuerySchema = z.object({
|
||||
otherWorkspaceId: workspaceIdSchema,
|
||||
direction: forkDirectionSchema,
|
||||
})
|
||||
export const getForkDiffContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/fork/diff',
|
||||
params: workspaceIdParamsSchema,
|
||||
query: getForkDiffQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
sourceWorkspaceId: z.string(),
|
||||
targetWorkspaceId: z.string(),
|
||||
willUpdate: z.number().int(),
|
||||
willCreate: z.number().int(),
|
||||
willArchive: z.number().int(),
|
||||
/** Per-workflow change list for the sync preview. */
|
||||
workflows: z.array(forkWorkflowChangeSchema),
|
||||
unmappedRequired: z.array(forkUnmappedReferenceSchema),
|
||||
unmappedOptional: z.array(forkUnmappedReferenceSchema),
|
||||
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
|
||||
mcpReauthServerIds: z.array(z.string()),
|
||||
/** Review-only descriptions of inline secrets that cannot be id-mapped. */
|
||||
inlineSecretSources: z.array(z.string()),
|
||||
/** Configured selector fields per parent (credential/KB), for the pre-sync reconfigure. */
|
||||
dependentReconfigs: z.array(forkDependentReconfigSchema),
|
||||
/** Every workflow each mapped resource is used in, for the always-on reconfigure listing. */
|
||||
resourceUsages: z.array(forkResourceUsageSchema),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type GetForkDiffResponse = z.output<typeof getForkDiffContract.response.schema>
|
||||
export type ForkWorkflowChange = z.output<typeof forkWorkflowChangeSchema>
|
||||
|
||||
/**
|
||||
* A workflow whose required dependent fields a sync cleared because their parent
|
||||
* (e.g. a credential) was changed - the target must re-pick them. The workflow's
|
||||
* draft holds the synced state but it was NOT redeployed (the prior version keeps
|
||||
* running), so nothing runs broken until the user reconfigures.
|
||||
*/
|
||||
export const forkNeedsConfigurationSchema = z.object({
|
||||
workflowName: z.string(),
|
||||
/** Names of the blocks in the workflow that need a re-check (deduplicated). */
|
||||
blocks: z.array(z.string()).min(1),
|
||||
})
|
||||
export type ForkNeedsConfiguration = z.output<typeof forkNeedsConfigurationSchema>
|
||||
|
||||
/**
|
||||
* One dependent field's value in the stored mapping. The sync modal sends the full set for
|
||||
* every dependent whose parent is mapped; promote persists them to
|
||||
* `workspace_fork_dependent_value` and applies them verbatim to the target blocks, so the
|
||||
* user's selection survives every future sync without re-picking. `blockId` is the
|
||||
* deterministic fork block id, so the value lands on the right block.
|
||||
*/
|
||||
export const forkDependentValueEntrySchema = z.object({
|
||||
workflowId: nonEmptyIdSchema,
|
||||
blockId: nonEmptyIdSchema,
|
||||
subBlockKey: z.string().min(1, 'subBlockKey is required'),
|
||||
value: z.string(),
|
||||
})
|
||||
export type ForkDependentValueEntry = z.input<typeof forkDependentValueEntrySchema>
|
||||
|
||||
export const promoteForkBodySchema = z.object({
|
||||
otherWorkspaceId: workspaceIdSchema,
|
||||
direction: forkDirectionSchema,
|
||||
/**
|
||||
* The full stored mapping of dependent-field values; persisted to
|
||||
* `workspace_fork_dependent_value` and applied to the target blocks verbatim. Omitting the
|
||||
* field leaves the stored mapping untouched (the store stays the source of truth); sending
|
||||
* an explicit `[]` clears it for the written replace targets.
|
||||
*/
|
||||
dependentValues: z.array(forkDependentValueEntrySchema).max(2000).optional(),
|
||||
})
|
||||
export const promoteForkContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/fork/promote',
|
||||
params: workspaceIdParamsSchema,
|
||||
body: promoteForkBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
promoteRunId: z.string(),
|
||||
updated: z.number().int(),
|
||||
created: z.number().int(),
|
||||
archived: z.number().int(),
|
||||
redeployed: z.number().int(),
|
||||
deployFailed: z.number().int(),
|
||||
unmappedRequired: z.array(forkUnmappedReferenceSchema),
|
||||
/** Workflows whose required dependent fields the target must re-pick post-sync. */
|
||||
needsConfiguration: z.array(forkNeedsConfigurationSchema),
|
||||
/** Workflows whose optional dependent fields a swap cleared (surfaced, not gated). */
|
||||
clearedOptional: z.array(forkNeedsConfigurationSchema),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type PromoteForkBody = z.input<typeof promoteForkBodySchema>
|
||||
export type PromoteForkResponse = z.output<typeof promoteForkContract.response.schema>
|
||||
|
||||
/** Structured detail for a background job, surfaced in the Activity tab's expand row. */
|
||||
export const backgroundWorkMetadataSchema = z
|
||||
.object({
|
||||
/** Display name of the user who performed the action (denormalized at write time). */
|
||||
actorName: z.string().optional(),
|
||||
// Fork content copy
|
||||
childWorkspaceId: z.string().optional(),
|
||||
childWorkspaceName: z.string().optional(),
|
||||
workflowsCopied: z.number().int().optional(),
|
||||
tables: z.number().int().optional(),
|
||||
knowledgeBases: z.number().int().optional(),
|
||||
files: z.number().int().optional(),
|
||||
copied: z.number().int().optional(),
|
||||
failed: z.number().int().optional(),
|
||||
/** Names of the resources a fork copied, by kind, for the report breakdown. */
|
||||
workflowNames: z.array(z.string()).optional(),
|
||||
tableNames: z.array(z.string()).optional(),
|
||||
knowledgeBaseNames: z.array(z.string()).optional(),
|
||||
fileNames: z.array(z.string()).optional(),
|
||||
customToolNames: z.array(z.string()).optional(),
|
||||
skillNames: z.array(z.string()).optional(),
|
||||
mcpServerNames: z.array(z.string()).optional(),
|
||||
// Sync / rollback
|
||||
otherWorkspaceName: z.string().optional(),
|
||||
direction: z.enum(['push', 'pull']).optional(),
|
||||
updated: z.number().int().optional(),
|
||||
created: z.number().int().optional(),
|
||||
archived: z.number().int().optional(),
|
||||
redeployed: z.number().int().optional(),
|
||||
deployFailed: z.number().int().optional(),
|
||||
restored: z.number().int().optional(),
|
||||
unarchived: z.number().int().optional(),
|
||||
removed: z.number().int().optional(),
|
||||
skipped: z.number().int().optional(),
|
||||
/** Names of the workflows a sync changed, by action, for the report breakdown. */
|
||||
updatedNames: z.array(z.string()).optional(),
|
||||
createdNames: z.array(z.string()).optional(),
|
||||
archivedNames: z.array(z.string()).optional(),
|
||||
/** Workflows whose required dependent fields a sync left for the target to re-pick. */
|
||||
needsConfiguration: z.array(forkNeedsConfigurationSchema).optional(),
|
||||
/** Workflows whose optional dependent fields a sync cleared (FYI, non-blocking). */
|
||||
clearedOptional: z.array(forkNeedsConfigurationSchema).optional(),
|
||||
})
|
||||
.nullable()
|
||||
export const backgroundWorkItemSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
workflowId: z.string().nullable(),
|
||||
kind: z.enum(['deployment_side_effects', 'fork_content_copy', 'fork_sync', 'fork_rollback']),
|
||||
status: z.enum(['pending', 'processing', 'completed', 'completed_with_warnings', 'failed']),
|
||||
message: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
metadata: backgroundWorkMetadataSchema,
|
||||
startedAt: z.string(),
|
||||
completedAt: z.string().nullable(),
|
||||
})
|
||||
export type BackgroundWorkMetadata = z.output<typeof backgroundWorkMetadataSchema>
|
||||
export const getWorkspaceBackgroundWorkContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/workspaces/[id]/background-work',
|
||||
params: workspaceIdParamsSchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({ items: z.array(backgroundWorkItemSchema) }),
|
||||
},
|
||||
})
|
||||
export type BackgroundWorkItem = z.output<typeof backgroundWorkItemSchema>
|
||||
export type GetWorkspaceBackgroundWorkResponse = z.output<
|
||||
typeof getWorkspaceBackgroundWorkContract.response.schema
|
||||
>
|
||||
|
||||
export const rollbackForkBodySchema = z.object({
|
||||
otherWorkspaceId: workspaceIdSchema,
|
||||
})
|
||||
export const rollbackForkContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/workspaces/[id]/fork/rollback',
|
||||
params: workspaceIdParamsSchema,
|
||||
body: rollbackForkBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: z.object({
|
||||
restored: z.number().int(),
|
||||
archived: z.number().int(),
|
||||
unarchived: z.number().int(),
|
||||
/** Snapshot workflows that no longer exist and couldn't be reactivated. */
|
||||
skipped: z.number().int(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
export type RollbackForkBody = z.input<typeof rollbackForkBodySchema>
|
||||
export type RollbackForkResponse = z.output<typeof rollbackForkContract.response.schema>
|
||||
@@ -23,6 +23,9 @@ export const workspaceSchema = z.object({
|
||||
inviteMembersEnabled: z.boolean().optional(),
|
||||
inviteDisabledReason: z.string().nullable().optional(),
|
||||
inviteUpgradeRequired: z.boolean().optional(),
|
||||
// Source workspace id when this was created as a fork (null otherwise). Optional
|
||||
// because not every workspace response builder includes the column.
|
||||
forkedFromWorkspaceId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type Workspace = z.output<typeof workspaceSchema>
|
||||
|
||||
@@ -160,6 +160,12 @@ export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED)
|
||||
*/
|
||||
export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED)
|
||||
|
||||
/**
|
||||
* Is workspace forking enabled via env var override
|
||||
* This bypasses hosted (Enterprise) requirements for self-hosted deployments
|
||||
*/
|
||||
export const isForkingEnabled = isTruthy(env.FORKING_ENABLED)
|
||||
|
||||
/**
|
||||
* Is E2B enabled for remote code execution
|
||||
*/
|
||||
|
||||
@@ -411,6 +411,7 @@ export const env = createEnv({
|
||||
AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
|
||||
DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
|
||||
DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
|
||||
FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements)
|
||||
|
||||
// Organizations - for self-hosted deployments
|
||||
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
|
||||
@@ -510,6 +511,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
|
||||
NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
|
||||
NEXT_PUBLIC_DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
|
||||
NEXT_PUBLIC_FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements)
|
||||
NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: z.boolean().optional(), // Show the "Workflow" column type in user tables (defaults to false)
|
||||
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
|
||||
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
|
||||
@@ -549,6 +551,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
|
||||
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,
|
||||
NEXT_PUBLIC_DATA_DRAINS_ENABLED: process.env.NEXT_PUBLIC_DATA_DRAINS_ENABLED,
|
||||
NEXT_PUBLIC_FORKING_ENABLED: process.env.NEXT_PUBLIC_FORKING_ENABLED,
|
||||
NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED: process.env.NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED,
|
||||
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
|
||||
NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS,
|
||||
|
||||
@@ -14,11 +14,17 @@ import {
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import {
|
||||
remapConditionIdsInSubBlocks,
|
||||
remapVariableIdsInSubBlocks,
|
||||
remapWorkflowReferencesInSubBlocks,
|
||||
type SubBlockRecord,
|
||||
sanitizeSubBlocksForDuplicate,
|
||||
} from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateHelper')
|
||||
|
||||
@@ -54,43 +60,6 @@ interface DuplicateWorkflowResult {
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
}
|
||||
/**
|
||||
* Untrusted shape of a persisted block subBlocks JSON column. We narrow `type`/`value`
|
||||
* with runtime checks before mutating; the index signature exists because callers pass
|
||||
* the raw record back to drizzle without knowing which subBlock keys it contains.
|
||||
*/
|
||||
type SubBlockRecord = Record<string, { type?: unknown; value?: unknown; [key: string]: unknown }>
|
||||
|
||||
/**
|
||||
* Untrusted shape of a single entry inside a `variables-input` value array. The
|
||||
* `variableId` slot is widened to `unknown` so we are forced to type-narrow before
|
||||
* trusting it as a remap key — persisted JSON may legitimately predate the field.
|
||||
*/
|
||||
type VariableAssignment = Record<string, unknown> & { variableId?: unknown }
|
||||
const DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS = new Set(
|
||||
SYSTEM_SUBBLOCK_IDS.filter((id) => id !== 'triggerCredentials')
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
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}_`))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function assertTargetFolderMutable(
|
||||
tx: DbOrTx,
|
||||
@@ -124,149 +93,6 @@ async function assertTargetFolderMutable(
|
||||
}
|
||||
}
|
||||
|
||||
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 duplication', {
|
||||
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 duplication')
|
||||
}
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps old variable IDs to new variable IDs inside block subBlocks.
|
||||
* Specifically targets `variables-input` subblocks whose value is an array
|
||||
* of variable assignments containing a `variableId` field.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
function remapWorkflowReferencesInSubBlocks(
|
||||
subBlocks: SubBlockRecord,
|
||||
workflowIdMap: Map<string, string> | undefined
|
||||
): SubBlockRecord {
|
||||
if (!workflowIdMap?.size) return subBlocks
|
||||
|
||||
const updated: SubBlockRecord = {}
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (
|
||||
subBlock &&
|
||||
typeof subBlock === 'object' &&
|
||||
subBlock.type === 'workflow-selector' &&
|
||||
typeof subBlock.value === 'string'
|
||||
) {
|
||||
updated[key] = {
|
||||
...subBlock,
|
||||
value: workflowIdMap.get(subBlock.value) ?? subBlock.value,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
updated[key] = subBlock
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps condition/router block IDs within subBlocks when a block is duplicated.
|
||||
* Returns a new object without mutating the input.
|
||||
*/
|
||||
function remapConditionIdsInSubBlocks(
|
||||
subBlocks: Record<string, any>,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): Record<string, any> {
|
||||
const updated: Record<string, any> = {}
|
||||
|
||||
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 {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
updated[key] = subBlock
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workflow with all its blocks, edges, and subflows
|
||||
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
coerceObjectArray,
|
||||
remapWorkflowReferencesInSubBlocks,
|
||||
type SubBlockRecord,
|
||||
} from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
|
||||
describe('remapWorkflowReferencesInSubBlocks', () => {
|
||||
const map = new Map([
|
||||
['wf-src', 'wf-dst'],
|
||||
['sub-src', 'sub-dst'],
|
||||
])
|
||||
|
||||
it('remaps a top-level workflow-selector value', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
target: { id: 'target', type: 'workflow-selector', value: 'wf-src' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.target.value).toBe('wf-dst')
|
||||
})
|
||||
|
||||
it('remaps a nested workflow_input tool workflowId in a tool-input array', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [
|
||||
{ type: 'workflow_input', params: { workflowId: 'sub-src', inputMapping: '{}' } },
|
||||
{ type: 'custom-tool', customToolId: 'ct-1' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }>
|
||||
expect(tools[0].params?.workflowId).toBe('sub-dst')
|
||||
expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' })
|
||||
})
|
||||
|
||||
it('handles a JSON-stringified tool-input value', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'sub-src' } }]),
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.tools.value).toBe(
|
||||
JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'sub-dst' } }])
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves unknown workflow ids and non-workflow tools untouched', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
sel: { id: 'sel', type: 'workflow-selector', value: 'wf-unknown' },
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [{ type: 'workflow_input', params: { workflowId: 'wf-unknown' } }],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.sel.value).toBe('wf-unknown')
|
||||
expect(result.tools).toBe(subBlocks.tools)
|
||||
})
|
||||
|
||||
it('returns the input unchanged when the id map is empty', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
target: { id: 'target', type: 'workflow-selector', value: 'wf-src' },
|
||||
}
|
||||
expect(remapWorkflowReferencesInSubBlocks(subBlocks, new Map())).toBe(subBlocks)
|
||||
})
|
||||
|
||||
it('clears an unmapped workflow-selector when clearUnmapped is set (cross-workspace)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
sel: { id: 'sel', type: 'workflow-selector', value: 'wf-unknown' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.sel.value).toBe('')
|
||||
})
|
||||
|
||||
it('drops an unmapped workflow_input tool when clearUnmapped is set', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [
|
||||
{ type: 'workflow_input', params: { workflowId: 'wf-unknown' } },
|
||||
{ type: 'workflow_input', params: { workflowId: 'sub-src' } },
|
||||
],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
const tools = result.tools.value as Array<{ params?: { workflowId?: string } }>
|
||||
expect(tools).toHaveLength(1)
|
||||
expect(tools[0].params?.workflowId).toBe('sub-dst')
|
||||
})
|
||||
|
||||
it('remaps the advanced-mode manualWorkflowId override', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.manualWorkflowId.value).toBe('wf-dst')
|
||||
})
|
||||
|
||||
it('remaps a comma-separated manualWorkflowIds list', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.manualWorkflowIds.value).toBe('wf-dst,sub-dst')
|
||||
})
|
||||
|
||||
it('drops unmapped ids from a manualWorkflowIds list when clearUnmapped is set', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
manualWorkflowIds: {
|
||||
id: 'manualWorkflowIds',
|
||||
type: 'short-input',
|
||||
value: 'wf-src,wf-unknown',
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.manualWorkflowIds.value).toBe('wf-dst')
|
||||
})
|
||||
|
||||
it('remaps a multi-select workflowSelector array', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowSelector: { id: 'workflowSelector', type: 'dropdown', value: ['wf-src', 'sub-src'] },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('coerceObjectArray', () => {
|
||||
it('returns arrays directly', () => {
|
||||
expect(coerceObjectArray([{ a: 1 }])).toEqual({ array: [{ a: 1 }], wasString: false })
|
||||
})
|
||||
it('parses JSON-string arrays', () => {
|
||||
expect(coerceObjectArray('[{"a":1}]')).toEqual({ array: [{ a: 1 }], wasString: true })
|
||||
})
|
||||
it('returns null for non-array values', () => {
|
||||
expect(coerceObjectArray('hi')).toEqual({ array: null, wasString: false })
|
||||
expect(coerceObjectArray(42)).toEqual({ array: null, wasString: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { remapConditionBlockIds } from '@/lib/workflows/condition-ids'
|
||||
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: single
|
||||
* `workflow-selector` / `manualWorkflowId` values, multi-workflow lists
|
||||
* (`workflowSelector` multi-select + comma-separated `manualWorkflowIds`, as used
|
||||
* by the logs block), and `workflow_input` sub-workflow tools nested in a
|
||||
* `tool-input` array (an agent calling another workflow as a tool).
|
||||
*
|
||||
* `clearUnmapped` controls the cross-workspace case: fork/promote pass `true` so a
|
||||
* reference to 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 }
|
||||
): 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
|
||||
}
|
||||
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' || baseKey === 'manualWorkflowId') &&
|
||||
typeof subBlock.value === 'string' &&
|
||||
subBlock.value
|
||||
) {
|
||||
updated[key] = { ...subBlock, value: remapScalar(subBlock.value) }
|
||||
continue
|
||||
}
|
||||
if (baseKey === 'manualWorkflowIds' || baseKey === 'workflowSelector') {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -637,6 +637,23 @@ export async function deployWorkflow(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse to deploy an archived (soft-deleted) workflow. Checked under the row
|
||||
// lock so it's atomic with a concurrent fork rollback that archives a
|
||||
// promote-created workflow: a stale promote deploy can never resurrect it into
|
||||
// an archived-but-deployed (incoherent) state.
|
||||
const [archivedRow] = await tx
|
||||
.select({ archivedAt: workflow.archivedAt })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
if (archivedRow?.archivedAt != null) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: 'Cannot deploy an archived workflow',
|
||||
errorCode: 'validation' as const,
|
||||
}
|
||||
}
|
||||
|
||||
currentState = params.workflowState ?? (await loadWorkflowDeploymentSnapshot(workflowId, tx))
|
||||
if (!currentState) {
|
||||
return {
|
||||
|
||||
@@ -682,7 +682,13 @@ function isVisibleToolParameter(param: ToolParameterConfig, values: Record<strin
|
||||
)
|
||||
}
|
||||
|
||||
function getToolInputParamConfigs({
|
||||
/**
|
||||
* Resolve a stored tool's params to their subBlock configs (the same resolution
|
||||
* the search index + UI use). Exported so cross-workspace remapping (fork/promote)
|
||||
* can detect and rewrite the workspace-scoped resource ids nested inside block
|
||||
* tools, not just the top-level selectors.
|
||||
*/
|
||||
export function getToolInputParamConfigs({
|
||||
tool,
|
||||
parentCanonicalModes,
|
||||
credentialTypeById,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { backgroundWorkStatus } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, desc, eq, inArray, isNull, lte } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
const logger = createLogger('ForkBackgroundWork')
|
||||
|
||||
export type BackgroundWorkKind =
|
||||
| 'deployment_side_effects'
|
||||
| 'fork_content_copy'
|
||||
| 'fork_sync'
|
||||
| 'fork_rollback'
|
||||
export type BackgroundWorkStatusValue =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'completed_with_warnings'
|
||||
| 'failed'
|
||||
|
||||
/** Statuses surfaced as recent jobs - active, completed, or a recent issue. */
|
||||
const SURFACED_STATUSES: BackgroundWorkStatusValue[] = [
|
||||
'pending',
|
||||
'processing',
|
||||
'completed',
|
||||
'completed_with_warnings',
|
||||
'failed',
|
||||
]
|
||||
|
||||
/** Cap on recent jobs returned for a workspace's Activity tab. */
|
||||
const BACKGROUND_WORK_LIST_LIMIT = 20
|
||||
|
||||
/**
|
||||
* An active (pending/processing) row older than this is treated as abandoned: the
|
||||
* worker crashed or restarted before {@link finishBackgroundWork}, and a hard crash
|
||||
* (Trigger CRASHED / process death) fires no in-task hook. The outbox-processor cron
|
||||
* sweeps these via {@link reapStaleBackgroundWork}, marking them `failed` so the UI
|
||||
* surfaces the failure instead of spinning forever.
|
||||
*/
|
||||
const STALE_ACTIVE_MS = 30 * 60 * 1000
|
||||
|
||||
/** Terminal rows older than this are pruned by the cron so the audit trail stays bounded. */
|
||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
export interface BackgroundWorkRow {
|
||||
id: string
|
||||
workspaceId: string
|
||||
workflowId: string | null
|
||||
kind: BackgroundWorkKind
|
||||
status: BackgroundWorkStatusValue
|
||||
message: string | null
|
||||
error: string | null
|
||||
metadata: unknown
|
||||
startedAt: Date
|
||||
completedAt: Date | null
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin tracking a unit of background work, returning its id. Any prior row for the
|
||||
* same scope (workspace + workflow + kind) is removed first so exactly one status per
|
||||
* scope is live - a fresh run supersedes a stale completed/failed one.
|
||||
*/
|
||||
export async function startBackgroundWork(
|
||||
executor: DbOrTx,
|
||||
params: {
|
||||
workspaceId: string
|
||||
workflowId?: string | null
|
||||
kind: BackgroundWorkKind
|
||||
message?: string
|
||||
metadata?: unknown
|
||||
/**
|
||||
* Replace any prior row for the same scope (workspace + workflow + kind) so exactly
|
||||
* one status per scope is live (default). Pass `false` to keep an append-only audit
|
||||
* trail - e.g. fork jobs, where each fork is a distinct historical entry.
|
||||
*/
|
||||
supersede?: boolean
|
||||
}
|
||||
): Promise<string> {
|
||||
const { workspaceId, workflowId = null, kind, message, metadata, supersede = true } = params
|
||||
if (supersede) {
|
||||
await executor
|
||||
.delete(backgroundWorkStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(backgroundWorkStatus.workspaceId, workspaceId),
|
||||
eq(backgroundWorkStatus.kind, kind),
|
||||
workflowId == null
|
||||
? isNull(backgroundWorkStatus.workflowId)
|
||||
: eq(backgroundWorkStatus.workflowId, workflowId)
|
||||
)
|
||||
)
|
||||
}
|
||||
const id = generateId()
|
||||
const now = new Date()
|
||||
await executor.insert(backgroundWorkStatus).values({
|
||||
id,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
kind,
|
||||
status: 'processing',
|
||||
message: message ?? null,
|
||||
metadata: metadata ?? null,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a synchronous operation directly as a terminal audit entry (no `processing`
|
||||
* phase). Append-only - used by sync/rollback, which complete in-request, so they show
|
||||
* up in the same workspace audit log as fork jobs.
|
||||
*/
|
||||
export async function recordBackgroundWork(
|
||||
executor: DbOrTx,
|
||||
params: {
|
||||
workspaceId: string
|
||||
kind: BackgroundWorkKind
|
||||
status: Extract<BackgroundWorkStatusValue, 'completed' | 'completed_with_warnings' | 'failed'>
|
||||
message?: string
|
||||
error?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date()
|
||||
await executor.insert(backgroundWorkStatus).values({
|
||||
id: generateId(),
|
||||
workspaceId: params.workspaceId,
|
||||
workflowId: null,
|
||||
kind: params.kind,
|
||||
status: params.status,
|
||||
message: params.message ?? null,
|
||||
error: params.error ?? null,
|
||||
metadata: params.metadata ?? null,
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
/** Mark a tracked unit of work terminal (completed / completed_with_warnings / failed). */
|
||||
export async function finishBackgroundWork(
|
||||
executor: DbOrTx,
|
||||
id: string,
|
||||
params: {
|
||||
status: Extract<BackgroundWorkStatusValue, 'completed' | 'completed_with_warnings' | 'failed'>
|
||||
message?: string
|
||||
error?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date()
|
||||
// Merge metadata so terminal counts (copied/failed) augment what start recorded
|
||||
// (child name, per-kind plan) instead of replacing it.
|
||||
let metadata: unknown
|
||||
if (params.metadata !== undefined) {
|
||||
const [existing] = await executor
|
||||
.select({ metadata: backgroundWorkStatus.metadata })
|
||||
.from(backgroundWorkStatus)
|
||||
.where(eq(backgroundWorkStatus.id, id))
|
||||
.limit(1)
|
||||
metadata = { ...toMetadataRecord(existing?.metadata), ...toMetadataRecord(params.metadata) }
|
||||
}
|
||||
await executor
|
||||
.update(backgroundWorkStatus)
|
||||
.set({
|
||||
status: params.status,
|
||||
message: params.message ?? null,
|
||||
error: params.error ?? null,
|
||||
...(params.metadata !== undefined ? { metadata } : {}),
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(backgroundWorkStatus.id, id))
|
||||
}
|
||||
|
||||
/** Coerce an unknown jsonb metadata value to a plain record for safe merging. */
|
||||
function toMetadataRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent background-work jobs for a workspace - the durable audit record the Activity
|
||||
* tab renders, most-recent first and capped. Fork jobs are append-only (one row per
|
||||
* fork), so this is the workspace's fork history; older rows are pruned by the cron.
|
||||
*/
|
||||
export async function listSurfacedBackgroundWork(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<BackgroundWorkRow[]> {
|
||||
const rows = await executor
|
||||
.select()
|
||||
.from(backgroundWorkStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(backgroundWorkStatus.workspaceId, workspaceId),
|
||||
inArray(backgroundWorkStatus.status, SURFACED_STATUSES)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(backgroundWorkStatus.updatedAt))
|
||||
.limit(BACKGROUND_WORK_LIST_LIMIT)
|
||||
return rows as BackgroundWorkRow[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail background-work rows stuck in an active state past {@link STALE_ACTIVE_MS}: the
|
||||
* worker crashed or restarted before writing a terminal status, and a hard crash has
|
||||
* no in-task hook to recover from. Marks them `failed` so the UI shows the failure
|
||||
* rather than an indefinitely-spinning banner. Touches ONLY `background_work_status`.
|
||||
* Returns the count reaped; invoked from the outbox-processor cron.
|
||||
*/
|
||||
export async function reapStaleBackgroundWork(executor: DbOrTx): Promise<number> {
|
||||
const now = new Date()
|
||||
const cutoff = new Date(now.getTime() - STALE_ACTIVE_MS)
|
||||
const reaped = await executor
|
||||
.update(backgroundWorkStatus)
|
||||
.set({
|
||||
status: 'failed',
|
||||
error: 'Background work did not finish in time (the worker may have restarted).',
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(backgroundWorkStatus.status, ['pending', 'processing']),
|
||||
lte(backgroundWorkStatus.startedAt, cutoff)
|
||||
)
|
||||
)
|
||||
.returning({ id: backgroundWorkStatus.id })
|
||||
|
||||
// Retention: the append-only fork audit trail would otherwise grow forever, so drop
|
||||
// terminal rows past the retention window. The Activity tab caps display separately.
|
||||
await executor
|
||||
.delete(backgroundWorkStatus)
|
||||
.where(
|
||||
and(
|
||||
inArray(backgroundWorkStatus.status, ['completed', 'completed_with_warnings', 'failed']),
|
||||
lte(backgroundWorkStatus.updatedAt, new Date(now.getTime() - RETENTION_MS))
|
||||
)
|
||||
)
|
||||
|
||||
if (reaped.length > 0) {
|
||||
logger.warn('Reaped stale background-work rows', {
|
||||
count: reaped.length,
|
||||
thresholdMs: STALE_ACTIVE_MS,
|
||||
})
|
||||
}
|
||||
return reaped.length
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { db } from '@sim/db'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { finishBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
|
||||
import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files'
|
||||
import { executeForkFileBlobCopies } from '@/lib/workspaces/fork/copy/copy-files'
|
||||
import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources'
|
||||
import { copyForkResourceContent } from '@/lib/workspaces/fork/copy/copy-resources'
|
||||
|
||||
/**
|
||||
* Serializable payload for the post-fork heavy-content copy. Runs either as a
|
||||
* Trigger.dev task (`background/fork-content-copy`) or inline via `runDetached`
|
||||
* when Trigger.dev is disabled - both call {@link runForkContentCopy}.
|
||||
*/
|
||||
export interface ForkContentCopyPayload {
|
||||
contentPlan: ForkContentPlan
|
||||
blobTasks: BlobCopyTask[]
|
||||
/**
|
||||
* `background_work_status` row to finish when the copy ends, so the source workspace's
|
||||
* Manage Forks -> Activity entry resolves (completed / warning / error). Started right
|
||||
* after the fork commits so it's visible immediately.
|
||||
*/
|
||||
statusId?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the heavy fork content after the fork transaction has committed: table
|
||||
* rows, KB documents + embeddings (keyset-paginated), and file blobs. Best-effort
|
||||
* and idempotency-unsafe (per-row inserts use fresh ids), so it must run at most
|
||||
* once - never blindly retried. Per-resource failures are counted (not thrown), so
|
||||
* the run finishes `completed_with_warnings` rather than failing the whole copy.
|
||||
*/
|
||||
export async function runForkContentCopy(payload: ForkContentCopyPayload): Promise<void> {
|
||||
const { contentPlan, blobTasks, statusId, requestId } = payload
|
||||
try {
|
||||
const resourceCounts = await copyForkResourceContent({ contentPlan, requestId })
|
||||
const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId)
|
||||
const copied = resourceCounts.copied + fileCounts.copied
|
||||
const failed = resourceCounts.failed + fileCounts.failed
|
||||
if (statusId) {
|
||||
await finishBackgroundWork(db, statusId, {
|
||||
status: failed > 0 ? 'completed_with_warnings' : 'completed',
|
||||
message:
|
||||
failed > 0
|
||||
? `Copied ${copied} item${copied === 1 ? '' : 's'}; ${failed} could not be copied`
|
||||
: `Copied ${copied} item${copied === 1 ? '' : 's'}`,
|
||||
metadata: { copied, failed },
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (statusId) {
|
||||
await finishBackgroundWork(db, statusId, {
|
||||
status: 'failed',
|
||||
error: getErrorMessage(error, 'Background resource copy failed'),
|
||||
}).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { workspaceFiles } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
|
||||
import type { StorageContext } from '@/lib/uploads/shared/types'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCopyFiles')
|
||||
|
||||
export interface BlobCopyTask {
|
||||
sourceKey: string
|
||||
targetKey: string
|
||||
context: StorageContext
|
||||
fileName: string
|
||||
contentType: string
|
||||
userId: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export interface PlanForkFileCopiesResult {
|
||||
/**
|
||||
* source storage key -> child storage key. `file-upload` subblocks reference
|
||||
* files by storage key (not `workspace_files.id`), so the fork remap keys on the
|
||||
* storage key. File identity is not persisted in the fork resource map - files
|
||||
* are a fork-copy-only resource (not remapped on promote).
|
||||
*/
|
||||
keyMap: Map<string, string>
|
||||
/** Blob duplications to run after the fork transaction commits. */
|
||||
blobTasks: BlobCopyTask[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert child `workspace_files` metadata rows for the selected files (new id +
|
||||
* new storage key) and return the source→child storage-key map plus the blob
|
||||
* copies to run post-commit. The metadata row must exist before the blob upload
|
||||
* (its idempotent metadata insert reuses the row), and both must run after the
|
||||
* child workspace row exists (FK). Runs in the fork transaction; blob I/O is
|
||||
* deferred to {@link executeForkFileBlobCopies}.
|
||||
*/
|
||||
export async function planForkFileCopies(params: {
|
||||
tx: DbOrTx
|
||||
sourceWorkspaceId: string
|
||||
childWorkspaceId: string
|
||||
userId: string
|
||||
fileIds: string[]
|
||||
now: Date
|
||||
}): Promise<PlanForkFileCopiesResult> {
|
||||
const { tx, sourceWorkspaceId, childWorkspaceId, userId, fileIds, now } = params
|
||||
const keyMap = new Map<string, string>()
|
||||
const blobTasks: BlobCopyTask[] = []
|
||||
if (fileIds.length === 0) return { keyMap, blobTasks }
|
||||
|
||||
// Batch the metadata read (one query for all selected files) instead of a per-file
|
||||
// lookup: non-deleted, scoped to the source workspace, and restricted to durable
|
||||
// `workspace` files. Only workspace files are forkable - chat/copilot/mothership
|
||||
// uploads are session-scoped and their chat-bound unique index can't be duplicated -
|
||||
// so any non-workspace id passed here is ignored rather than copied.
|
||||
const metas = await tx
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
inArray(workspaceFiles.id, fileIds),
|
||||
eq(workspaceFiles.workspaceId, sourceWorkspaceId),
|
||||
eq(workspaceFiles.context, 'workspace'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
for (const meta of metas) {
|
||||
const childFileId = generateId()
|
||||
// Use the canonical workspace-file key (`workspace/{id}/...`) so the file-serve
|
||||
// API can infer the storage context; a bare `{id}/...` key has no context prefix.
|
||||
const targetKey = generateWorkspaceFileKey(childWorkspaceId, meta.originalName)
|
||||
await tx.insert(workspaceFiles).values({
|
||||
...meta,
|
||||
id: childFileId,
|
||||
key: targetKey,
|
||||
workspaceId: childWorkspaceId,
|
||||
userId,
|
||||
folderId: null,
|
||||
deletedAt: null,
|
||||
uploadedAt: now,
|
||||
})
|
||||
keyMap.set(meta.key, targetKey)
|
||||
blobTasks.push({
|
||||
sourceKey: meta.key,
|
||||
targetKey,
|
||||
context: meta.context as StorageContext,
|
||||
fileName: meta.originalName,
|
||||
contentType: meta.contentType,
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
})
|
||||
}
|
||||
|
||||
return { keyMap, blobTasks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate each planned file blob to its new key. `uploadFile`'s metadata insert
|
||||
* is idempotent on the key (the row was already created in the transaction), so
|
||||
* this only copies bytes. Best-effort: a failed blob leaves the metadata row
|
||||
* pointing at a missing object, which the user can re-upload.
|
||||
*/
|
||||
export async function executeForkFileBlobCopies(
|
||||
blobTasks: BlobCopyTask[],
|
||||
requestId = 'unknown'
|
||||
): Promise<{ copied: number; failed: number }> {
|
||||
let copied = 0
|
||||
let failed = 0
|
||||
for (const task of blobTasks) {
|
||||
try {
|
||||
const buffer = await downloadFile({
|
||||
key: task.sourceKey,
|
||||
context: task.context,
|
||||
maxBytes: MAX_FILE_SIZE,
|
||||
})
|
||||
await uploadFile({
|
||||
file: buffer,
|
||||
fileName: task.fileName,
|
||||
contentType: task.contentType,
|
||||
context: task.context,
|
||||
customKey: task.targetKey,
|
||||
preserveKey: true,
|
||||
metadata: {
|
||||
userId: task.userId,
|
||||
workspaceId: task.workspaceId,
|
||||
originalName: task.fileName,
|
||||
},
|
||||
})
|
||||
copied += 1
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
logger.warn(`[${requestId}] Failed to copy file blob during fork`, {
|
||||
targetKey: task.targetKey,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
return { copied, failed }
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
customTools,
|
||||
document,
|
||||
embedding,
|
||||
knowledgeBase,
|
||||
mcpServers,
|
||||
skill,
|
||||
userTableDefinitions,
|
||||
userTableRows,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, asc, eq, gt, inArray, isNull, type SQL } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { generateMcpServerId } from '@/lib/mcp/utils'
|
||||
import type { TableSchema } from '@/lib/table/types'
|
||||
import type {
|
||||
ForkMappingUpsert,
|
||||
ForkResourceType,
|
||||
} from '@/lib/workspaces/fork/mapping/mapping-store'
|
||||
import { remapForkTableWorkflowGroups } from '@/lib/workspaces/fork/remap/remap-table-groups'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCopyResources')
|
||||
|
||||
/** Page size for the post-transaction bulk content copy (keyset-paginated). */
|
||||
const CONTENT_PAGE = 500
|
||||
|
||||
export interface CopyResourcesParams {
|
||||
tx: DbOrTx
|
||||
sourceWorkspaceId: string
|
||||
childWorkspaceId: string
|
||||
userId: string
|
||||
now: Date
|
||||
/** Source resource ids selected for copy, by kind. */
|
||||
selection: {
|
||||
customTools: string[]
|
||||
skills: string[]
|
||||
mcpServers: string[]
|
||||
tables: string[]
|
||||
knowledgeBases: string[]
|
||||
}
|
||||
/** source workflow id -> child workflow id, for table workflow-group remap. */
|
||||
workflowIdMap: Map<string, string>
|
||||
}
|
||||
|
||||
export interface ForkContentPlanEntry {
|
||||
sourceId: string
|
||||
childId: string
|
||||
}
|
||||
|
||||
/** Bulk content to copy AFTER the fork transaction commits (best-effort, batched). */
|
||||
export interface ForkContentPlan {
|
||||
sourceWorkspaceId: string
|
||||
childWorkspaceId: string
|
||||
tables: ForkContentPlanEntry[]
|
||||
knowledgeBases: ForkContentPlanEntry[]
|
||||
}
|
||||
|
||||
/** Display names of the copied resources, by kind, for the fork activity report. */
|
||||
export interface ForkCopiedResourceNames {
|
||||
tables: string[]
|
||||
knowledgeBases: string[]
|
||||
customTools: string[]
|
||||
skills: string[]
|
||||
mcpServers: string[]
|
||||
}
|
||||
|
||||
export interface CopyResourcesResult {
|
||||
/** source resource id -> child resource id, keyed by fork resource type. */
|
||||
idMap: Map<ForkResourceType, Map<string, string>>
|
||||
/** Identity mapping rows to persist for every copied resource. */
|
||||
mappingEntries: ForkMappingUpsert[]
|
||||
/** Heavy row/document/embedding content to copy post-commit. */
|
||||
contentPlan: ForkContentPlan
|
||||
/** Names of the copied resources, by kind, for the fork report breakdown. */
|
||||
names: ForkCopiedResourceNames
|
||||
}
|
||||
|
||||
function setId(idMap: Map<ForkResourceType, Map<string, string>>, type: ForkResourceType) {
|
||||
let map = idMap.get(type)
|
||||
if (!map) {
|
||||
map = new Map()
|
||||
idMap.set(type, map)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the selected resources' **container rows** into the child workspace inside
|
||||
* the fork transaction: custom tools, skills, and MCP server configs (each a
|
||||
* single row), plus table definitions and knowledge-base rows (without their bulk
|
||||
* rows / documents / embeddings). This keeps the fork transaction bounded to
|
||||
* O(selected resources) single-row writes. The heavy content (table rows, KB
|
||||
* documents + embeddings) is returned as a {@link ForkContentPlan} for
|
||||
* {@link copyForkResourceContent} to copy best-effort after commit. Secrets are
|
||||
* never copied: MCP OAuth tokens are omitted (re-auth required) and KB connectors
|
||||
* are not copied (the child is a content snapshot without live sync).
|
||||
*/
|
||||
export async function copyForkResourceContainers(
|
||||
params: CopyResourcesParams
|
||||
): Promise<CopyResourcesResult> {
|
||||
const { tx, sourceWorkspaceId, childWorkspaceId, userId, now, selection, workflowIdMap } = params
|
||||
const idMap = new Map<ForkResourceType, Map<string, string>>()
|
||||
const mappingEntries: ForkMappingUpsert[] = []
|
||||
const contentPlan: ForkContentPlan = {
|
||||
sourceWorkspaceId,
|
||||
childWorkspaceId,
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
}
|
||||
const names: ForkCopiedResourceNames = {
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
}
|
||||
|
||||
const record = (type: ForkResourceType, sourceId: string, childId: string) => {
|
||||
setId(idMap, type).set(sourceId, childId)
|
||||
mappingEntries.push({
|
||||
resourceType: type,
|
||||
parentResourceId: sourceId,
|
||||
childResourceId: childId,
|
||||
})
|
||||
}
|
||||
|
||||
if (selection.customTools.length > 0) {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(customTools)
|
||||
.where(
|
||||
and(
|
||||
inArray(customTools.id, selection.customTools),
|
||||
eq(customTools.workspaceId, sourceWorkspaceId)
|
||||
)
|
||||
)
|
||||
const inserts: (typeof customTools.$inferInsert)[] = []
|
||||
for (const row of rows) {
|
||||
const childId = generateId()
|
||||
inserts.push({
|
||||
...row,
|
||||
id: childId,
|
||||
workspaceId: childWorkspaceId,
|
||||
userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
record('custom_tool', row.id, childId)
|
||||
names.customTools.push(row.title)
|
||||
}
|
||||
if (inserts.length > 0) await tx.insert(customTools).values(inserts)
|
||||
}
|
||||
|
||||
if (selection.skills.length > 0) {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(skill)
|
||||
.where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId)))
|
||||
const inserts: (typeof skill.$inferInsert)[] = []
|
||||
for (const row of rows) {
|
||||
const childId = generateId()
|
||||
inserts.push({
|
||||
...row,
|
||||
id: childId,
|
||||
workspaceId: childWorkspaceId,
|
||||
userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
record('skill', row.id, childId)
|
||||
names.skills.push(row.name)
|
||||
}
|
||||
if (inserts.length > 0) await tx.insert(skill).values(inserts)
|
||||
}
|
||||
|
||||
if (selection.mcpServers.length > 0) {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
inArray(mcpServers.id, selection.mcpServers),
|
||||
eq(mcpServers.workspaceId, sourceWorkspaceId),
|
||||
isNull(mcpServers.deletedAt)
|
||||
)
|
||||
)
|
||||
// `generateMcpServerId` is deterministic on (workspace, url), so two selected
|
||||
// servers with the same normalized URL derive the same child id. Insert once
|
||||
// and map both source ids to the surviving child rather than aborting the fork.
|
||||
const insertsByChildId = new Map<string, typeof mcpServers.$inferInsert>()
|
||||
for (const row of rows) {
|
||||
const childId = row.url ? generateMcpServerId(childWorkspaceId, row.url) : generateId()
|
||||
record('mcp_server', row.id, childId)
|
||||
if (insertsByChildId.has(childId)) continue
|
||||
names.mcpServers.push(row.name)
|
||||
insertsByChildId.set(childId, {
|
||||
...row,
|
||||
id: childId,
|
||||
workspaceId: childWorkspaceId,
|
||||
createdBy: userId,
|
||||
// Secrets are never copied across workspaces: drop the registered OAuth
|
||||
// client + any auth headers so the child re-authenticates from scratch.
|
||||
oauthClientId: null,
|
||||
oauthClientSecret: null,
|
||||
headers: {},
|
||||
connectionStatus: 'disconnected',
|
||||
lastConnected: null,
|
||||
lastError: null,
|
||||
deletedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
if (insertsByChildId.size > 0) {
|
||||
await tx
|
||||
.insert(mcpServers)
|
||||
.values([...insertsByChildId.values()])
|
||||
.onConflictDoNothing()
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.tables.length > 0) {
|
||||
const definitions = await tx
|
||||
.select()
|
||||
.from(userTableDefinitions)
|
||||
.where(
|
||||
and(
|
||||
inArray(userTableDefinitions.id, selection.tables),
|
||||
eq(userTableDefinitions.workspaceId, sourceWorkspaceId),
|
||||
isNull(userTableDefinitions.archivedAt)
|
||||
)
|
||||
)
|
||||
const inserts: (typeof userTableDefinitions.$inferInsert)[] = []
|
||||
for (const definition of definitions) {
|
||||
const childTableId = generateId()
|
||||
const remappedSchema = remapForkTableWorkflowGroups(
|
||||
definition.schema as TableSchema,
|
||||
workflowIdMap
|
||||
)
|
||||
inserts.push({
|
||||
...definition,
|
||||
id: childTableId,
|
||||
workspaceId: childWorkspaceId,
|
||||
schema: remappedSchema,
|
||||
createdBy: userId,
|
||||
rowsVersion: 0,
|
||||
// Start at 0 - the post-commit content copy raises it to the rows actually
|
||||
// copied, so a failed/partial copy never advertises the source's count.
|
||||
rowCount: 0,
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
record('table', definition.id, childTableId)
|
||||
contentPlan.tables.push({ sourceId: definition.id, childId: childTableId })
|
||||
names.tables.push(definition.name)
|
||||
}
|
||||
if (inserts.length > 0) await tx.insert(userTableDefinitions).values(inserts)
|
||||
}
|
||||
|
||||
if (selection.knowledgeBases.length > 0) {
|
||||
const bases = await tx
|
||||
.select()
|
||||
.from(knowledgeBase)
|
||||
.where(
|
||||
and(
|
||||
inArray(knowledgeBase.id, selection.knowledgeBases),
|
||||
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
|
||||
isNull(knowledgeBase.deletedAt)
|
||||
)
|
||||
)
|
||||
const inserts: (typeof knowledgeBase.$inferInsert)[] = []
|
||||
for (const base of bases) {
|
||||
const childKbId = generateId()
|
||||
inserts.push({
|
||||
...base,
|
||||
id: childKbId,
|
||||
workspaceId: childWorkspaceId,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
record('knowledge_base', base.id, childKbId)
|
||||
contentPlan.knowledgeBases.push({ sourceId: base.id, childId: childKbId })
|
||||
names.knowledgeBases.push(base.name)
|
||||
}
|
||||
if (inserts.length > 0) await tx.insert(knowledgeBase).values(inserts)
|
||||
}
|
||||
|
||||
return { idMap, mappingEntries, contentPlan, names }
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the heavy resource content described by a {@link ForkContentPlan} AFTER the
|
||||
* fork transaction has committed: table rows, and KB documents + embeddings. Reads
|
||||
* and writes are keyset-paginated so peak memory is bounded to one page, and each
|
||||
* resource is copied in its own short statements (never one long transaction).
|
||||
* Best-effort: a failure on one resource is logged and the others continue - the
|
||||
* fork itself (workflows + container rows) already succeeded.
|
||||
*/
|
||||
export async function copyForkResourceContent(params: {
|
||||
contentPlan: ForkContentPlan
|
||||
requestId?: string
|
||||
}): Promise<{ copied: number; failed: number }> {
|
||||
const { contentPlan, requestId = 'unknown' } = params
|
||||
const { childWorkspaceId } = contentPlan
|
||||
|
||||
let copiedResources = 0
|
||||
let failedResources = 0
|
||||
|
||||
for (const table of contentPlan.tables) {
|
||||
try {
|
||||
let copied = 0
|
||||
let afterId: string | null = null
|
||||
for (;;) {
|
||||
const where: SQL<unknown> | undefined =
|
||||
afterId === null
|
||||
? eq(userTableRows.tableId, table.sourceId)
|
||||
: and(eq(userTableRows.tableId, table.sourceId), gt(userTableRows.id, afterId))
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(userTableRows)
|
||||
.where(where)
|
||||
.orderBy(asc(userTableRows.id))
|
||||
.limit(CONTENT_PAGE)
|
||||
if (rows.length === 0) break
|
||||
await db.insert(userTableRows).values(
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
id: generateId(),
|
||||
tableId: table.childId,
|
||||
workspaceId: childWorkspaceId,
|
||||
}))
|
||||
)
|
||||
copied += rows.length
|
||||
afterId = rows[rows.length - 1].id
|
||||
if (rows.length < CONTENT_PAGE) break
|
||||
}
|
||||
await db
|
||||
.update(userTableDefinitions)
|
||||
.set({ rowCount: copied })
|
||||
.where(eq(userTableDefinitions.id, table.childId))
|
||||
copiedResources += 1
|
||||
} catch (error) {
|
||||
failedResources += 1
|
||||
logger.warn(`[${requestId}] Failed to copy table rows during fork`, {
|
||||
sourceTableId: table.sourceId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const kb of contentPlan.knowledgeBases) {
|
||||
try {
|
||||
let afterDocId: string | null = null
|
||||
for (;;) {
|
||||
// Only copy LIVE documents - exclude soft-deleted and archived rows, matching
|
||||
// how the rest of the KB system treats them as gone (chunks/tags/search filter
|
||||
// both). A fork must not resurrect documents removed from the source base.
|
||||
const liveDocs = and(
|
||||
eq(document.knowledgeBaseId, kb.sourceId),
|
||||
isNull(document.deletedAt),
|
||||
isNull(document.archivedAt)
|
||||
)
|
||||
const where: SQL<unknown> | undefined =
|
||||
afterDocId === null ? liveDocs : and(liveDocs, gt(document.id, afterDocId))
|
||||
const docs = await db
|
||||
.select()
|
||||
.from(document)
|
||||
.where(where)
|
||||
.orderBy(asc(document.id))
|
||||
.limit(CONTENT_PAGE)
|
||||
if (docs.length === 0) break
|
||||
for (const doc of docs) {
|
||||
const childDocId = generateId()
|
||||
await db.insert(document).values({
|
||||
...doc,
|
||||
id: childDocId,
|
||||
knowledgeBaseId: kb.childId,
|
||||
connectorId: null,
|
||||
deletedAt: null,
|
||||
archivedAt: null,
|
||||
})
|
||||
await copyDocumentEmbeddings(doc.id, childDocId, kb.childId)
|
||||
}
|
||||
afterDocId = docs[docs.length - 1].id
|
||||
if (docs.length < CONTENT_PAGE) break
|
||||
}
|
||||
copiedResources += 1
|
||||
} catch (error) {
|
||||
failedResources += 1
|
||||
logger.warn(`[${requestId}] Failed to copy knowledge base content during fork`, {
|
||||
sourceKnowledgeBaseId: kb.sourceId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { copied: copiedResources, failed: failedResources }
|
||||
}
|
||||
|
||||
async function copyDocumentEmbeddings(
|
||||
sourceDocumentId: string,
|
||||
childDocumentId: string,
|
||||
childKnowledgeBaseId: string
|
||||
): Promise<void> {
|
||||
let afterId: string | null = null
|
||||
for (;;) {
|
||||
const where: SQL<unknown> | undefined =
|
||||
afterId === null
|
||||
? eq(embedding.documentId, sourceDocumentId)
|
||||
: and(eq(embedding.documentId, sourceDocumentId), gt(embedding.id, afterId))
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(embedding)
|
||||
.where(where)
|
||||
.orderBy(asc(embedding.id))
|
||||
.limit(CONTENT_PAGE)
|
||||
if (rows.length === 0) break
|
||||
await db.insert(embedding).values(
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
id: generateId(),
|
||||
documentId: childDocumentId,
|
||||
knowledgeBaseId: childKnowledgeBaseId,
|
||||
}))
|
||||
)
|
||||
afterId = rows[rows.length - 1].id
|
||||
if (rows.length < CONTENT_PAGE) break
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkflowNameRegistry } from '@/lib/workspaces/fork/copy/copy-workflows'
|
||||
|
||||
describe('buildWorkflowNameRegistry', () => {
|
||||
it('reports a name as taken by another workflow in the same folder', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
|
||||
expect(reg.isTaken('f1', 'Onboarding', null)).toBe(true)
|
||||
expect(reg.isTaken('f1', 'Onboarding', 'w2')).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes the workflow itself so a replace can keep its own name', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
|
||||
expect(reg.isTaken('f1', 'Onboarding', 'w1')).toBe(false)
|
||||
})
|
||||
|
||||
it('is folder-scoped: the same name in another folder is free', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Onboarding' }])
|
||||
expect(reg.isTaken('f2', 'Onboarding', null)).toBe(false)
|
||||
expect(reg.isTaken(null, 'Onboarding', null)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats the root (null) folder distinctly, matching coalesce(folderId, "")', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: null, name: 'Root WF' }])
|
||||
expect(reg.isTaken(null, 'Root WF', null)).toBe(true)
|
||||
expect(reg.isTaken('f1', 'Root WF', null)).toBe(false)
|
||||
})
|
||||
|
||||
it('claims a new name so a later workflow in the same copy loop sees it taken', () => {
|
||||
const reg = buildWorkflowNameRegistry([])
|
||||
expect(reg.isTaken('f1', 'Report', null)).toBe(false)
|
||||
reg.claim('f1', 'Report', 'wA')
|
||||
expect(reg.isTaken('f1', 'Report', null)).toBe(true)
|
||||
expect(reg.isTaken('f1', 'Report', 'wA')).toBe(false)
|
||||
})
|
||||
|
||||
it('releases the prior name when a workflow is renamed (claim moves keys)', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Old' }])
|
||||
reg.claim('f1', 'New', 'w1')
|
||||
expect(reg.isTaken('f1', 'Old', null)).toBe(false)
|
||||
expect(reg.isTaken('f1', 'New', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('re-claiming the same (folder, name) is a no-op', () => {
|
||||
const reg = buildWorkflowNameRegistry([{ id: 'w1', folderId: 'f1', name: 'Same' }])
|
||||
reg.claim('f1', 'Same', 'w1')
|
||||
expect(reg.isTaken('f1', 'Same', 'w1')).toBe(false)
|
||||
expect(reg.isTaken('f1', 'Same', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles multiple holders (legacy duplicates) and partial release', () => {
|
||||
const reg = buildWorkflowNameRegistry([
|
||||
{ id: 'w1', folderId: 'f1', name: 'Dup' },
|
||||
{ id: 'w2', folderId: 'f1', name: 'Dup' },
|
||||
])
|
||||
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(true)
|
||||
reg.claim('f1', 'Other', 'w2')
|
||||
expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,548 @@
|
||||
import { workflow, workflowBlocks, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import {
|
||||
remapConditionIdsInSubBlocks,
|
||||
remapVariableIdsInSubBlocks,
|
||||
remapWorkflowReferencesInSubBlocks,
|
||||
type SubBlockRecord,
|
||||
sanitizeSubBlocksForDuplicate,
|
||||
} from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
deriveForkBlockId,
|
||||
type ForkBlockIdResolver,
|
||||
} from '@/lib/workspaces/fork/remap/block-identity'
|
||||
import {
|
||||
applyDependentOverrides,
|
||||
collectClearedDependents,
|
||||
type NeedsConfigurationField,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
import type {
|
||||
BlockData,
|
||||
BlockState,
|
||||
Loop,
|
||||
Parallel,
|
||||
SubBlockState,
|
||||
WorkflowState,
|
||||
Variable as WorkflowStateVariable,
|
||||
} from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCopyWorkflows')
|
||||
|
||||
type SubBlockTransform = (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord
|
||||
|
||||
interface ResolveForkFolderMappingParams {
|
||||
tx: DbOrTx
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
userId: string
|
||||
now: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the source workspace's folder tree into the target workspace, creating
|
||||
* folders as needed and reusing target folders that already match by name within
|
||||
* the same (mapped) parent. Returns a map from source folder id to target folder
|
||||
* id so copied workflows can be placed in the corresponding folder.
|
||||
*/
|
||||
export async function resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
}: ResolveForkFolderMappingParams): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
|
||||
const sourceFolders = await tx
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(eq(workflowFolder.workspaceId, sourceWorkspaceId), isNull(workflowFolder.archivedAt))
|
||||
)
|
||||
|
||||
if (sourceFolders.length === 0) return map
|
||||
|
||||
const targetFolders = await tx
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(eq(workflowFolder.workspaceId, targetWorkspaceId), isNull(workflowFolder.archivedAt))
|
||||
)
|
||||
|
||||
const targetByKey = new Map<string, string>()
|
||||
for (const folder of targetFolders) {
|
||||
targetByKey.set(`${folder.parentId ?? ''}::${folder.name}`, folder.id)
|
||||
}
|
||||
|
||||
const byId = new Map(sourceFolders.map((folder) => [folder.id, folder]))
|
||||
const ordered: typeof sourceFolders = []
|
||||
const seen = new Set<string>()
|
||||
const visit = (folder: (typeof sourceFolders)[number]) => {
|
||||
if (seen.has(folder.id)) return
|
||||
const parent = folder.parentId ? byId.get(folder.parentId) : undefined
|
||||
if (parent) visit(parent)
|
||||
seen.add(folder.id)
|
||||
ordered.push(folder)
|
||||
}
|
||||
for (const folder of sourceFolders) visit(folder)
|
||||
|
||||
const newFolders: (typeof sourceFolders)[number][] = []
|
||||
for (const folder of ordered) {
|
||||
const mappedParentId = folder.parentId ? (map.get(folder.parentId) ?? null) : null
|
||||
const key = `${mappedParentId ?? ''}::${folder.name}`
|
||||
const existing = targetByKey.get(key)
|
||||
if (existing) {
|
||||
map.set(folder.id, existing)
|
||||
continue
|
||||
}
|
||||
const newFolderId = generateId()
|
||||
map.set(folder.id, newFolderId)
|
||||
targetByKey.set(key, newFolderId)
|
||||
newFolders.push({
|
||||
...folder,
|
||||
id: newFolderId,
|
||||
userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
parentId: mappedParentId,
|
||||
locked: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (newFolders.length > 0) {
|
||||
await tx.insert(workflowFolder).values(newFolders)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
// `\u0000` (a NUL byte) can never appear in a Postgres text column, so it is a
|
||||
// collision-free separator between the folder id and the name.
|
||||
const workflowNameKey = (folderId: string | null, name: string) => `${folderId ?? ''}\u0000${name}`
|
||||
|
||||
/**
|
||||
* In-memory registry of a workspace's active workflow names, keyed by
|
||||
* (folderId, name) - the exact columns of the `workflow_workspace_folder_name_active_unique`
|
||||
* partial index. Lets fork/promote resolve name collisions across many copied workflows
|
||||
* from a single load instead of one `nameTaken` query per workflow inside the (locked)
|
||||
* transaction.
|
||||
*
|
||||
* Correctness is still guaranteed by that DB unique index; this is only a proactive
|
||||
* collision avoider. A stale snapshot (e.g. a concurrent non-fork rename mid-promote) can
|
||||
* therefore only cause a rare, retry-able unique violation - never a duplicate name -
|
||||
* exactly as the prior per-workflow check-then-write already could.
|
||||
*/
|
||||
export interface WorkflowNameRegistry {
|
||||
/** True when (folderId, name) is held by any active workflow other than `excludeWorkflowId`. */
|
||||
isTaken(folderId: string | null, name: string, excludeWorkflowId: string | null): boolean
|
||||
/** Record that `workflowId` now holds (folderId, name), releasing any name it held before. */
|
||||
claim(folderId: string | null, name: string, workflowId: string): void
|
||||
}
|
||||
|
||||
/** Build a {@link WorkflowNameRegistry} from already-loaded rows (pure - unit-testable). */
|
||||
export function buildWorkflowNameRegistry(
|
||||
rows: Array<{ id: string; folderId: string | null; name: string }>
|
||||
): WorkflowNameRegistry {
|
||||
const holdersByKey = new Map<string, Set<string>>()
|
||||
const keyByWorkflow = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
const key = workflowNameKey(row.folderId, row.name)
|
||||
const holders = holdersByKey.get(key)
|
||||
if (holders) holders.add(row.id)
|
||||
else holdersByKey.set(key, new Set([row.id]))
|
||||
keyByWorkflow.set(row.id, key)
|
||||
}
|
||||
|
||||
return {
|
||||
isTaken(folderId, name, excludeWorkflowId) {
|
||||
const holders = holdersByKey.get(workflowNameKey(folderId, name))
|
||||
if (!holders) return false
|
||||
for (const id of holders) if (id !== excludeWorkflowId) return true
|
||||
return false
|
||||
},
|
||||
claim(folderId, name, workflowId) {
|
||||
const newKey = workflowNameKey(folderId, name)
|
||||
const prevKey = keyByWorkflow.get(workflowId)
|
||||
if (prevKey === newKey) return
|
||||
if (prevKey) holdersByKey.get(prevKey)?.delete(workflowId)
|
||||
const holders = holdersByKey.get(newKey)
|
||||
if (holders) holders.add(workflowId)
|
||||
else holdersByKey.set(newKey, new Set([workflowId]))
|
||||
keyByWorkflow.set(workflowId, newKey)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Load every active workflow name in a workspace into a {@link WorkflowNameRegistry}. */
|
||||
export async function loadWorkflowNameRegistry(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<WorkflowNameRegistry> {
|
||||
const rows = await executor
|
||||
.select({ id: workflow.id, folderId: workflow.folderId, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
|
||||
return buildWorkflowNameRegistry(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched read of the current DRAFT subBlocks for a set of (replace) target
|
||||
* workflows, keyed `workflowId -> blockId -> subBlocks`. One query for the whole
|
||||
* promote so the locked apply phase doesn't do N per-workflow loads; called
|
||||
* pre-write so it reflects the target state the user configured before this sync
|
||||
* overwrites it. Promote uses it to detect required dependents the sync left empty
|
||||
* (see {@link collectClearedDependents}); fork-create has no prior target and skips it.
|
||||
*/
|
||||
export async function loadTargetDraftSubBlocks(
|
||||
executor: DbOrTx,
|
||||
workflowIds: string[]
|
||||
): Promise<Map<string, Map<string, SubBlockRecord>>> {
|
||||
const byWorkflow = new Map<string, Map<string, SubBlockRecord>>()
|
||||
if (workflowIds.length === 0) return byWorkflow
|
||||
const rows = await executor
|
||||
.select({
|
||||
workflowId: workflowBlocks.workflowId,
|
||||
blockId: workflowBlocks.id,
|
||||
subBlocks: workflowBlocks.subBlocks,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(inArray(workflowBlocks.workflowId, workflowIds))
|
||||
for (const row of rows) {
|
||||
let blocks = byWorkflow.get(row.workflowId)
|
||||
if (!blocks) {
|
||||
blocks = new Map<string, SubBlockRecord>()
|
||||
byWorkflow.set(row.workflowId, blocks)
|
||||
}
|
||||
blocks.set(row.blockId, (row.subBlocks ?? {}) as SubBlockRecord)
|
||||
}
|
||||
return byWorkflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a non-colliding name for a copied workflow against the preloaded registry, which
|
||||
* mirrors the workspace's (folder, name, not-archived, exclude-self) predicate from one
|
||||
* query instead of one per candidate. Mirrors {@link deduplicateWorkflowName}'s ` (n)`
|
||||
* numbering, but reads from memory so the copy loop issues no per-workflow name queries.
|
||||
*/
|
||||
function resolveTargetWorkflowName(
|
||||
registry: WorkflowNameRegistry,
|
||||
folderId: string | null,
|
||||
name: string,
|
||||
excludeWorkflowId: string | null
|
||||
): string {
|
||||
const taken = (candidate: string) => registry.isTaken(folderId, candidate, excludeWorkflowId)
|
||||
if (!taken(name)) return name
|
||||
for (let i = 2; i < 100; i++) {
|
||||
const candidate = `${name} (${i})`
|
||||
if (!taken(candidate)) return candidate
|
||||
}
|
||||
return `${name} (${generateId().slice(0, 6)})`
|
||||
}
|
||||
|
||||
export interface CopyWorkflowResult {
|
||||
targetWorkflowId: string
|
||||
mode: 'create' | 'replace'
|
||||
name: string
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
/**
|
||||
* `dependsOn` fields (top-level and nested tool-input) a remapped parent left empty
|
||||
* that weren't restored from the target draft - the parent legitimately changed.
|
||||
* Carries `required` per field: promote skips redeploy + gates on required ones and
|
||||
* surfaces optional ones so a cleared filter never broadens behavior silently.
|
||||
*/
|
||||
clearedDependents: NeedsConfigurationField[]
|
||||
/**
|
||||
* Source block id -> assigned target block id, so the caller can persist the
|
||||
* block-identity pairs (see `recordForkBlockPairs`) that keep promotes reversible.
|
||||
*/
|
||||
blockIdMapping: Map<string, string>
|
||||
}
|
||||
|
||||
export interface CopyWorkflowStateParams {
|
||||
tx: DbOrTx
|
||||
targetWorkflowId: string
|
||||
targetWorkspaceId: string
|
||||
userId: string
|
||||
mode: 'create' | 'replace'
|
||||
now: Date
|
||||
/** Source workflow's deployed state (the only thing fork/promote copies). */
|
||||
sourceState: WorkflowState
|
||||
/** Source workflow metadata for naming, folder placement, and sort order. */
|
||||
sourceMeta: {
|
||||
name: string
|
||||
description: string | null
|
||||
folderId: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
/** source workflow id -> target workflow id, for `workflow-selector` references */
|
||||
workflowIdMap: Map<string, string>
|
||||
/** source folder id -> target folder id */
|
||||
folderIdMap: Map<string, string>
|
||||
/** Optional resource-reference remap applied to every block's subBlocks. */
|
||||
transformSubBlocks?: SubBlockTransform
|
||||
/**
|
||||
* The target workflow's current draft subBlocks (block id -> subBlocks), for
|
||||
* `replace` mode only. When present, required dependents that the sync left empty
|
||||
* (the parent change cleared and the stored mapping didn't fill) are reported in
|
||||
* {@link CopyWorkflowResult.needsConfiguration}.
|
||||
*/
|
||||
targetCurrentBlocks?: Map<string, SubBlockRecord>
|
||||
/**
|
||||
* Per-block (block id -> subBlock key -> value) stored dependent values applied last,
|
||||
* after the reference transform cleared the source's, so the stored mapping is the sole
|
||||
* source of truth for what each dependent selector resolves to.
|
||||
*/
|
||||
dependentOverrides?: Map<string, Map<string, string>>
|
||||
/**
|
||||
* Preloaded name registry so name-collision resolution reads from memory instead of one
|
||||
* query per workflow inside the tx. Build once per copy loop via {@link loadWorkflowNameRegistry}.
|
||||
*/
|
||||
nameRegistry: WorkflowNameRegistry
|
||||
/**
|
||||
* Resolve each source block to its target block id, reusing the persisted counterpart
|
||||
* when one exists so a push keeps the parent's original block ids (and webhook URLs)
|
||||
* instead of re-deriving them (see {@link buildForkBlockIdResolver}). Omitted on fork
|
||||
* creation, where every id is derived fresh.
|
||||
*/
|
||||
resolveBlockId?: ForkBlockIdResolver
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a source workflow's deployed `WorkflowState` into a target workflow,
|
||||
* assigning deterministic block ids (so trigger webhook URLs and external block
|
||||
* references stay stable across promotes) and applying the resource-reference
|
||||
* transform. Writes the remapped state to the target's draft via
|
||||
* `saveWorkflowToNormalizedTables`. In `create` mode a new workflow row is
|
||||
* inserted (undeployed); in `replace` mode the existing target row is kept and
|
||||
* its draft is overwritten. Deploying the target (and capturing the rollback
|
||||
* point) is the caller's responsibility.
|
||||
*/
|
||||
export async function copyWorkflowStateIntoTarget(
|
||||
params: CopyWorkflowStateParams
|
||||
): Promise<CopyWorkflowResult> {
|
||||
const {
|
||||
tx,
|
||||
targetWorkflowId,
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
mode,
|
||||
now,
|
||||
sourceState,
|
||||
sourceMeta,
|
||||
workflowIdMap,
|
||||
folderIdMap,
|
||||
transformSubBlocks,
|
||||
targetCurrentBlocks,
|
||||
dependentOverrides,
|
||||
nameRegistry,
|
||||
resolveBlockId,
|
||||
requestId = 'unknown',
|
||||
} = params
|
||||
|
||||
const targetFolderId = sourceMeta.folderId ? (folderIdMap.get(sourceMeta.folderId) ?? null) : null
|
||||
|
||||
const varIdMapping = new Map<string, string>()
|
||||
const remappedVariables: Record<string, WorkflowStateVariable> = {}
|
||||
for (const [oldVarId, variable] of Object.entries(sourceState.variables ?? {})) {
|
||||
const newVarId = generateId()
|
||||
varIdMapping.set(oldVarId, newVarId)
|
||||
remappedVariables[newVarId] = { ...variable, id: newVarId }
|
||||
}
|
||||
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
for (const oldBlockId of Object.keys(sourceState.blocks)) {
|
||||
blockIdMapping.set(
|
||||
oldBlockId,
|
||||
resolveBlockId
|
||||
? resolveBlockId(targetWorkflowId, oldBlockId)
|
||||
: deriveForkBlockId(targetWorkflowId, oldBlockId)
|
||||
)
|
||||
}
|
||||
|
||||
const newBlocks: Record<string, BlockState> = {}
|
||||
const clearedDependents: NeedsConfigurationField[] = []
|
||||
for (const [oldBlockId, block] of Object.entries(sourceState.blocks)) {
|
||||
const newBlockId = blockIdMapping.get(oldBlockId)!
|
||||
|
||||
let updatedData = block.data
|
||||
if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) {
|
||||
const dataObj = block.data as Record<string, unknown>
|
||||
if (typeof dataObj.parentId === 'string' && blockIdMapping.has(dataObj.parentId)) {
|
||||
updatedData = {
|
||||
...dataObj,
|
||||
parentId: blockIdMapping.get(dataObj.parentId)!,
|
||||
extent: 'parent',
|
||||
} as BlockData
|
||||
}
|
||||
}
|
||||
|
||||
// double-cast-allowed: SubBlockState is structurally a SubBlockRecord entry but lacks the open index signature SubBlockRecord declares
|
||||
const sourceSubBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
|
||||
const sanitizedSource = sanitizeSubBlocksForDuplicate(sourceSubBlocks)
|
||||
let subBlocks: SubBlockRecord = sanitizedSource
|
||||
if (transformSubBlocks) {
|
||||
subBlocks = transformSubBlocks(subBlocks, block.type)
|
||||
}
|
||||
if (varIdMapping.size > 0) {
|
||||
subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping)
|
||||
}
|
||||
// Cross-workspace copy: clear references to workflows that weren't copied
|
||||
// rather than leave them pointing at the source workspace.
|
||||
subBlocks = remapWorkflowReferencesInSubBlocks(subBlocks, workflowIdMap, {
|
||||
clearUnmapped: true,
|
||||
})
|
||||
subBlocks = remapConditionIdsInSubBlocks(subBlocks, oldBlockId, newBlockId) as SubBlockRecord
|
||||
|
||||
// Apply the stored dependent values for this block (the modal's mapping). The reference
|
||||
// transform already cleared the source's dependent values when their parent was remapped,
|
||||
// so the stored mapping is the SOLE source of truth - no implicit "preserve the target's
|
||||
// value" path. Allowlisted (top-level + nested tool params) inside applyDependentOverrides
|
||||
// so a crafted value can't touch a parent/credential field or inject a bogus subblock.
|
||||
const targetCurrent = targetCurrentBlocks?.get(newBlockId)
|
||||
const blockOverrides = dependentOverrides?.get(newBlockId)
|
||||
if (blockOverrides && blockOverrides.size > 0) {
|
||||
subBlocks = applyDependentOverrides(subBlocks, block.type, blockOverrides)
|
||||
}
|
||||
|
||||
// Dependents the TARGET had configured that the parent change cleared and nothing
|
||||
// restored: the target must re-pick required ones (promote skips this workflow's
|
||||
// redeploy) and is told about optional ones. Keyed on the target draft so a field the
|
||||
// source carried but the target never set isn't flagged.
|
||||
if (mode === 'replace' && targetCurrent) {
|
||||
clearedDependents.push(
|
||||
...collectClearedDependents(block.type, newBlockId, block.name, targetCurrent, subBlocks)
|
||||
)
|
||||
}
|
||||
|
||||
newBlocks[newBlockId] = {
|
||||
...block,
|
||||
id: newBlockId,
|
||||
// double-cast-allowed: remap helpers return SubBlockRecord; the entries retain the SubBlockState shape this block requires
|
||||
subBlocks: subBlocks as unknown as Record<string, SubBlockState>,
|
||||
data: updatedData,
|
||||
}
|
||||
}
|
||||
|
||||
const newEdges = sourceState.edges.flatMap((edge) => {
|
||||
const newSource = blockIdMapping.get(edge.source)
|
||||
const newTarget = blockIdMapping.get(edge.target)
|
||||
if (!newSource || !newTarget) {
|
||||
logger.warn(`[${requestId}] Skipping edge with unmapped block reference during fork copy`, {
|
||||
edgeId: edge.id,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const newSourceHandle = edge.sourceHandle
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
|
||||
: edge.sourceHandle
|
||||
return [
|
||||
{
|
||||
...edge,
|
||||
id: generateId(),
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
sourceHandle: newSourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const newLoops: Record<string, Loop> = {}
|
||||
for (const [oldId, loop] of Object.entries(sourceState.loops ?? {})) {
|
||||
const newId = blockIdMapping.get(oldId) ?? oldId
|
||||
newLoops[newId] = {
|
||||
...loop,
|
||||
id: newId,
|
||||
nodes: loop.nodes.flatMap((nodeId) => {
|
||||
const mapped = blockIdMapping.get(nodeId)
|
||||
return mapped ? [mapped] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const newParallels: Record<string, Parallel> = {}
|
||||
for (const [oldId, parallel] of Object.entries(sourceState.parallels ?? {})) {
|
||||
const newId = blockIdMapping.get(oldId) ?? oldId
|
||||
newParallels[newId] = {
|
||||
...parallel,
|
||||
id: newId,
|
||||
nodes: parallel.nodes.flatMap((nodeId) => {
|
||||
const mapped = blockIdMapping.get(nodeId)
|
||||
return mapped ? [mapped] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedName = resolveTargetWorkflowName(
|
||||
nameRegistry,
|
||||
targetFolderId,
|
||||
sourceMeta.name,
|
||||
mode === 'replace' ? targetWorkflowId : null
|
||||
)
|
||||
// Claim the resolved name so the next workflow in this copy loop sees it taken. The DB
|
||||
// write below uses the same (folderId, name), so the registry stays consistent with it.
|
||||
nameRegistry.claim(targetFolderId, resolvedName, targetWorkflowId)
|
||||
|
||||
if (mode === 'create') {
|
||||
await tx.insert(workflow).values({
|
||||
id: targetWorkflowId,
|
||||
userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
folderId: targetFolderId,
|
||||
sortOrder: sourceMeta.sortOrder,
|
||||
name: resolvedName,
|
||||
description: sourceMeta.description,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
locked: false,
|
||||
variables: remappedVariables,
|
||||
})
|
||||
} else {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
name: resolvedName,
|
||||
description: sourceMeta.description,
|
||||
folderId: targetFolderId,
|
||||
variables: remappedVariables,
|
||||
lastSynced: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflow.id, targetWorkflowId))
|
||||
}
|
||||
|
||||
const remappedState: WorkflowState = {
|
||||
blocks: newBlocks,
|
||||
edges: newEdges,
|
||||
loops: newLoops,
|
||||
parallels: newParallels,
|
||||
variables: remappedVariables,
|
||||
}
|
||||
const saved = await saveWorkflowToNormalizedTables(targetWorkflowId, remappedState, tx)
|
||||
if (!saved.success) {
|
||||
throw new Error(`Failed to write forked workflow ${targetWorkflowId}: ${saved.error}`)
|
||||
}
|
||||
|
||||
return {
|
||||
targetWorkflowId,
|
||||
mode,
|
||||
name: resolvedName,
|
||||
blocksCount: Object.keys(newBlocks).length,
|
||||
edgesCount: newEdges.length,
|
||||
subflowsCount: Object.keys(newLoops).length + Object.keys(newParallels).length,
|
||||
clearedDependents,
|
||||
blockIdMapping,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { db, runOutsideTransactionContext } from '@sim/db'
|
||||
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
||||
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkspaceForkDeployBridge')
|
||||
|
||||
/**
|
||||
* Hard ceiling on how many deployed workflows one fork/promote loads into memory at
|
||||
* once (each as a full `WorkflowState`). There is no per-workspace workflow cap in
|
||||
* the product, so this is the safety valve: real workspaces hold tens to low
|
||||
* hundreds, making this ~5-10x headroom that never blocks legitimate use, it sits
|
||||
* below the fork feature's other item caps (resource selection 2000, mapping
|
||||
* entries 5000 - both lighter-weight than full states), and it bounds a pathological
|
||||
* workspace to a few hundred MB of transient state instead of an unbounded load.
|
||||
*/
|
||||
export const MAX_FORK_DEPLOYED_WORKFLOWS = 1000
|
||||
|
||||
export interface DeployedWorkflowSummary {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
folderId: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflows in a workspace that are deployed and not archived - the only ones that
|
||||
* fork/promote. Requires an actually-active deployment version, not just the
|
||||
* `isDeployed` flag: a workflow flagged deployed with no active version (a "ghost"
|
||||
* left by an inconsistent state) has nothing to copy, so excluding it here keeps the
|
||||
* diff/plan counts aligned with what apply actually writes instead of over-reporting
|
||||
* then silently skipping it. Correlated `exists` (not a join) so a workflow is never
|
||||
* double-listed if more than one active version row ever exists.
|
||||
*/
|
||||
export async function listDeployedWorkflows(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<DeployedWorkflowSummary[]> {
|
||||
return executor
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
eq(workflow.isDeployed, true),
|
||||
isNull(workflow.archivedAt),
|
||||
exists(
|
||||
db
|
||||
.select({ one: sql`1` })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** The active deployment version number for a workflow, or null when it has none. */
|
||||
export async function getActiveDeploymentVersionNumber(
|
||||
executor: DbOrTx,
|
||||
workflowId: string
|
||||
): Promise<number | null> {
|
||||
const [row] = await executor
|
||||
.select({ version: workflowDeploymentVersion.version })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row?.version ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched {@link getActiveDeploymentVersionNumber}: the active deployed version per
|
||||
* workflow id, so promote's apply phase resolves every prior version in one query
|
||||
* instead of N round-trips inside the (locked) transaction. Workflows with no active
|
||||
* version are absent from the map.
|
||||
*/
|
||||
export async function getActiveDeploymentVersionNumbers(
|
||||
executor: DbOrTx,
|
||||
workflowIds: string[]
|
||||
): Promise<Map<string, number>> {
|
||||
if (workflowIds.length === 0) return new Map()
|
||||
const rows = await executor
|
||||
.select({
|
||||
workflowId: workflowDeploymentVersion.workflowId,
|
||||
version: workflowDeploymentVersion.version,
|
||||
})
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
inArray(workflowDeploymentVersion.workflowId, workflowIds),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
return new Map(rows.map((row) => [row.workflowId, row.version]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a source workspace's deployed workflows and each one's active deployed state
|
||||
* on the global pool. Fork/promote callers MUST run this BEFORE opening their
|
||||
* transaction: doing these heavy per-workflow reads inside the tx checks out a
|
||||
* SECOND pooled connection while the tx holds the first, which can deadlock the
|
||||
* pool at saturation (primary pool max is 15). The source is read-only for the
|
||||
* operation, so a pre-transaction snapshot is the value that gets force-pushed.
|
||||
*
|
||||
* Holds every source state in memory at once (bounded by the workspace's deployed
|
||||
* workflow count) - the apply step needs each state to write its target inside the
|
||||
* single atomic transaction, so it cannot stream them one at a time.
|
||||
*/
|
||||
export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promise<{
|
||||
deployedWorkflows: DeployedWorkflowSummary[]
|
||||
sourceStates: Map<string, WorkflowState>
|
||||
}> {
|
||||
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
|
||||
// Fail fast on the cheap count before loading any heavy state into memory.
|
||||
if (deployedWorkflows.length > MAX_FORK_DEPLOYED_WORKFLOWS) {
|
||||
throw new ForkError(
|
||||
`This workspace has ${deployedWorkflows.length} deployed workflows, which exceeds the fork/sync limit of ${MAX_FORK_DEPLOYED_WORKFLOWS}.`,
|
||||
400
|
||||
)
|
||||
}
|
||||
// Read states in bounded-concurrency batches instead of one serial await per workflow:
|
||||
// serial cost is O(workflows) round trips (this also runs on the diff preview, refetched
|
||||
// while the sync modal is open). The cap keeps concurrent global-pool checkouts well
|
||||
// under the pool max even at the workflow ceiling, and this runs BEFORE any transaction.
|
||||
const sourceStates = new Map<string, WorkflowState>()
|
||||
const READ_CONCURRENCY = 5
|
||||
for (let i = 0; i < deployedWorkflows.length; i += READ_CONCURRENCY) {
|
||||
const batch = deployedWorkflows.slice(i, i + READ_CONCURRENCY)
|
||||
const states = await Promise.all(batch.map((wf) => readDeployedState(wf.id, sourceWorkspaceId)))
|
||||
batch.forEach((wf, index) => {
|
||||
const state = states[index]
|
||||
if (state) sourceStates.set(wf.id, state)
|
||||
})
|
||||
}
|
||||
return { deployedWorkflows, sourceStates }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a workflow's active deployed state as a `WorkflowState`. Returns null ONLY
|
||||
* when the workflow genuinely has no active deployment (a legitimate skip); real
|
||||
* DB/migration errors propagate so the caller fails loudly instead of silently
|
||||
* dropping the workflow from the fork/promote. Block migrations (credential remap
|
||||
* to current ids) are applied so copied references reflect current resources.
|
||||
*/
|
||||
export async function readDeployedState(
|
||||
workflowId: string,
|
||||
workspaceId: string
|
||||
): Promise<WorkflowState | null> {
|
||||
// This reads the (unchanged) SOURCE workspace on the global pool. Callers like
|
||||
// promote run it inside their transaction, so escape the tx context: the read
|
||||
// must not join the promote's transaction (and the tripwire forbids global-pool
|
||||
// queries inside a tx). Outside a transaction this is a no-op.
|
||||
return runOutsideTransactionContext(async () => {
|
||||
const version = await getActiveDeploymentVersionNumber(db, workflowId)
|
||||
if (version == null) {
|
||||
logger.warn('No active deployment for workflow during fork/promote', { workflowId })
|
||||
return null
|
||||
}
|
||||
const data = await loadDeployedWorkflowState(workflowId, workspaceId)
|
||||
return {
|
||||
blocks: data.blocks,
|
||||
edges: data.edges,
|
||||
loops: data.loops,
|
||||
parallels: data.parallels,
|
||||
variables: (data.variables ?? {}) as Record<string, Variable>,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PermissionType } from '@sim/platform-authz/workspace'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { Workspace } from '@/lib/api/contracts/workspaces'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
|
||||
import { runDetached } from '@/lib/core/utils/background'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
finishBackgroundWork,
|
||||
startBackgroundWork,
|
||||
} from '@/lib/workspaces/fork/background-work/store'
|
||||
import {
|
||||
type ForkContentCopyPayload,
|
||||
runForkContentCopy,
|
||||
} from '@/lib/workspaces/fork/copy/content-copy-runner'
|
||||
import { planForkFileCopies } from '@/lib/workspaces/fork/copy/copy-files'
|
||||
import {
|
||||
copyForkResourceContainers,
|
||||
type ForkCopiedResourceNames,
|
||||
} from '@/lib/workspaces/fork/copy/copy-resources'
|
||||
import {
|
||||
copyWorkflowStateIntoTarget,
|
||||
loadWorkflowNameRegistry,
|
||||
resolveForkFolderMapping,
|
||||
} from '@/lib/workspaces/fork/copy/copy-workflows'
|
||||
import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
|
||||
import { setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
|
||||
import {
|
||||
type ForkBlockPair,
|
||||
reconcileForkBlockPairs,
|
||||
toForkBlockPairs,
|
||||
} from '@/lib/workspaces/fork/mapping/block-map-store'
|
||||
import {
|
||||
type ForkMappingUpsert,
|
||||
type ForkResourceType,
|
||||
seedEdgeMappings,
|
||||
} from '@/lib/workspaces/fork/mapping/mapping-store'
|
||||
import { createForkBootstrapTransform } from '@/lib/workspaces/fork/remap/fork-bootstrap'
|
||||
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
|
||||
import type { WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import type { WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
|
||||
import { WORKSPACE_MODE } from '@/lib/workspaces/policy'
|
||||
|
||||
const logger = createLogger('WorkspaceForkCreate')
|
||||
|
||||
/** Source resource ids the user selected to copy into the child, by kind. */
|
||||
export interface ForkResourceSelection {
|
||||
files: string[]
|
||||
tables: string[]
|
||||
knowledgeBases: string[]
|
||||
customTools: string[]
|
||||
skills: string[]
|
||||
mcpServers: string[]
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: ForkResourceSelection = {
|
||||
files: [],
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
}
|
||||
|
||||
export interface CreateForkParams {
|
||||
source: WorkspaceWithOwner
|
||||
policy: WorkspaceCreationPolicy
|
||||
userId: string
|
||||
/** Display name of the user forking, recorded on the activity entry. */
|
||||
actorName?: string
|
||||
name?: string
|
||||
selection?: ForkResourceSelection
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface CreateForkResult {
|
||||
/** Full child workspace row so callers can merge it into the workspace-list cache. */
|
||||
workspace: Workspace
|
||||
workflowsCopied: number
|
||||
}
|
||||
|
||||
const FORK_KIND_TO_RESOURCE_TYPE: Partial<Record<ForkRemapKind, ForkResourceType>> = {
|
||||
'custom-tool': 'custom_tool',
|
||||
skill: 'skill',
|
||||
'mcp-server': 'mcp_server',
|
||||
table: 'table',
|
||||
'knowledge-base': 'knowledge_base',
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fork of `source`: a new child workspace that copies the parent's
|
||||
* **deployed** workflows (left undeployed in the child), snapshots the parent's
|
||||
* member list, copies the user-selected resources (files, tables, knowledge bases,
|
||||
* custom tools, skills, MCP server configs) with fresh ids, and records the
|
||||
* source→child identity for each. Workflow references to copied resources are
|
||||
* rewritten to the child ids; references to resources that were not copied (and
|
||||
* all credential references) are cleared; env-var references are preserved.
|
||||
*/
|
||||
export async function createFork(params: CreateForkParams): Promise<CreateForkResult> {
|
||||
const { source, policy, userId, requestId = 'unknown' } = params
|
||||
const selection = params.selection ?? EMPTY_SELECTION
|
||||
const childName = params.name?.trim() || `${source.name} (fork)`
|
||||
|
||||
// Read the source's deployed workflows + states BEFORE the transaction so these
|
||||
// global-pool reads don't check out a second pooled connection from inside the
|
||||
// fork tx (which can deadlock the pool at saturation).
|
||||
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(source.id)
|
||||
|
||||
const forkedWorkflowNames: string[] = []
|
||||
let forkedResourceNames: ForkCopiedResourceNames = {
|
||||
tables: [],
|
||||
knowledgeBases: [],
|
||||
customTools: [],
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
}
|
||||
const { result, blobTasks, contentPlan } = await db.transaction(async (tx) => {
|
||||
await setForkLockTimeout(tx)
|
||||
const now = new Date()
|
||||
const childWorkspaceId = generateId()
|
||||
|
||||
await tx.insert(workspace).values({
|
||||
id: childWorkspaceId,
|
||||
name: childName,
|
||||
ownerId: userId,
|
||||
organizationId: policy.organizationId,
|
||||
workspaceMode: policy.workspaceMode,
|
||||
billedAccountUserId: policy.billedAccountUserId,
|
||||
allowPersonalApiKeys: true,
|
||||
forkedFromWorkspaceId: source.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const sourcePermissions = await tx
|
||||
.select({ userId: permissions.userId, permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, source.id)))
|
||||
|
||||
const permissionByUser = new Map<string, PermissionType>()
|
||||
for (const row of sourcePermissions) {
|
||||
permissionByUser.set(row.userId, row.permissionType)
|
||||
}
|
||||
permissionByUser.set(userId, 'admin')
|
||||
if (
|
||||
policy.workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
|
||||
policy.billedAccountUserId &&
|
||||
policy.billedAccountUserId !== userId
|
||||
) {
|
||||
permissionByUser.set(policy.billedAccountUserId, 'admin')
|
||||
}
|
||||
|
||||
await tx.insert(permissions).values(
|
||||
Array.from(permissionByUser.entries()).map(([memberUserId, permissionType]) => ({
|
||||
id: generateId(),
|
||||
entityType: 'workspace' as const,
|
||||
entityId: childWorkspaceId,
|
||||
userId: memberUserId,
|
||||
permissionType,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
|
||||
const workflowIdMap = new Map<string, string>()
|
||||
for (const wf of deployedWorkflows) workflowIdMap.set(wf.id, generateId())
|
||||
|
||||
const fileResult = await planForkFileCopies({
|
||||
tx,
|
||||
sourceWorkspaceId: source.id,
|
||||
childWorkspaceId,
|
||||
userId,
|
||||
fileIds: selection.files,
|
||||
now,
|
||||
})
|
||||
|
||||
const resourceResult = await copyForkResourceContainers({
|
||||
tx,
|
||||
sourceWorkspaceId: source.id,
|
||||
childWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
selection: {
|
||||
customTools: selection.customTools,
|
||||
skills: selection.skills,
|
||||
mcpServers: selection.mcpServers,
|
||||
tables: selection.tables,
|
||||
knowledgeBases: selection.knowledgeBases,
|
||||
},
|
||||
workflowIdMap,
|
||||
})
|
||||
forkedResourceNames = resourceResult.names
|
||||
|
||||
const resolveCopied = (kind: ForkRemapKind, sourceId: string): string | null => {
|
||||
if (kind === 'file') return fileResult.keyMap.get(sourceId) ?? null
|
||||
const resourceType = FORK_KIND_TO_RESOURCE_TYPE[kind]
|
||||
if (!resourceType) return null
|
||||
return resourceResult.idMap.get(resourceType)?.get(sourceId) ?? null
|
||||
}
|
||||
const transform = createForkBootstrapTransform(resolveCopied)
|
||||
|
||||
const folderIdMap = await resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId: source.id,
|
||||
targetWorkspaceId: childWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
})
|
||||
|
||||
// The child is brand new, so this loads an empty registry; name collisions can only
|
||||
// arise among the copied workflows themselves, which the in-loop claims resolve.
|
||||
const nameRegistry = await loadWorkflowNameRegistry(tx, childWorkspaceId)
|
||||
|
||||
let workflowsCopied = 0
|
||||
// Seed the block-identity map (parent block -> derived child block) so a later push of
|
||||
// this fork resolves each child block back to the parent's ORIGINAL id instead of
|
||||
// re-deriving and re-keying the parent's webhook URLs.
|
||||
const blockPairs: ForkBlockPair[] = []
|
||||
const sourceWorkflowIds: string[] = []
|
||||
for (const wf of deployedWorkflows) {
|
||||
const sourceState = sourceStates.get(wf.id)
|
||||
if (!sourceState) continue
|
||||
const targetWorkflowId = workflowIdMap.get(wf.id)!
|
||||
const copyResult = await copyWorkflowStateIntoTarget({
|
||||
tx,
|
||||
targetWorkflowId,
|
||||
targetWorkspaceId: childWorkspaceId,
|
||||
userId,
|
||||
mode: 'create',
|
||||
now,
|
||||
sourceState,
|
||||
sourceMeta: {
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
folderId: wf.folderId,
|
||||
sortOrder: wf.sortOrder,
|
||||
},
|
||||
workflowIdMap,
|
||||
folderIdMap,
|
||||
transformSubBlocks: transform,
|
||||
nameRegistry,
|
||||
requestId,
|
||||
})
|
||||
// Creation copies parent -> child, so the source side is the parent.
|
||||
blockPairs.push(...toForkBlockPairs(copyResult.blockIdMapping, true, wf.id, targetWorkflowId))
|
||||
sourceWorkflowIds.push(wf.id)
|
||||
workflowsCopied += 1
|
||||
forkedWorkflowNames.push(wf.name)
|
||||
}
|
||||
await reconcileForkBlockPairs(tx, childWorkspaceId, true, sourceWorkflowIds, blockPairs)
|
||||
|
||||
// A fork carries only DEPLOYED workflows. When the source has none (e.g. it was
|
||||
// itself just forked and never redeployed), seed a default workflow so the child
|
||||
// is a usable workspace rather than a blank one with no workflow at all - the same
|
||||
// starter "New workspace" creates. Any copied resources still land alongside it.
|
||||
if (workflowsCopied === 0) {
|
||||
const defaultWorkflowId = generateId()
|
||||
await tx.insert(workflow).values({
|
||||
id: defaultWorkflowId,
|
||||
userId,
|
||||
workspaceId: childWorkspaceId,
|
||||
folderId: null,
|
||||
name: 'default-agent',
|
||||
description: 'Your first workflow - start building here!',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
const { workflowState } = buildDefaultWorkflowArtifacts()
|
||||
await saveWorkflowToNormalizedTables(defaultWorkflowId, workflowState, tx)
|
||||
}
|
||||
|
||||
const seedEntries: ForkMappingUpsert[] = []
|
||||
for (const [sourceWorkflowId, childWorkflowId] of workflowIdMap.entries()) {
|
||||
seedEntries.push({
|
||||
resourceType: 'workflow',
|
||||
parentResourceId: sourceWorkflowId,
|
||||
childResourceId: childWorkflowId,
|
||||
})
|
||||
}
|
||||
seedEntries.push(...resourceResult.mappingEntries)
|
||||
await seedEdgeMappings(tx, childWorkspaceId, userId, seedEntries)
|
||||
|
||||
logger.info(`[${requestId}] Created fork ${childWorkspaceId} from ${source.id}`, {
|
||||
workflowsCopied,
|
||||
mappingsSeeded: seedEntries.length,
|
||||
})
|
||||
|
||||
return {
|
||||
result: {
|
||||
workspace: {
|
||||
id: childWorkspaceId,
|
||||
name: childName,
|
||||
ownerId: userId,
|
||||
organizationId: policy.organizationId,
|
||||
workspaceMode: policy.workspaceMode,
|
||||
billedAccountUserId: policy.billedAccountUserId,
|
||||
allowPersonalApiKeys: true,
|
||||
forkedFromWorkspaceId: source.id,
|
||||
},
|
||||
workflowsCopied,
|
||||
},
|
||||
blobTasks: fileResult.blobTasks,
|
||||
contentPlan: resourceResult.contentPlan,
|
||||
}
|
||||
})
|
||||
|
||||
// Bulk content (table rows, KB documents + embeddings) and file blobs are copied
|
||||
// AFTER the fork commits, in the background, so the fork request returns as soon
|
||||
// as the workflows exist and is never blocked on (or timed out by) heavy I/O.
|
||||
// Trigger.dev runs it out-of-process (surviving deploys); without it, runDetached
|
||||
// runs it inline best-effort. Both are batched/bounded internally.
|
||||
const hasContent =
|
||||
contentPlan.tables.length > 0 || contentPlan.knowledgeBases.length > 0 || blobTasks.length > 0
|
||||
|
||||
// Record a durable job for EVERY fork (the fork already committed), scoped to the
|
||||
// SOURCE workspace - that's where the fork was initiated and where its Activity tab
|
||||
// lives, so the record survives a reload of the fork modal. When there is heavy
|
||||
// content to copy in the background the row stays `processing` until the runner
|
||||
// finishes it (merging in copied/failed); otherwise the fork is already complete.
|
||||
const forkedName = result.workspace.name
|
||||
// The fork already committed; failing to record the tracking row must not turn it into
|
||||
// a 500. Log and continue without a status row - the background content copy below still
|
||||
// runs (its runner no-ops the status update when statusId is absent).
|
||||
let statusId: string | undefined
|
||||
try {
|
||||
statusId = await startBackgroundWork(db, {
|
||||
workspaceId: source.id,
|
||||
kind: 'fork_content_copy',
|
||||
// Append-only: each fork is a distinct entry in the source workspace's fork history.
|
||||
supersede: false,
|
||||
message: hasContent ? `Copying resources to "${forkedName}"` : `Forked into "${forkedName}"`,
|
||||
metadata: {
|
||||
childWorkspaceId: result.workspace.id,
|
||||
childWorkspaceName: forkedName,
|
||||
actorName: params.actorName,
|
||||
workflowsCopied: result.workflowsCopied,
|
||||
tables: contentPlan.tables.length,
|
||||
knowledgeBases: contentPlan.knowledgeBases.length,
|
||||
files: blobTasks.length,
|
||||
workflowNames: forkedWorkflowNames,
|
||||
tableNames: forkedResourceNames.tables,
|
||||
knowledgeBaseNames: forkedResourceNames.knowledgeBases,
|
||||
fileNames: blobTasks.map((task) => task.fileName),
|
||||
customToolNames: forkedResourceNames.customTools,
|
||||
skillNames: forkedResourceNames.skills,
|
||||
mcpServerNames: forkedResourceNames.mcpServers,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to record fork background-work status`, {
|
||||
childWorkspaceId: result.workspace.id,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
if (statusId) {
|
||||
await finishBackgroundWork(db, statusId, {
|
||||
status: 'completed',
|
||||
message: `Forked into "${forkedName}"`,
|
||||
metadata: { copied: 0, failed: 0 },
|
||||
}).catch(() => {})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const payload: ForkContentCopyPayload = { contentPlan, blobTasks, statusId, requestId }
|
||||
try {
|
||||
if (isTriggerDevEnabled) {
|
||||
const [{ forkContentCopyTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
|
||||
import('@/background/fork-content-copy'),
|
||||
import('@trigger.dev/sdk'),
|
||||
import('@/lib/core/async-jobs/region'),
|
||||
])
|
||||
await tasks.trigger<typeof forkContentCopyTask>('fork-content-copy', payload, {
|
||||
region: await resolveTriggerRegion(),
|
||||
})
|
||||
} else {
|
||||
runDetached('fork-content-copy', () => runForkContentCopy(payload))
|
||||
}
|
||||
} catch (error) {
|
||||
// The fork itself succeeded; only scheduling the background copy failed. Surface
|
||||
// it on the status row instead of failing the (committed) fork response.
|
||||
logger.error(`[${requestId}] Failed to schedule fork content copy`, {
|
||||
childWorkspaceId: result.workspace.id,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
if (statusId) {
|
||||
await finishBackgroundWork(db, statusId, {
|
||||
status: 'failed',
|
||||
error: getErrorMessage(error, 'Could not start the background copy'),
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
||||
import { isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { HttpError } from '@/lib/core/utils/http-error'
|
||||
import { type ForkEdge, resolveForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
|
||||
import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
|
||||
|
||||
/** Direction of a promote, relative to the workspace the caller is acting from. */
|
||||
export type PromoteDirection = 'push' | 'pull'
|
||||
|
||||
/**
|
||||
* Enterprise-only gate shared by every fork/promote route. On Sim Cloud the gate
|
||||
* is the Enterprise plan; on self-hosted it's `FORKING_ENABLED`, which 404s when
|
||||
* unset so a newer image doesn't silently expose forking. Mirrors the data-drains
|
||||
* gate - this repo gates EE features by plan + env flag, not by directory.
|
||||
*/
|
||||
async function assertForkingEnabled(organizationId: string | null): Promise<void> {
|
||||
if (!isBillingEnabled && !isForkingEnabled) {
|
||||
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
|
||||
}
|
||||
if (isBillingEnabled) {
|
||||
const hasEnterprise = organizationId
|
||||
? await isOrganizationOnEnterprisePlan(organizationId)
|
||||
: false
|
||||
if (!hasEnterprise) {
|
||||
throw new ForkError('Workspace forking is available on Enterprise plans only', 403)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain error for fork/promote operations. Carries a concrete `statusCode` so
|
||||
* `withRouteHandler` maps it to the right HTTP status and forwards the
|
||||
* client-safe `message`.
|
||||
*/
|
||||
export class ForkError extends HttpError {
|
||||
readonly statusCode: number
|
||||
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message)
|
||||
this.name = 'ForkError'
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
async function requireWorkspace(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<{ workspace: WorkspaceWithOwner; canAdmin: boolean }> {
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
if (!access.exists || !access.workspace) {
|
||||
throw new ForkError('Workspace not found', 404)
|
||||
}
|
||||
await assertForkingEnabled(access.workspace.organizationId)
|
||||
return { workspace: access.workspace, canAdmin: access.canAdmin }
|
||||
}
|
||||
|
||||
/** Require admin access; returns the (active) workspace. */
|
||||
export async function assertWorkspaceAdminAccess(
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceWithOwner> {
|
||||
const { workspace, canAdmin } = await requireWorkspace(workspaceId, userId)
|
||||
if (!canAdmin) {
|
||||
throw new ForkError('Admin access is required for this workspace', 403)
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
export interface ForkAuthorization {
|
||||
source: WorkspaceWithOwner
|
||||
policy: WorkspaceCreationPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize forking `sourceWorkspaceId`: the caller needs admin access to the
|
||||
* source (a fork copies its deployed workflows and resources en masse) and must
|
||||
* pass the workspace-creation policy for the parent's org (the child inherits the
|
||||
* parent's org/mode; plan caps apply). Org owners/admins derive workspace admin.
|
||||
*
|
||||
* `pinOrganization` makes the child ALWAYS land in the source's org - including a
|
||||
* personal source (org `null`) - rather than the acting user's membership org,
|
||||
* which the policy would otherwise fall back to when the source is personal.
|
||||
*/
|
||||
export async function assertCanFork(
|
||||
sourceWorkspaceId: string,
|
||||
userId: string
|
||||
): Promise<ForkAuthorization> {
|
||||
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
|
||||
const policy = await getWorkspaceCreationPolicy({
|
||||
userId,
|
||||
activeOrganizationId: source.organizationId,
|
||||
pinOrganization: true,
|
||||
})
|
||||
if (!policy.canCreate) {
|
||||
throw new ForkError(
|
||||
policy.reason ?? 'You cannot create another workspace on your current plan',
|
||||
policy.status >= 400 ? policy.status : 403
|
||||
)
|
||||
}
|
||||
return { source, policy }
|
||||
}
|
||||
|
||||
export interface PromoteAuthorization {
|
||||
edge: ForkEdge
|
||||
source: WorkspaceWithOwner
|
||||
target: WorkspaceWithOwner
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a promote along the strict edge between `currentWorkspaceId` and
|
||||
* `otherWorkspaceId`. Requires admin on BOTH the source and the target: a sync
|
||||
* reads the source's deployed workflows/resources and force-replaces the target's,
|
||||
* and the sync surface is only ever offered to workspace admins. `push` sends
|
||||
* current -> other; `pull` brings other -> current.
|
||||
*/
|
||||
export async function assertCanPromote(
|
||||
currentWorkspaceId: string,
|
||||
otherWorkspaceId: string,
|
||||
direction: PromoteDirection,
|
||||
userId: string
|
||||
): Promise<PromoteAuthorization> {
|
||||
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
|
||||
if (!edge) {
|
||||
throw new ForkError('These workspaces are not a direct fork edge', 400)
|
||||
}
|
||||
const sourceWorkspaceId = direction === 'push' ? currentWorkspaceId : otherWorkspaceId
|
||||
const targetWorkspaceId = direction === 'push' ? otherWorkspaceId : currentWorkspaceId
|
||||
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
|
||||
const target = await assertWorkspaceAdminAccess(targetWorkspaceId, userId)
|
||||
return { edge, source, target, sourceWorkspaceId, targetWorkspaceId }
|
||||
}
|
||||
|
||||
/** Authorize rolling back the last promote into `targetWorkspaceId` (admin only). */
|
||||
export async function assertCanRollback(
|
||||
targetWorkspaceId: string,
|
||||
userId: string
|
||||
): Promise<WorkspaceWithOwner> {
|
||||
return assertWorkspaceAdminAccess(targetWorkspaceId, userId)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } from '@sim/db/schema'
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
export interface ForkLineageNode {
|
||||
id: string
|
||||
name: string
|
||||
organizationId: string | null
|
||||
}
|
||||
|
||||
export interface ForkEdge {
|
||||
childWorkspaceId: string
|
||||
parentWorkspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent workspace id a fork was created from, or null when the workspace
|
||||
* is not a fork (or has been archived).
|
||||
*/
|
||||
export async function getForkParentId(workspaceId: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ parentId: workspace.forkedFromWorkspaceId })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
return row?.parentId ?? null
|
||||
}
|
||||
|
||||
/** The parent lineage node for a fork, or null when it has no live parent. */
|
||||
export async function getForkParent(workspaceId: string): Promise<ForkLineageNode | null> {
|
||||
const parentId = await getForkParentId(workspaceId)
|
||||
if (!parentId) return null
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
organizationId: workspace.organizationId,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, parentId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the strict fork edge between two workspaces, identifying which is the
|
||||
* child (the one whose `forkedFromWorkspaceId` points at the other). Returns
|
||||
* null when the two workspaces are not a direct parent/child pair.
|
||||
*/
|
||||
export async function resolveForkEdge(
|
||||
workspaceAId: string,
|
||||
workspaceBId: string
|
||||
): Promise<ForkEdge | null> {
|
||||
if (workspaceAId === workspaceBId) return null
|
||||
if ((await getForkParentId(workspaceAId)) === workspaceBId) {
|
||||
return { childWorkspaceId: workspaceAId, parentWorkspaceId: workspaceBId }
|
||||
}
|
||||
if ((await getForkParentId(workspaceBId)) === workspaceAId) {
|
||||
return { childWorkspaceId: workspaceBId, parentWorkspaceId: workspaceAId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a fork transaction waits for a lock before aborting. Bounds the wait on the
|
||||
* target/edge advisory locks (and any incidental row lock) so a contended sync into the
|
||||
* same target fails fast and returns its pooled connection instead of piling waiters up
|
||||
* and stagnating the pool at scale. 10s favors completing a legit sync queued behind an
|
||||
* in-flight one, while still tripping on a pathological hold. Connection-level timeouts
|
||||
* are not used (PlanetScale rejects them) - this is transaction-scoped only.
|
||||
*/
|
||||
const FORK_LOCK_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Apply {@link FORK_LOCK_TIMEOUT_MS} to the current transaction (`set_config(local)`),
|
||||
* so it covers `pg_advisory_xact_lock` waits too. Call at the very start of a fork
|
||||
* transaction, before acquiring any lock.
|
||||
*/
|
||||
export async function setForkLockTimeout(tx: DbOrTx): Promise<void> {
|
||||
await tx.execute(sql`select set_config('lock_timeout', ${`${FORK_LOCK_TIMEOUT_MS}ms`}, true)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize concurrent promote/rollback on a fork edge with a transaction-scoped
|
||||
* advisory lock keyed by the edge (the child workspace id). `hashtextextended`
|
||||
* (64-bit, matching every other advisory lock in the repo) makes a collision
|
||||
* between distinct keys astronomically unlikely; a collision would only cause
|
||||
* unnecessary serialization, never a correctness issue.
|
||||
*/
|
||||
export async function acquireForkEdgeLock(tx: DbOrTx, childWorkspaceId: string): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-edge:${childWorkspaceId}`}, 0))`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize every promote/rollback whose TARGET is this workspace. Sibling forks
|
||||
* promote into the same parent on different edge locks, so the edge lock alone does
|
||||
* not serialize them; this lock does, keeping concurrent syncs into one target from
|
||||
* interleaving and keeping rollback's "newest sync" check race-free. Always acquire
|
||||
* this BEFORE {@link acquireForkEdgeLock} so the two are taken in a consistent order.
|
||||
*/
|
||||
export async function acquireForkTargetLock(tx: DbOrTx, targetWorkspaceId: string): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-target:${targetWorkspaceId}`}, 0))`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toForkBlockPairs } from '@/lib/workspaces/fork/mapping/block-map-store'
|
||||
|
||||
describe('toForkBlockPairs', () => {
|
||||
const mapping = new Map([
|
||||
['src-1', 'tgt-1'],
|
||||
['src-2', 'tgt-2'],
|
||||
])
|
||||
|
||||
it('orients source->target as parent->child on pull/create (source = parent)', () => {
|
||||
expect(toForkBlockPairs(mapping, true, 'wf-parent', 'wf-child')).toEqual([
|
||||
{
|
||||
parentWorkflowId: 'wf-parent',
|
||||
childWorkflowId: 'wf-child',
|
||||
parentBlockId: 'src-1',
|
||||
childBlockId: 'tgt-1',
|
||||
},
|
||||
{
|
||||
parentWorkflowId: 'wf-parent',
|
||||
childWorkflowId: 'wf-child',
|
||||
parentBlockId: 'src-2',
|
||||
childBlockId: 'tgt-2',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('orients source->target as child->parent on push (source = child)', () => {
|
||||
expect(toForkBlockPairs(mapping, false, 'wf-child', 'wf-parent')).toEqual([
|
||||
{
|
||||
parentWorkflowId: 'wf-parent',
|
||||
childWorkflowId: 'wf-child',
|
||||
parentBlockId: 'tgt-1',
|
||||
childBlockId: 'src-1',
|
||||
},
|
||||
{
|
||||
parentWorkflowId: 'wf-parent',
|
||||
childWorkflowId: 'wf-child',
|
||||
parentBlockId: 'tgt-2',
|
||||
childBlockId: 'src-2',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty list for an empty mapping', () => {
|
||||
expect(toForkBlockPairs(new Map(), true, 'wf-parent', 'wf-child')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { workspaceForkBlockMap } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type { ForkBlockMap } from '@/lib/workspaces/fork/remap/block-identity'
|
||||
|
||||
/** One persisted block-identity pair for an edge (carries both workflow sides). */
|
||||
export interface ForkBlockPair {
|
||||
parentWorkflowId: string
|
||||
parentBlockId: string
|
||||
childWorkflowId: string
|
||||
childBlockId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an edge's persisted block-identity pairs into both lookup directions, each entry
|
||||
* carrying its target-side workflow so the resolver can scope a reuse to the right workflow
|
||||
* (see {@link buildForkBlockIdResolver}). Empty for an edge that predates the map (every
|
||||
* block id then derives, exactly as before).
|
||||
*/
|
||||
export async function loadForkBlockMap(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string
|
||||
): Promise<ForkBlockMap> {
|
||||
const rows = await executor
|
||||
.select({
|
||||
parentWorkflowId: workspaceForkBlockMap.parentWorkflowId,
|
||||
parentBlockId: workspaceForkBlockMap.parentBlockId,
|
||||
childWorkflowId: workspaceForkBlockMap.childWorkflowId,
|
||||
childBlockId: workspaceForkBlockMap.childBlockId,
|
||||
})
|
||||
.from(workspaceForkBlockMap)
|
||||
.where(eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId))
|
||||
const parentToChild = new Map<string, { targetBlockId: string; targetWorkflowId: string }>()
|
||||
const childToParent = new Map<string, { targetBlockId: string; targetWorkflowId: string }>()
|
||||
for (const row of rows) {
|
||||
parentToChild.set(row.parentBlockId, {
|
||||
targetBlockId: row.childBlockId,
|
||||
targetWorkflowId: row.childWorkflowId,
|
||||
})
|
||||
childToParent.set(row.childBlockId, {
|
||||
targetBlockId: row.parentBlockId,
|
||||
targetWorkflowId: row.parentWorkflowId,
|
||||
})
|
||||
}
|
||||
return { parentToChild, childToParent }
|
||||
}
|
||||
|
||||
/**
|
||||
* Orient one workflow's copy mapping (`sourceBlockId -> targetBlockId`) into block pairs.
|
||||
* On pull/create the source is the parent; on push it's the child. The workflow ids are
|
||||
* fixed for the whole mapping (one source workflow copied into one target workflow).
|
||||
*/
|
||||
export function toForkBlockPairs(
|
||||
blockIdMapping: ReadonlyMap<string, string>,
|
||||
sourceIsParent: boolean,
|
||||
sourceWorkflowId: string,
|
||||
targetWorkflowId: string
|
||||
): ForkBlockPair[] {
|
||||
const parentWorkflowId = sourceIsParent ? sourceWorkflowId : targetWorkflowId
|
||||
const childWorkflowId = sourceIsParent ? targetWorkflowId : sourceWorkflowId
|
||||
const pairs: ForkBlockPair[] = []
|
||||
for (const [sourceBlockId, targetBlockId] of blockIdMapping) {
|
||||
pairs.push({
|
||||
parentWorkflowId,
|
||||
childWorkflowId,
|
||||
parentBlockId: sourceIsParent ? sourceBlockId : targetBlockId,
|
||||
childBlockId: sourceIsParent ? targetBlockId : sourceBlockId,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the persisted pairs for the promoted SOURCE workflows with the live ones. Deleting
|
||||
* by the (stable) source-side workflow id first does two things: it nukes pairs for blocks
|
||||
* the source deleted since the last sync (e.g. a removed trigger), and it clears the old pair
|
||||
* for a workflow whose target was archived + re-created - so the map always reflects the live
|
||||
* lineage and a stale pair can never re-home a block onto an archived workflow's id. Block
|
||||
* identity is otherwise immutable, so on a steady sync this just deletes and re-inserts the
|
||||
* same pairs. `sourceIsParent` is true on pull/create, false on push.
|
||||
*/
|
||||
export async function reconcileForkBlockPairs(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
sourceIsParent: boolean,
|
||||
sourceWorkflowIds: string[],
|
||||
pairs: ForkBlockPair[]
|
||||
): Promise<void> {
|
||||
if (sourceWorkflowIds.length > 0) {
|
||||
const sourceWorkflowColumn = sourceIsParent
|
||||
? workspaceForkBlockMap.parentWorkflowId
|
||||
: workspaceForkBlockMap.childWorkflowId
|
||||
await executor
|
||||
.delete(workspaceForkBlockMap)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId),
|
||||
inArray(sourceWorkflowColumn, sourceWorkflowIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (pairs.length === 0) return
|
||||
const now = new Date()
|
||||
await executor
|
||||
.insert(workspaceForkBlockMap)
|
||||
.values(
|
||||
pairs.map((pair) => ({
|
||||
id: generateId(),
|
||||
childWorkspaceId,
|
||||
parentWorkflowId: pair.parentWorkflowId,
|
||||
parentBlockId: pair.parentBlockId,
|
||||
childWorkflowId: pair.childWorkflowId,
|
||||
childBlockId: pair.childBlockId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { detectForkCascadeReferences } from '@/lib/workspaces/fork/mapping/cascade'
|
||||
import type {
|
||||
ForkReference,
|
||||
ForkReferenceResolver,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
|
||||
/** Executor that returns the queued result arrays in the order queries are issued. */
|
||||
function queuedExecutor(results: unknown[][]): DbOrTx {
|
||||
let index = 0
|
||||
const builder = {
|
||||
from: () => builder,
|
||||
where: () => Promise.resolve(results[index++] ?? []),
|
||||
}
|
||||
return { select: () => builder } as unknown as DbOrTx
|
||||
}
|
||||
|
||||
function ref(kind: ForkReference['kind'], sourceId: string): ForkReference {
|
||||
return { kind, sourceId, subBlockKey: 'tools', required: false }
|
||||
}
|
||||
|
||||
const resolveNone: ForkReferenceResolver = () => null
|
||||
const resolveAll: ForkReferenceResolver = (_kind, sourceId) => sourceId
|
||||
|
||||
describe('detectForkCascadeReferences', () => {
|
||||
it('returns empty when there are no content references', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('credential', 'cred-1'), ref('table', 'tbl-1')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toEqual([])
|
||||
expect(result.unmapped).toEqual([])
|
||||
expect(result.mcpReauthServerIds).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces env keys from custom tool code as required unmapped env-var refs', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([[{ id: 't1', title: 'Weather', code: 'fetch(`{{API_KEY}}`)' }]]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('custom-tool', 't1')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toHaveLength(1)
|
||||
expect(result.references[0]).toMatchObject({
|
||||
kind: 'env-var',
|
||||
sourceId: 'API_KEY',
|
||||
required: true,
|
||||
})
|
||||
expect(result.unmapped).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('marks env-var cascade refs mapped when the resolver finds them in the target', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([[{ id: 't1', title: 'Weather', code: '{{API_KEY}}' }]]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('custom-tool', 't1')],
|
||||
resolve: resolveAll,
|
||||
})
|
||||
expect(result.references).toHaveLength(1)
|
||||
expect(result.unmapped).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('extracts env keys from MCP url/headers and flags oauth servers for re-auth', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([
|
||||
[
|
||||
{
|
||||
id: 'mcp-1',
|
||||
name: 'Server',
|
||||
url: 'https://x/{{HOST_KEY}}',
|
||||
headers: { Authorization: '{{TOKEN}}' },
|
||||
authType: 'headers',
|
||||
},
|
||||
{ id: 'mcp-2', name: 'OAuth Server', url: 'https://y', headers: {}, authType: 'oauth' },
|
||||
],
|
||||
]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('mcp-server', 'mcp-1'), ref('mcp-server', 'mcp-2')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
const envIds = result.references
|
||||
.filter((r) => r.kind === 'env-var')
|
||||
.map((r) => r.sourceId)
|
||||
.sort()
|
||||
expect(envIds).toEqual(['HOST_KEY', 'TOKEN'])
|
||||
expect(result.mcpReauthServerIds).toEqual(['mcp-2'])
|
||||
})
|
||||
|
||||
it('flags literal MCP header values as inline secrets (not env)', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([
|
||||
[
|
||||
{
|
||||
id: 'mcp-1',
|
||||
name: 'Server',
|
||||
url: 'https://x',
|
||||
headers: { Authorization: 'sk-literal' },
|
||||
authType: 'headers',
|
||||
},
|
||||
],
|
||||
]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('mcp-server', 'mcp-1')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toHaveLength(0)
|
||||
expect(result.inlineSecretSources).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces KB connector credentials as required credential refs', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([
|
||||
[{ id: 'kc-1', knowledgeBaseId: 'kb-1', credentialId: 'cred-9', encryptedApiKey: null }],
|
||||
]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('knowledge-base', 'kb-1')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toHaveLength(1)
|
||||
expect(result.references[0]).toMatchObject({
|
||||
kind: 'credential',
|
||||
sourceId: 'cred-9',
|
||||
required: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('dedupes a shared env key referenced by two custom tools', async () => {
|
||||
const result = await detectForkCascadeReferences({
|
||||
executor: queuedExecutor([
|
||||
[
|
||||
{ id: 't1', title: 'A', code: '{{SHARED}}' },
|
||||
{ id: 't2', title: 'B', code: '{{SHARED}}' },
|
||||
],
|
||||
]),
|
||||
sourceWorkspaceId: 'ws',
|
||||
references: [ref('custom-tool', 't1'), ref('custom-tool', 't2')],
|
||||
resolve: resolveNone,
|
||||
})
|
||||
expect(result.references).toHaveLength(1)
|
||||
expect(result.references[0].sourceId).toBe('SHARED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { customTools, knowledgeConnector, mcpServers } from '@sim/db/schema'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
ENV_REF_PATTERN,
|
||||
type ForkReference,
|
||||
type ForkReferenceResolver,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
|
||||
function extractEnvKeys(text: string): string[] {
|
||||
const keys = new Set<string>()
|
||||
for (const match of text.matchAll(ENV_REF_PATTERN)) {
|
||||
if (match[1]) keys.add(match[1])
|
||||
}
|
||||
return Array.from(keys)
|
||||
}
|
||||
|
||||
export interface ForkCascadeResult {
|
||||
/** Transitive env-var / credential references discovered inside referenced resources. */
|
||||
references: ForkReference[]
|
||||
unmapped: ForkReference[]
|
||||
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
|
||||
mcpReauthServerIds: string[]
|
||||
/** Human-readable descriptions of inline secrets that cannot be mapped (review-only). */
|
||||
inlineSecretSources: string[]
|
||||
}
|
||||
|
||||
const EMPTY: ForkCascadeResult = {
|
||||
references: [],
|
||||
unmapped: [],
|
||||
mcpReauthServerIds: [],
|
||||
inlineSecretSources: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the bodies of resources a workflow references (custom tools, MCP servers,
|
||||
* knowledge bases) and surface the secrets they carry transitively: `{{ENV}}`
|
||||
* keys inside custom tool code and MCP url/headers, and credential ids on KB
|
||||
* connectors. These become additional required env-var / credential references
|
||||
* (validated for existence in the target via `resolve`). OAuth MCP servers and
|
||||
* inline connector keys are surfaced separately for review since they cannot be
|
||||
* id-mapped. Reads only the source workspace's resources.
|
||||
*/
|
||||
export async function detectForkCascadeReferences(params: {
|
||||
executor: DbOrTx
|
||||
sourceWorkspaceId: string
|
||||
references: ForkReference[]
|
||||
resolve: ForkReferenceResolver
|
||||
}): Promise<ForkCascadeResult> {
|
||||
const { executor, sourceWorkspaceId, references, resolve } = params
|
||||
|
||||
const customToolIds = new Set<string>()
|
||||
const mcpServerIds = new Set<string>()
|
||||
const knowledgeBaseIds = new Set<string>()
|
||||
for (const reference of references) {
|
||||
if (reference.kind === 'custom-tool') customToolIds.add(reference.sourceId)
|
||||
else if (reference.kind === 'mcp-server') mcpServerIds.add(reference.sourceId)
|
||||
else if (reference.kind === 'knowledge-base') knowledgeBaseIds.add(reference.sourceId)
|
||||
}
|
||||
|
||||
if (customToolIds.size === 0 && mcpServerIds.size === 0 && knowledgeBaseIds.size === 0) {
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
const refs = new Map<string, ForkReference>()
|
||||
const unmapped = new Map<string, ForkReference>()
|
||||
const mcpReauthServerIds = new Set<string>()
|
||||
const inlineSecretSources: string[] = []
|
||||
|
||||
const recordEnv = (key: string, sourceLabel: string) => {
|
||||
const dedupeKey = `env-var:${key}`
|
||||
if (refs.has(dedupeKey)) return
|
||||
const reference: ForkReference = {
|
||||
kind: 'env-var',
|
||||
sourceId: key,
|
||||
subBlockKey: '(cascade)',
|
||||
blockName: sourceLabel,
|
||||
required: true,
|
||||
}
|
||||
refs.set(dedupeKey, reference)
|
||||
if (resolve('env-var', key) == null) unmapped.set(dedupeKey, reference)
|
||||
}
|
||||
|
||||
const recordCredential = (credentialId: string, sourceLabel: string) => {
|
||||
const dedupeKey = `credential:${credentialId}`
|
||||
if (refs.has(dedupeKey)) return
|
||||
const reference: ForkReference = {
|
||||
kind: 'credential',
|
||||
sourceId: credentialId,
|
||||
subBlockKey: '(cascade)',
|
||||
blockName: sourceLabel,
|
||||
required: true,
|
||||
}
|
||||
refs.set(dedupeKey, reference)
|
||||
if (resolve('credential', credentialId) == null) unmapped.set(dedupeKey, reference)
|
||||
}
|
||||
|
||||
if (customToolIds.size > 0) {
|
||||
const tools = await executor
|
||||
.select({ id: customTools.id, title: customTools.title, code: customTools.code })
|
||||
.from(customTools)
|
||||
.where(
|
||||
and(
|
||||
inArray(customTools.id, Array.from(customToolIds)),
|
||||
eq(customTools.workspaceId, sourceWorkspaceId)
|
||||
)
|
||||
)
|
||||
for (const tool of tools) {
|
||||
for (const key of extractEnvKeys(tool.code ?? '')) {
|
||||
recordEnv(key, `Custom tool: ${tool.title}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mcpServerIds.size > 0) {
|
||||
const servers = await executor
|
||||
.select({
|
||||
id: mcpServers.id,
|
||||
name: mcpServers.name,
|
||||
url: mcpServers.url,
|
||||
headers: mcpServers.headers,
|
||||
authType: mcpServers.authType,
|
||||
})
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
inArray(mcpServers.id, Array.from(mcpServerIds)),
|
||||
eq(mcpServers.workspaceId, sourceWorkspaceId)
|
||||
)
|
||||
)
|
||||
for (const server of servers) {
|
||||
const label = `MCP server: ${server.name}`
|
||||
if (server.url) {
|
||||
for (const key of extractEnvKeys(server.url)) recordEnv(key, label)
|
||||
}
|
||||
const headers = (server.headers ?? {}) as Record<string, unknown>
|
||||
for (const [headerName, headerValue] of Object.entries(headers)) {
|
||||
if (typeof headerValue !== 'string') continue
|
||||
const keys = extractEnvKeys(headerValue)
|
||||
if (keys.length > 0) {
|
||||
for (const key of keys) recordEnv(key, label)
|
||||
} else if (server.authType === 'headers' && headerValue) {
|
||||
inlineSecretSources.push(`${label} (header ${headerName})`)
|
||||
}
|
||||
}
|
||||
if (server.authType === 'oauth') mcpReauthServerIds.add(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (knowledgeBaseIds.size > 0) {
|
||||
const connectors = await executor
|
||||
.select({
|
||||
id: knowledgeConnector.id,
|
||||
knowledgeBaseId: knowledgeConnector.knowledgeBaseId,
|
||||
credentialId: knowledgeConnector.credentialId,
|
||||
encryptedApiKey: knowledgeConnector.encryptedApiKey,
|
||||
})
|
||||
.from(knowledgeConnector)
|
||||
.where(
|
||||
and(
|
||||
inArray(knowledgeConnector.knowledgeBaseId, Array.from(knowledgeBaseIds)),
|
||||
isNull(knowledgeConnector.deletedAt)
|
||||
)
|
||||
)
|
||||
for (const connector of connectors) {
|
||||
if (connector.credentialId) {
|
||||
recordCredential(connector.credentialId, `Knowledge base connector`)
|
||||
} else if (connector.encryptedApiKey) {
|
||||
inlineSecretSources.push(`Knowledge base connector ${connector.id} (API key)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
references: Array.from(refs.values()),
|
||||
unmapped: Array.from(unmapped.values()),
|
||||
mcpReauthServerIds: Array.from(mcpReauthServerIds),
|
||||
inlineSecretSources,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
collectForkDependentReconfigs,
|
||||
collectForkResourceUsages,
|
||||
} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
|
||||
import {
|
||||
buildForkBlockIdResolver,
|
||||
deriveForkBlockId,
|
||||
EMPTY_FORK_BLOCK_MAP,
|
||||
} from '@/lib/workspaces/fork/remap/block-identity'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
|
||||
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
|
||||
|
||||
const sourceState = (
|
||||
blockType: string,
|
||||
subBlocks: Record<string, { value: unknown }>
|
||||
): WorkflowState =>
|
||||
({
|
||||
blocks: { 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks } },
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
}) as unknown as WorkflowState
|
||||
|
||||
const replaceItem = {
|
||||
sourceWorkflowId: 'wf-src',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
mode: 'replace' as const,
|
||||
}
|
||||
|
||||
// No persisted block map in these unit tests, so the resolver derives - matching the
|
||||
// `deriveForkBlockId(...)` ids the expectations assert.
|
||||
const resolve = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP)
|
||||
|
||||
describe('collectForkDependentReconfigs', () => {
|
||||
it("emits the active operation's credential-dependent selector (condition-gated)", () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{ id: 'operation', title: 'Operation', type: 'dropdown' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
},
|
||||
// A different operation's variant -> excluded by its condition, not by emptiness.
|
||||
{
|
||||
id: 'otherFolder',
|
||||
title: 'Move To Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
condition: { field: 'operation', value: 'move' },
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('gmail', {
|
||||
credential: { value: 'cred-src' },
|
||||
operation: { value: 'read' },
|
||||
folder: { value: 'INBOX' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-src',
|
||||
parentContextKey: 'oauthCredential',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
|
||||
blockName: 'Block',
|
||||
subBlockKey: 'folder',
|
||||
selectorKey: 'gmail.labels',
|
||||
title: 'Label',
|
||||
currentValue: 'INBOX',
|
||||
required: true,
|
||||
consumesContextKeys: [],
|
||||
context: {},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('emits a knowledge-base-dependent document selector', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{
|
||||
id: 'knowledgeBaseSelector',
|
||||
title: 'Knowledge Base',
|
||||
type: 'knowledge-base-selector',
|
||||
canonicalParamId: 'knowledgeBaseId',
|
||||
},
|
||||
{
|
||||
id: 'documentSelector',
|
||||
title: 'Document',
|
||||
type: 'document-selector',
|
||||
canonicalParamId: 'documentId',
|
||||
selectorKey: 'knowledge.documents',
|
||||
dependsOn: ['knowledgeBaseSelector'],
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('knowledge', {
|
||||
knowledgeBaseSelector: { value: 'kb-src' },
|
||||
documentSelector: { value: 'doc-src' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
parentKind: 'knowledge-base',
|
||||
parentSourceId: 'kb-src',
|
||||
parentContextKey: 'knowledgeBaseId',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
|
||||
blockName: 'Block',
|
||||
subBlockKey: 'documentSelector',
|
||||
selectorKey: 'knowledge.documents',
|
||||
title: 'Document',
|
||||
currentValue: 'doc-src',
|
||||
required: true,
|
||||
consumesContextKeys: [],
|
||||
context: {},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('offers an active credential selector even when the source left it empty', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
)
|
||||
// The source has the credential but no label - the user must still be able to set one
|
||||
// during the swap (a prior sync may have cleared it), so the selector is offered.
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: '' } }),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ subBlockKey: 'folder', parentSourceId: 'cred-src' })
|
||||
})
|
||||
|
||||
it('still skips a selector whose parent credential is unset', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
['wf-src', sourceState('gmail', { credential: { value: '' }, folder: { value: 'INBOX' } })],
|
||||
])
|
||||
expect(collectForkDependentReconfigs([replaceItem], states, resolve)).toEqual([])
|
||||
})
|
||||
|
||||
it('skips create-mode targets and credentialSet refs', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
)
|
||||
const created = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('gmail', { credential: { value: 'cred-src' }, folder: { value: 'INBOX' } }),
|
||||
],
|
||||
])
|
||||
expect(
|
||||
collectForkDependentReconfigs(
|
||||
[{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt', mode: 'create' }],
|
||||
created,
|
||||
resolve
|
||||
)
|
||||
).toEqual([])
|
||||
|
||||
const orgSet = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('gmail', {
|
||||
credential: { value: 'credentialSet:cs-1' },
|
||||
folder: { value: 'INBOX' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
expect(collectForkDependentReconfigs([replaceItem], orgSet, resolve)).toEqual([])
|
||||
})
|
||||
|
||||
it('walks the transitive chain and tags the context key a re-pick provides', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'spreadsheetId',
|
||||
title: 'Spreadsheet',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'spreadsheetId',
|
||||
selectorKey: 'google.drive',
|
||||
dependsOn: ['credential'],
|
||||
},
|
||||
{
|
||||
id: 'sheetName',
|
||||
title: 'Sheet',
|
||||
type: 'sheet-selector',
|
||||
selectorKey: 'google.sheets',
|
||||
dependsOn: ['spreadsheetId'],
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('google_sheets', {
|
||||
credential: { value: 'cred-src' },
|
||||
spreadsheetId: { value: 'ss-src' },
|
||||
sheetName: { value: 'Sheet1' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
// Both the spreadsheet (direct) and its sheet (transitive) are offered, in order.
|
||||
expect(result.map((entry) => entry.subBlockKey)).toEqual(['spreadsheetId', 'sheetName'])
|
||||
const spreadsheet = result.find((entry) => entry.subBlockKey === 'spreadsheetId')
|
||||
expect(spreadsheet?.parentKind).toBe('credential')
|
||||
expect(spreadsheet?.providesContextKey).toBe('spreadsheetId')
|
||||
const sheet = result.find((entry) => entry.subBlockKey === 'sheetName')
|
||||
expect(sheet?.parentKind).toBe('credential')
|
||||
expect(sheet?.required).toBe(true)
|
||||
// The sheet consumes the spreadsheet's key, so the modal gates it on that re-pick.
|
||||
expect(sheet?.consumesContextKeys).toEqual(['spreadsheetId'])
|
||||
// The source spreadsheet rides in context; the modal overlays the re-picked one.
|
||||
expect(sheet?.context.spreadsheetId).toBe('ss-src')
|
||||
})
|
||||
|
||||
it('emits a credential-dependent selector nested inside a tool-input tool', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('agent', {
|
||||
tools: {
|
||||
value: [
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail 1',
|
||||
params: { credential: 'cred-src', folder: 'INBOX' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-src',
|
||||
parentContextKey: 'oauthCredential',
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
targetBlockId: deriveForkBlockId('wf-tgt', 'block-1'),
|
||||
blockName: 'Block',
|
||||
subBlockKey: 'tools[0].folder',
|
||||
selectorKey: 'gmail.labels',
|
||||
title: 'Gmail 1: Label',
|
||||
currentValue: 'INBOX',
|
||||
required: true,
|
||||
consumesContextKeys: [],
|
||||
context: {},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('offers a nested tool selector even when the source left it empty', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('agent', {
|
||||
tools: {
|
||||
value: [
|
||||
{ type: 'gmail', title: 'Gmail 1', params: { credential: 'cred-src', folder: '' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ subBlockKey: 'tools[0].folder', title: 'Gmail 1: Label' })
|
||||
})
|
||||
|
||||
it('evaluates a nested tool selector condition against the tool-level operation', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
// Active only under read - and `operation` lives at the tool level, not params.
|
||||
condition: { field: 'operation', value: 'read_gmail' },
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const reading = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('agent', {
|
||||
tools: {
|
||||
value: [
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail',
|
||||
operation: 'read_gmail',
|
||||
params: { credential: 'cred-src', folder: 'INBOX' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
])
|
||||
expect(collectForkDependentReconfigs([replaceItem], reading, resolve)).toHaveLength(1)
|
||||
|
||||
// Same tool under a different operation -> the read-only label is gated off.
|
||||
const sending = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('agent', {
|
||||
tools: {
|
||||
value: [
|
||||
{
|
||||
type: 'gmail',
|
||||
title: 'Gmail',
|
||||
operation: 'send_gmail',
|
||||
params: { credential: 'cred-src', folder: 'INBOX' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
])
|
||||
expect(collectForkDependentReconfigs([replaceItem], sending, resolve)).toEqual([])
|
||||
})
|
||||
|
||||
it('anchors on a table selector for its column dependents', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{
|
||||
id: 'tableSelector',
|
||||
title: 'Table',
|
||||
type: 'table-selector',
|
||||
canonicalParamId: 'tableId',
|
||||
},
|
||||
{
|
||||
id: 'conflictColumnSelector',
|
||||
title: 'Column',
|
||||
type: 'column-selector',
|
||||
canonicalParamId: 'conflictColumn',
|
||||
selectorKey: 'table.columns',
|
||||
dependsOn: ['tableSelector'],
|
||||
},
|
||||
])
|
||||
)
|
||||
const states = new Map<string, WorkflowState>([
|
||||
[
|
||||
'wf-src',
|
||||
sourceState('table', {
|
||||
tableSelector: { value: 'tbl-src' },
|
||||
conflictColumnSelector: { value: 'col1' },
|
||||
}),
|
||||
],
|
||||
])
|
||||
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
parentKind: 'table',
|
||||
parentSourceId: 'tbl-src',
|
||||
parentContextKey: 'tableId',
|
||||
subBlockKey: 'conflictColumnSelector',
|
||||
selectorKey: 'table.columns',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectForkResourceUsages', () => {
|
||||
const usageItem = (
|
||||
sourceWorkflowId: string,
|
||||
targetWorkflowId: string,
|
||||
name: string,
|
||||
mode: 'create' | 'replace' = 'replace'
|
||||
) => ({ sourceWorkflowId, targetWorkflowId, mode, sourceMeta: { name } })
|
||||
|
||||
// The reference scan reads each subblock entry's own `type`, so credential usages need
|
||||
// typed entries (unlike the dependent collector, which keys off the block config).
|
||||
const credentialState = (credentialId: string): WorkflowState =>
|
||||
({
|
||||
blocks: {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'gmail',
|
||||
name: 'Block',
|
||||
subBlocks: { credential: { id: 'credential', type: 'oauth-input', value: credentialId } },
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
}) as unknown as WorkflowState
|
||||
|
||||
it('lists each replace workflow a resource is used in, with its (target) name', () => {
|
||||
const states = new Map<string, WorkflowState>([
|
||||
['wf-a', credentialState('cred-src')],
|
||||
['wf-b', credentialState('cred-src')],
|
||||
])
|
||||
const result = collectForkResourceUsages(
|
||||
[usageItem('wf-a', 'wf-tgt-a', 'Workflow A'), usageItem('wf-b', 'wf-tgt-b', 'Workflow B')],
|
||||
states
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-src',
|
||||
workflows: [
|
||||
{ workflowId: 'wf-tgt-a', workflowName: 'Workflow A' },
|
||||
{ workflowId: 'wf-tgt-b', workflowName: 'Workflow B' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('skips create-mode targets (the source config carries over on first sync)', () => {
|
||||
const states = new Map<string, WorkflowState>([['wf-a', credentialState('cred-src')]])
|
||||
expect(
|
||||
collectForkResourceUsages([usageItem('wf-a', 'wf-tgt-a', 'A', 'create')], states)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
|
||||
import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
|
||||
import {
|
||||
buildSelectorContextFromBlock,
|
||||
SELECTOR_CONTEXT_FIELDS,
|
||||
} from '@/lib/workflows/subblocks/context'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildSubBlockValues,
|
||||
evaluateSubBlockCondition,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
|
||||
import {
|
||||
isSubBlockRequired,
|
||||
scanWorkflowReferences,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const isSelectorContextKey = (
|
||||
key: string
|
||||
): key is Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0] =>
|
||||
SELECTOR_CONTEXT_FIELDS.has(key as Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0])
|
||||
|
||||
interface ReconfigItem {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
mode: 'create' | 'replace'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent anchor types a dependent selector can hang off, with the SelectorContext key
|
||||
* the new parent value is supplied under. A parent is a remappable resource (rewritten
|
||||
* source->target on sync) whose target swap clears its dependents. MCP servers are
|
||||
* intentionally excluded: their tool dependent has no `selectorKey` and a separate
|
||||
* (non-`useSelectorOptions`) stack, so it falls back to the needs-config surfacing.
|
||||
*/
|
||||
const PARENT_ANCHORS: ReadonlyArray<{
|
||||
subBlockType: string
|
||||
parentKind: ForkDependentReconfig['parentKind']
|
||||
parentContextKey: string
|
||||
}> = [
|
||||
{ subBlockType: 'oauth-input', parentKind: 'credential', parentContextKey: 'oauthCredential' },
|
||||
{
|
||||
subBlockType: 'knowledge-base-selector',
|
||||
parentKind: 'knowledge-base',
|
||||
parentContextKey: 'knowledgeBaseId',
|
||||
},
|
||||
{ subBlockType: 'table-selector', parentKind: 'table', parentContextKey: 'tableId' },
|
||||
]
|
||||
|
||||
interface EmitAnchoredParams {
|
||||
/** The block (top-level) or tool config whose subblocks are scanned for anchors. */
|
||||
config: NonNullable<ReturnType<typeof getBlock>>
|
||||
/** Flat id -> value map for that config (top-level subblock values, or a tool's params). */
|
||||
values: Record<string, unknown>
|
||||
/** Block/tool type + its subblock shape, for building the source selector context. */
|
||||
contextBlockType: string
|
||||
contextSubBlocks: Record<string, { value?: unknown }>
|
||||
blockName: string
|
||||
targetWorkflowId: string
|
||||
/** Memoized so the deterministic target block id is derived at most once per block. */
|
||||
resolveTargetBlockId: () => string
|
||||
/** Map a dependent's config id to its wire `subBlockKey` (identity, or nested `tools[i].id`). */
|
||||
makeSubBlockKey: (dependentId: string) => string
|
||||
makeTitle: (dependent: SubBlockConfig) => string
|
||||
/**
|
||||
* Emit `providesContextKey`/`consumesContextKeys` so the modal can chain in-block
|
||||
* re-picks. Top-level chains; nested tool params don't (a tool's chain would need
|
||||
* per-tool context scoping - out of scope - and the common nested case is a single
|
||||
* credential-anchored field).
|
||||
*/
|
||||
chaining: boolean
|
||||
out: ForkDependentReconfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one config's credential/KB/table-anchored selector dependents that the source had
|
||||
* configured. Shared by the top-level subblock scan and the nested `tool-input` tool scan.
|
||||
* Walks the FULL transitive dependent chain (parent -> child -> grandchild) per anchor and
|
||||
* dedups fields reachable via multiple anchors/paths.
|
||||
*/
|
||||
function emitAnchoredDependents(params: EmitAnchoredParams): void {
|
||||
const {
|
||||
config,
|
||||
values,
|
||||
contextBlockType,
|
||||
contextSubBlocks,
|
||||
blockName,
|
||||
targetWorkflowId,
|
||||
resolveTargetBlockId,
|
||||
makeSubBlockKey,
|
||||
makeTitle,
|
||||
chaining,
|
||||
out,
|
||||
} = params
|
||||
const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks)
|
||||
const canonicalIndex = buildCanonicalIndex(config.subBlocks)
|
||||
const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg]))
|
||||
// A field could hang off two anchors (or be reachable via two paths); emit it once.
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const anchor of PARENT_ANCHORS) {
|
||||
for (const anchorCfg of config.subBlocks) {
|
||||
if (anchorCfg.type !== anchor.subBlockType || !anchorCfg.id) continue
|
||||
const rawValue = values[anchorCfg.id]
|
||||
const parentSourceId = typeof rawValue === 'string' ? rawValue : ''
|
||||
// Skip empty and org-scoped credential sets (those carry over unchanged).
|
||||
if (!parentSourceId || parentSourceId.startsWith('credentialSet:')) continue
|
||||
// Multi-value parents (comma-joined) can't match a single mapping entry; skip
|
||||
// (the field falls back to needs-config) rather than mis-bind to one of several.
|
||||
if (parentSourceId.includes(',')) continue
|
||||
|
||||
// Context the dependents need (spreadsheetId, ...) minus the parent key the modal supplies.
|
||||
const context: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(fullContext)) {
|
||||
if (key === anchor.parentContextKey) continue
|
||||
if (typeof value === 'string' && value) context[key] = value
|
||||
}
|
||||
|
||||
for (const clear of getWorkflowSearchDependentClears(config.subBlocks, anchorCfg.id)) {
|
||||
const dependent = configById.get(clear.subBlockId)
|
||||
if (!dependent?.id || !dependent.selectorKey) continue
|
||||
// Skip fields gated off by their `condition` - a selector under a now-inactive
|
||||
// operation (e.g. a move-only label while the block reads) isn't in play. We do
|
||||
// NOT require a source value: an active selector the source left empty is still
|
||||
// offered, so the user can set a label/sheet during the swap even when the source
|
||||
// (or a prior sync) cleared it - the whole point of the in-place re-pick.
|
||||
if (dependent.condition && !evaluateSubBlockCondition(dependent.condition, values)) continue
|
||||
// The SelectorContext key this field supplies to its own descendants, so the
|
||||
// modal can chain re-picks (re-picked spreadsheet feeds the sheet selector).
|
||||
const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[dependent.id] ?? dependent.id
|
||||
// Dedup by canonical key so a basic/advanced pair (or two paths to the same field)
|
||||
// is offered exactly once.
|
||||
if (seen.has(canonicalKey)) continue
|
||||
seen.add(canonicalKey)
|
||||
const providesContextKey =
|
||||
chaining && isSelectorContextKey(canonicalKey) ? canonicalKey : undefined
|
||||
// The SelectorContext keys this field needs from in-block siblings (e.g. a sheet
|
||||
// needs the spreadsheet), excluding the anchor key the modal already supplies, so
|
||||
// the modal can keep a child disabled until its re-picked parent is chosen.
|
||||
const consumesContextKeys = chaining
|
||||
? [
|
||||
...new Set(
|
||||
getDependsOnFields(dependent.dependsOn)
|
||||
.map((parent) => canonicalIndex.canonicalIdBySubBlockId[parent] ?? parent)
|
||||
.filter((key) => key !== anchor.parentContextKey && isSelectorContextKey(key))
|
||||
),
|
||||
]
|
||||
: []
|
||||
// Carry the selector's static `mimeType` filter (Drive/Sheets pickers) so the
|
||||
// modal selector loads the same filtered list the editor would, not all files.
|
||||
const dependentContext =
|
||||
typeof dependent.mimeType === 'string' && dependent.mimeType
|
||||
? { ...context, mimeType: dependent.mimeType }
|
||||
: context
|
||||
out.push({
|
||||
parentKind: anchor.parentKind,
|
||||
parentSourceId,
|
||||
parentContextKey: anchor.parentContextKey,
|
||||
targetWorkflowId,
|
||||
targetBlockId: resolveTargetBlockId(),
|
||||
blockName,
|
||||
subBlockKey: makeSubBlockKey(dependent.id),
|
||||
selectorKey: dependent.selectorKey,
|
||||
title: makeTitle(dependent),
|
||||
// Source value, so the always-on listing pre-fills a stable parent's selector.
|
||||
currentValue:
|
||||
typeof values[dependent.id] === 'string' ? (values[dependent.id] as string) : '',
|
||||
required: isSubBlockRequired(dependent.required, values),
|
||||
providesContextKey,
|
||||
consumesContextKeys,
|
||||
context: dependentContext,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the source's deployed workflows for configured selector fields that `dependsOn`
|
||||
* a remappable parent (a credential, knowledge base, or table) - the fields a sync clears
|
||||
* whenever that parent's target changes. Covers top-level block subblocks AND selectors
|
||||
* nested inside `tool-input` tools (Agent/tool blocks), so a Gmail tool's label inside an
|
||||
* Agent block is offered for re-pick too. Each entry carries the deterministic target
|
||||
* block id, the parent it hangs off (so the modal can bind it to the newly-chosen target),
|
||||
* and the source-derived selector context. Every selector active for the source's current
|
||||
* operation is emitted - including ones the source left empty - so the user can set a
|
||||
* value in place during the swap even when the source (or a prior sync) had none; only
|
||||
* selectors gated off by their `condition` (a different operation's variant) are skipped.
|
||||
* Replace targets only: a freshly created target has nothing configured to swap yet.
|
||||
*
|
||||
* `resolveTargetBlockId` MUST be the same resolver `copyWorkflowStateIntoTarget` uses for
|
||||
* this promote (see {@link buildForkBlockIdResolver}); otherwise the modal would key a
|
||||
* re-pick by a derived id while the sync writes the block under its persisted counterpart,
|
||||
* and the override would silently miss.
|
||||
*/
|
||||
export function collectForkDependentReconfigs(
|
||||
items: ReconfigItem[],
|
||||
sourceStates: Map<string, WorkflowState>,
|
||||
resolveTargetBlockId: ForkBlockIdResolver
|
||||
): ForkDependentReconfig[] {
|
||||
const out: ForkDependentReconfig[] = []
|
||||
for (const item of items) {
|
||||
if (item.mode !== 'replace') continue
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
|
||||
const config = getBlock(block.type)
|
||||
if (!config) continue
|
||||
const subBlocks = (block.subBlocks ?? {}) as Record<string, { value?: unknown }>
|
||||
const sourceValues = buildSubBlockValues(subBlocks)
|
||||
let cachedTargetBlockId: string | null = null
|
||||
const resolveBlockId = () =>
|
||||
(cachedTargetBlockId ??= resolveTargetBlockId(item.targetWorkflowId, sourceBlockId))
|
||||
|
||||
// Top-level credential/KB/table-anchored selectors.
|
||||
emitAnchoredDependents({
|
||||
config,
|
||||
values: sourceValues,
|
||||
contextBlockType: block.type,
|
||||
contextSubBlocks: subBlocks,
|
||||
blockName: block.name,
|
||||
targetWorkflowId: item.targetWorkflowId,
|
||||
resolveTargetBlockId: resolveBlockId,
|
||||
makeSubBlockKey: (id) => id,
|
||||
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
|
||||
chaining: true,
|
||||
out,
|
||||
})
|
||||
|
||||
// Nested `tool-input` tools: each selected tool's own credential-anchored selectors,
|
||||
// keyed `toolInput[index].paramId` (matching the needs-config key) and titled with the
|
||||
// tool so the modal re-picks them under the same block card.
|
||||
for (const cfg of config.subBlocks) {
|
||||
if (cfg.type !== 'tool-input' || !cfg.id) continue
|
||||
const { array: tools } = coerceObjectArray(subBlocks[cfg.id]?.value)
|
||||
if (!tools) continue
|
||||
for (let index = 0; index < tools.length; index++) {
|
||||
const tool = tools[index]
|
||||
if (!isRecord(tool) || typeof tool.type !== 'string') continue
|
||||
const toolConfig = getBlock(tool.type)
|
||||
if (!toolConfig) continue
|
||||
const toolParams = isRecord(tool.params) ? tool.params : {}
|
||||
// A tool's `operation` is stored at the tool level, not in params, but subblock
|
||||
// conditions reference it (e.g. a Gmail label only under `read_gmail`). Merge it
|
||||
// in so condition-gating matches the editor's `{ operation, ...params }`.
|
||||
const toolValues =
|
||||
typeof tool.operation === 'string'
|
||||
? { operation: tool.operation, ...toolParams }
|
||||
: toolParams
|
||||
const toolContextSubBlocks: Record<string, { value?: unknown }> = {}
|
||||
for (const [key, value] of Object.entries(toolValues)) {
|
||||
toolContextSubBlocks[key] = { value }
|
||||
}
|
||||
const toolLabel =
|
||||
typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name
|
||||
const toolInputKey = cfg.id
|
||||
const toolIndex = index
|
||||
emitAnchoredDependents({
|
||||
config: toolConfig,
|
||||
values: toolValues,
|
||||
contextBlockType: tool.type,
|
||||
contextSubBlocks: toolContextSubBlocks,
|
||||
blockName: block.name,
|
||||
targetWorkflowId: item.targetWorkflowId,
|
||||
resolveTargetBlockId: resolveBlockId,
|
||||
makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`,
|
||||
makeTitle: (dependent) => `${toolLabel}: ${dependent.title ?? dependent.id ?? ''}`,
|
||||
chaining: false,
|
||||
out,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface ResourceUsageItem {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
mode: 'create' | 'replace'
|
||||
/** Source workflow name, shown as the (renamed-aware) target name in the listing. */
|
||||
sourceMeta: { name: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every workflow each mapped resource (any kind) is used in - the spine of the always-on
|
||||
* reconfigure listing under a mapping entry. Scans each source workflow's references
|
||||
* (deduped per workflow, so a resource used by several blocks is one workflow usage) and
|
||||
* groups them by `(kind, sourceId)`. Unlike {@link collectForkDependentReconfigs} this is
|
||||
* NOT anchor-limited: it includes resources with no configurable dependent (env vars, files,
|
||||
* a Gmail block with no active label) so the modal can still list - greyed - the workflows
|
||||
* they appear in. Replace targets only, mirroring the dependent collector (a freshly created
|
||||
* target carries the source config over and has nothing to reconcile yet).
|
||||
*/
|
||||
export function collectForkResourceUsages(
|
||||
items: ResourceUsageItem[],
|
||||
sourceStates: Map<string, WorkflowState>
|
||||
): ForkResourceUsage[] {
|
||||
const byResource = new Map<string, ForkResourceUsage>()
|
||||
for (const item of items) {
|
||||
if (item.mode !== 'replace') continue
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
const blocks = Object.values(state.blocks).map((block) => ({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
subBlocks: block.subBlocks as unknown,
|
||||
}))
|
||||
// scanWorkflowReferences already dedups by `${kind}:${sourceId}` across the workflow,
|
||||
// so each resource appears once per workflow here.
|
||||
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
|
||||
const key = `${reference.kind}\u0000${reference.sourceId}`
|
||||
let usage = byResource.get(key)
|
||||
if (!usage) {
|
||||
usage = { parentKind: reference.kind, parentSourceId: reference.sourceId, workflows: [] }
|
||||
byResource.set(key, usage)
|
||||
}
|
||||
usage.workflows.push({
|
||||
workflowId: item.targetWorkflowId,
|
||||
workflowName: item.sourceMeta.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(byResource.values())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
forkDependentValueKey,
|
||||
loadForkDependentValues,
|
||||
reconcileForkDependentValues,
|
||||
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
|
||||
|
||||
describe('forkDependentValueKey', () => {
|
||||
it('builds a stable triple key', () => {
|
||||
expect(forkDependentValueKey('wf', 'blk', 'folder')).toBe('wf\u0000blk\u0000folder')
|
||||
})
|
||||
|
||||
it("doesn't collide when an id contains a printable separator", () => {
|
||||
// 'a:b' + 'c' must differ from 'a' + 'b:c' - the NUL separator guarantees it.
|
||||
expect(forkDependentValueKey('a:b', 'c', 'd')).not.toBe(forkDependentValueKey('a', 'b:c', 'd'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadForkDependentValues', () => {
|
||||
it('selects the edge rows', async () => {
|
||||
const where = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ targetWorkflowId: 'wf', targetBlockId: 'b', subBlockKey: 'folder', value: 'INBOX' },
|
||||
])
|
||||
const from = vi.fn(() => ({ where }))
|
||||
const executor = { select: vi.fn(() => ({ from })) }
|
||||
const rows = await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1')
|
||||
expect(executor.select).toHaveBeenCalledTimes(1)
|
||||
expect(rows).toEqual([
|
||||
{ targetWorkflowId: 'wf', targetBlockId: 'b', subBlockKey: 'folder', value: 'INBOX' },
|
||||
])
|
||||
})
|
||||
|
||||
it('scopes the read to the given target workflows', async () => {
|
||||
const where = vi.fn().mockResolvedValue([])
|
||||
const from = vi.fn(() => ({ where }))
|
||||
const executor = { select: vi.fn(() => ({ from })) }
|
||||
await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1', ['wf-1', 'wf-2'])
|
||||
expect(executor.select).toHaveBeenCalledTimes(1)
|
||||
expect(where).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('short-circuits an empty target filter without querying', async () => {
|
||||
const executor = { select: vi.fn() }
|
||||
const rows = await loadForkDependentValues(executor as unknown as DbOrTx, 'ws-1', [])
|
||||
expect(executor.select).not.toHaveBeenCalled()
|
||||
expect(rows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileForkDependentValues', () => {
|
||||
function fakeExecutor() {
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined)
|
||||
const insertValues = vi.fn().mockResolvedValue(undefined)
|
||||
const executor = {
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
}
|
||||
return { executor: executor as unknown as DbOrTx, deleteWhere, insertValues }
|
||||
}
|
||||
|
||||
it('deletes the given workflows then inserts only non-empty values', async () => {
|
||||
const { executor, deleteWhere, insertValues } = fakeExecutor()
|
||||
await reconcileForkDependentValues(
|
||||
executor,
|
||||
'ws-1',
|
||||
['wf-1'],
|
||||
[
|
||||
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'INBOX' },
|
||||
{ targetWorkflowId: 'wf-1', targetBlockId: 'b2', subBlockKey: 'folder', value: '' },
|
||||
]
|
||||
)
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1)
|
||||
expect(insertValues).toHaveBeenCalledTimes(1)
|
||||
const rows = insertValues.mock.calls[0][0] as Array<Record<string, unknown>>
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
childWorkspaceId: 'ws-1',
|
||||
targetWorkflowId: 'wf-1',
|
||||
targetBlockId: 'b1',
|
||||
subBlockKey: 'folder',
|
||||
value: 'INBOX',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips the delete when no workflows are given, and skips insert when all values are empty', async () => {
|
||||
const { executor, deleteWhere, insertValues } = fakeExecutor()
|
||||
await reconcileForkDependentValues(
|
||||
executor,
|
||||
'ws-1',
|
||||
[],
|
||||
[{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: '' }]
|
||||
)
|
||||
expect(deleteWhere).not.toHaveBeenCalled()
|
||||
expect(insertValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a workflow (delete, no insert) when its full set is now empty', async () => {
|
||||
const { executor, deleteWhere, insertValues } = fakeExecutor()
|
||||
await reconcileForkDependentValues(executor, 'ws-1', ['wf-1'], [])
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1)
|
||||
expect(insertValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dedupes duplicate field entries (last value wins) so a retried payload cannot trip the unique index', async () => {
|
||||
const { executor, insertValues } = fakeExecutor()
|
||||
await reconcileForkDependentValues(
|
||||
executor,
|
||||
'ws-1',
|
||||
['wf-1'],
|
||||
[
|
||||
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'INBOX' },
|
||||
{ targetWorkflowId: 'wf-1', targetBlockId: 'b1', subBlockKey: 'folder', value: 'SENT' },
|
||||
]
|
||||
)
|
||||
expect(insertValues).toHaveBeenCalledTimes(1)
|
||||
const rows = insertValues.mock.calls[0][0] as Array<Record<string, unknown>>
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
targetWorkflowId: 'wf-1',
|
||||
targetBlockId: 'b1',
|
||||
subBlockKey: 'folder',
|
||||
value: 'SENT',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { workspaceForkDependentValue } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
/** One stored dependent-field value for an edge. */
|
||||
export interface ForkDependentValue {
|
||||
targetWorkflowId: string
|
||||
targetBlockId: string
|
||||
subBlockKey: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Stable key for a stored value (target workflow + block + subblock). */
|
||||
export function forkDependentValueKey(
|
||||
targetWorkflowId: string,
|
||||
targetBlockId: string,
|
||||
subBlockKey: string
|
||||
): string {
|
||||
// NUL separators so ids/keys containing ':' can't be confused for a different triple.
|
||||
return `${targetWorkflowId}\u0000${targetBlockId}\u0000${subBlockKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an edge's stored dependent values - the single source of truth for what each dependent
|
||||
* selector (Gmail label, KB document, sheet tab) is set to. Consumed two ways: the diff
|
||||
* overlays them as the modal's pre-filled value, and a promote applies them verbatim. Pass
|
||||
* `targetWorkflowIds` to scope the read to a plan's replace targets (matching the
|
||||
* `(childWorkspaceId, targetWorkflowId)` index) instead of loading the whole edge; an empty
|
||||
* array short-circuits to no rows.
|
||||
*/
|
||||
export async function loadForkDependentValues(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
targetWorkflowIds?: string[]
|
||||
): Promise<ForkDependentValue[]> {
|
||||
if (targetWorkflowIds && targetWorkflowIds.length === 0) return []
|
||||
const where = targetWorkflowIds
|
||||
? and(
|
||||
eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId),
|
||||
inArray(workspaceForkDependentValue.targetWorkflowId, targetWorkflowIds)
|
||||
)
|
||||
: eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId)
|
||||
return executor
|
||||
.select({
|
||||
targetWorkflowId: workspaceForkDependentValue.targetWorkflowId,
|
||||
targetBlockId: workspaceForkDependentValue.targetBlockId,
|
||||
subBlockKey: workspaceForkDependentValue.subBlockKey,
|
||||
value: workspaceForkDependentValue.value,
|
||||
})
|
||||
.from(workspaceForkDependentValue)
|
||||
.where(where)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the stored dependent values for the given target workflows with `values` (the full
|
||||
* set the modal sent). Deletes those workflows' rows first, then inserts the non-empty values,
|
||||
* so the store always equals exactly what the user configured - cleared fields drop out, and
|
||||
* blocks/fields that no longer exist are pruned. Empty values aren't stored (an empty store
|
||||
* entry and a missing one mean the same thing: unset).
|
||||
*/
|
||||
export async function reconcileForkDependentValues(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
targetWorkflowIds: string[],
|
||||
values: ForkDependentValue[]
|
||||
): Promise<void> {
|
||||
if (targetWorkflowIds.length > 0) {
|
||||
await executor
|
||||
.delete(workspaceForkDependentValue)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId),
|
||||
inArray(workspaceForkDependentValue.targetWorkflowId, targetWorkflowIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
// Dedupe by the stored (workflow, block, subblock) triple (last value wins) before building
|
||||
// insert rows, so a duplicated/retried payload entry can't trip the `..._field_unique` index
|
||||
// and abort the whole sync transaction. Empty values aren't stored.
|
||||
const deduped = new Map<string, ForkDependentValue>()
|
||||
for (const entry of values) {
|
||||
if (entry.value === '') continue
|
||||
deduped.set(
|
||||
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
|
||||
entry
|
||||
)
|
||||
}
|
||||
const rows = Array.from(deduped.values()).map((entry) => ({
|
||||
id: generateId(),
|
||||
childWorkspaceId,
|
||||
targetWorkflowId: entry.targetWorkflowId,
|
||||
targetBlockId: entry.targetBlockId,
|
||||
subBlockKey: entry.subBlockKey,
|
||||
value: entry.value,
|
||||
}))
|
||||
if (rows.length === 0) return
|
||||
await executor.insert(workspaceForkDependentValue).values(rows)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
|
||||
|
||||
const { mockFilterExisting, mockGetCredentialProviders, mockGetEnvKeys } = vi.hoisted(() => ({
|
||||
mockFilterExisting: vi.fn(),
|
||||
mockGetCredentialProviders: vi.fn(),
|
||||
mockGetEnvKeys: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({
|
||||
listForkResourceCandidates: vi.fn(),
|
||||
classifyCredentialResourceType: vi.fn(),
|
||||
getWorkspaceEnvKeys: mockGetEnvKeys,
|
||||
filterExistingForkTargets: mockFilterExisting,
|
||||
getCredentialProvidersByIds: mockGetCredentialProviders,
|
||||
CANDIDATE_LIMIT: 1000,
|
||||
}))
|
||||
|
||||
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import {
|
||||
findDuplicateTargetEntry,
|
||||
validateForkMappingTargets,
|
||||
} from '@/lib/workspaces/fork/mapping/mapping-service'
|
||||
|
||||
type ExistingByKind = Partial<Record<ForkRemapKind, Set<string>>>
|
||||
|
||||
describe('validateForkMappingTargets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFilterExisting.mockResolvedValue({} as ExistingByKind)
|
||||
mockGetEnvKeys.mockResolvedValue(new Set<string>())
|
||||
mockGetCredentialProviders.mockResolvedValue(new Map<string, string | null>())
|
||||
})
|
||||
|
||||
it('rejects a workflow-type entry with a target (identity is system-managed)', async () => {
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'workflow', sourceId: 'wf-src', targetId: 'wf-tgt' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('short-circuits without querying when no entry has a target', async () => {
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: null },
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
expect(mockFilterExisting).not.toHaveBeenCalled()
|
||||
expect(mockGetEnvKeys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts an env-var whose target key exists in the target workspace', async () => {
|
||||
mockGetEnvKeys.mockResolvedValue(new Set(['API_KEY']))
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'API_KEY' },
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an env-var whose target key is not in the target workspace', async () => {
|
||||
mockGetEnvKeys.mockResolvedValue(new Set())
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'env_var', sourceId: 'API_KEY', targetId: 'missing' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('accepts a target validated by exact id even when picker lists are capped', async () => {
|
||||
// filterExistingForkTargets checks by exact id (cap-free), so a target that would
|
||||
// sit past the candidate cap still validates.
|
||||
mockFilterExisting.mockResolvedValue({ table: new Set(['table-1001']) })
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'table', sourceId: 'table-src', targetId: 'table-1001' },
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a target that does not exist in the target workspace', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ table: new Set<string>() })
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'table', sourceId: 'table-src', targetId: 'table-gone' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('rejects a credential whose target provider differs from the source provider', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
|
||||
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
|
||||
workspaceId === 'ws-source'
|
||||
? new Map([['cred-src', 'google-email']])
|
||||
: new Map([['cred-tgt', 'google-calendar']])
|
||||
)
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'oauth_credential', sourceId: 'cred-src', targetId: 'cred-tgt' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
|
||||
it('accepts a credential whose target provider matches the source provider', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
|
||||
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
|
||||
workspaceId === 'ws-source'
|
||||
? new Map([['cred-src', 'google-email']])
|
||||
: new Map([['cred-tgt', 'google-email']])
|
||||
)
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'oauth_credential', sourceId: 'cred-src', targetId: 'cred-tgt' },
|
||||
])
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a credential whose source is not a credential in the source workspace', async () => {
|
||||
mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) })
|
||||
mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) =>
|
||||
workspaceId === 'ws-source'
|
||||
? new Map<string, string | null>() // cred-foreign is not in the source
|
||||
: new Map([['cred-tgt', 'google-email']])
|
||||
)
|
||||
await expect(
|
||||
validateForkMappingTargets('ws-source', 'ws-target', [
|
||||
{ resourceType: 'oauth_credential', sourceId: 'cred-foreign', targetId: 'cred-tgt' },
|
||||
])
|
||||
).rejects.toBeInstanceOf(ForkError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDuplicateTargetEntry', () => {
|
||||
it('returns null when every target is used by at most one source', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 't1' },
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 't2' },
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('flags two distinct sources mapped to the same target', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'shared' },
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: 'shared' },
|
||||
])
|
||||
).toEqual({ resourceType: 'oauth_credential', targetId: 'shared' })
|
||||
})
|
||||
|
||||
it('ignores cleared (null target) entries', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: null },
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c2', targetId: null },
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not flag the same source+target repeated', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
|
||||
{ resourceType: 'table', sourceId: 'c1', targetId: 't1' },
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not conflate the same target id across resource types', () => {
|
||||
expect(
|
||||
findDuplicateTargetEntry([
|
||||
{ resourceType: 'oauth_credential', sourceId: 'c1', targetId: 'same' },
|
||||
{ resourceType: 'table', sourceId: 'c2', targetId: 'same' },
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,421 @@
|
||||
import { db } from '@sim/db'
|
||||
import type { ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { listDeployedWorkflows, readDeployedState } from '@/lib/workspaces/fork/copy/deploy-bridge'
|
||||
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
|
||||
import { detectForkCascadeReferences } from '@/lib/workspaces/fork/mapping/cascade'
|
||||
import {
|
||||
buildForkResolver,
|
||||
deleteEdgeMappingsByChildResources,
|
||||
type ForkResourceType,
|
||||
getEdgeMappingRows,
|
||||
nonCredentialForkKindToResourceType,
|
||||
resourceTypeToForkKind,
|
||||
upsertEdgeMappings,
|
||||
} from '@/lib/workspaces/fork/mapping/mapping-store'
|
||||
import {
|
||||
CANDIDATE_LIMIT,
|
||||
classifyCredentialResourceType,
|
||||
type ForkResourceCandidate,
|
||||
filterExistingForkTargets,
|
||||
getCredentialProvidersByIds,
|
||||
getWorkspaceEnvKeys,
|
||||
listForkResourceCandidates,
|
||||
} from '@/lib/workspaces/fork/mapping/resources'
|
||||
import {
|
||||
type ForkReference,
|
||||
type ForkRemapKind,
|
||||
scanWorkflowReferences,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
|
||||
interface ForkMappingViewParams {
|
||||
edge: ForkEdge
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
}
|
||||
|
||||
function suggestTarget(
|
||||
kind: ForkRemapKind,
|
||||
sourceLabel: string,
|
||||
sourceProviderId: string | undefined,
|
||||
candidates: ForkResourceCandidate[]
|
||||
): string | null {
|
||||
const normalized = sourceLabel.trim().toLowerCase()
|
||||
const byLabel = candidates.filter((c) => c.label.trim().toLowerCase() === normalized)
|
||||
if (kind === 'credential' && sourceProviderId) {
|
||||
const match = byLabel.find((c) => c.providerId === sourceProviderId)
|
||||
if (match) return match.id
|
||||
}
|
||||
if (byLabel.length === 1) return byLabel[0].id
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the direction-oriented mapping view: every detected source reference with
|
||||
* its current target (persisted or env identity), an auto-suggested target by
|
||||
* name/provider, and the list of target candidates the UI can choose from.
|
||||
*/
|
||||
export async function getForkMappingView(
|
||||
params: ForkMappingViewParams
|
||||
): Promise<{ entries: ForkMappingEntry[] }> {
|
||||
const { edge, sourceWorkspaceId, targetWorkspaceId } = params
|
||||
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
|
||||
|
||||
const [mappingRows, targetEnvKeys, sourceEnvKeys, sourceCandidates, targetCandidates] =
|
||||
await Promise.all([
|
||||
getEdgeMappingRows(db, edge.childWorkspaceId),
|
||||
getWorkspaceEnvKeys(db, targetWorkspaceId),
|
||||
getWorkspaceEnvKeys(db, sourceWorkspaceId),
|
||||
listForkResourceCandidates(db, sourceWorkspaceId),
|
||||
listForkResourceCandidates(db, targetWorkspaceId),
|
||||
])
|
||||
|
||||
const resolver = buildForkResolver(mappingRows, { sourceIsParent, targetEnvKeys, sourceEnvKeys })
|
||||
|
||||
const resourceTypeBySourceId = new Map<string, Exclude<ForkResourceType, 'workflow'>>()
|
||||
for (const row of mappingRows) {
|
||||
// Workflow identity rows are system-managed, not user-mappable; skip them so a
|
||||
// scanned reference can never be labeled `workflow` and the view stays within
|
||||
// the mappable-type contract.
|
||||
if (row.resourceType === 'workflow') continue
|
||||
const key = sourceIsParent ? row.parentResourceId : row.childResourceId
|
||||
if (key) resourceTypeBySourceId.set(key, row.resourceType)
|
||||
}
|
||||
|
||||
// Scan one deployed workflow state at a time and merge deduped references, so
|
||||
// peak memory stays at a single workflow state rather than all of them at once.
|
||||
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
|
||||
const referenceByKey = new Map<string, ForkReference>()
|
||||
for (const wf of deployedWorkflows) {
|
||||
const state = await readDeployedState(wf.id, sourceWorkspaceId)
|
||||
if (!state) continue
|
||||
const blocks = Object.values(state.blocks).map((block) => ({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
subBlocks: block.subBlocks as unknown,
|
||||
}))
|
||||
for (const reference of scanWorkflowReferences(blocks, () => null).references) {
|
||||
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
|
||||
}
|
||||
}
|
||||
|
||||
const cascade = await detectForkCascadeReferences({
|
||||
executor: db,
|
||||
sourceWorkspaceId,
|
||||
references: Array.from(referenceByKey.values()),
|
||||
resolve: () => null,
|
||||
})
|
||||
for (const reference of cascade.references) {
|
||||
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
|
||||
}
|
||||
const references: ForkReference[] = Array.from(referenceByKey.values())
|
||||
|
||||
// First pass: resolve each reference's stored target + the data to build its entry,
|
||||
// collecting stored target ids so existence is checked by exact id (cap-free) - a
|
||||
// valid mapping to a target past the display cap must be RETAINED, not shown unmapped.
|
||||
interface PendingEntry {
|
||||
reference: ForkReference
|
||||
resourceType: Exclude<ForkResourceType, 'workflow'>
|
||||
sourceLabel: string
|
||||
sourceProviderId: string | undefined
|
||||
candidates: ForkResourceCandidate[]
|
||||
storedTargetId: string | null
|
||||
}
|
||||
const pending: PendingEntry[] = []
|
||||
const storedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
|
||||
for (const reference of references) {
|
||||
// Only SOURCE workspace secrets are mappable; a `{{KEY}}` that isn't a source
|
||||
// workspace env var is a personal (user-scoped) secret - leave it as-is.
|
||||
if (reference.kind === 'env-var' && !sourceEnvKeys.has(reference.sourceId)) continue
|
||||
let resourceType = resourceTypeBySourceId.get(reference.sourceId)
|
||||
if (!resourceType) {
|
||||
resourceType =
|
||||
reference.kind === 'credential'
|
||||
? await classifyCredentialResourceType(db, reference.sourceId, sourceWorkspaceId)
|
||||
: nonCredentialForkKindToResourceType(reference.kind)
|
||||
}
|
||||
|
||||
const sourceCandidate = sourceCandidates[reference.kind].find(
|
||||
(c) => c.id === reference.sourceId
|
||||
)
|
||||
const sourceLabel = sourceCandidate?.label ?? reference.sourceId
|
||||
const sourceProviderId = sourceCandidate?.providerId
|
||||
// A credential reference only maps to a target credential of the SAME OAuth
|
||||
// provider - a Gmail (google-email) reference must never offer a Google Calendar
|
||||
// credential. Non-credential kinds carry no provider, so their full list stands.
|
||||
const candidates =
|
||||
reference.kind === 'credential' && sourceProviderId
|
||||
? targetCandidates[reference.kind].filter(
|
||||
(candidate) => candidate.providerId === sourceProviderId
|
||||
)
|
||||
: targetCandidates[reference.kind]
|
||||
const storedTargetId = resolver(reference.kind, reference.sourceId) ?? null
|
||||
if (storedTargetId && reference.kind !== 'env-var') {
|
||||
;(storedTargetIdsByKind[reference.kind] ??= new Set()).add(storedTargetId)
|
||||
}
|
||||
pending.push({
|
||||
reference,
|
||||
resourceType,
|
||||
sourceLabel,
|
||||
sourceProviderId,
|
||||
candidates,
|
||||
storedTargetId,
|
||||
})
|
||||
}
|
||||
|
||||
// Cap-free existence of every stored target (env vars validated against env keys).
|
||||
const existingStoredTargets = await filterExistingForkTargets(
|
||||
db,
|
||||
targetWorkspaceId,
|
||||
storedTargetIdsByKind
|
||||
)
|
||||
|
||||
const entries: ForkMappingEntry[] = []
|
||||
for (const p of pending) {
|
||||
const targetExists =
|
||||
p.storedTargetId != null &&
|
||||
(p.reference.kind === 'env-var'
|
||||
? targetEnvKeys.has(p.storedTargetId)
|
||||
: (existingStoredTargets[p.reference.kind]?.has(p.storedTargetId) ?? false))
|
||||
const currentTargetId = targetExists ? p.storedTargetId : null
|
||||
|
||||
// If the retained current target isn't in the (capped) candidate list, append it
|
||||
// so the picker can still display the current selection.
|
||||
let candidates = p.candidates
|
||||
if (currentTargetId && !candidates.some((candidate) => candidate.id === currentTargetId)) {
|
||||
candidates = [...candidates, { id: currentTargetId, label: currentTargetId }]
|
||||
}
|
||||
|
||||
const targetId =
|
||||
currentTargetId ??
|
||||
suggestTarget(p.reference.kind, p.sourceLabel, p.sourceProviderId, candidates)
|
||||
// True when `targetId` is an unconfirmed name/provider suggestion (no persisted
|
||||
// mapping). The modal treats a suggestion as a pending change so it shows the
|
||||
// pre-sync reconfigure rather than letting an accepted suggestion silently clear
|
||||
// dependents and surface them only after the sync.
|
||||
const suggested = currentTargetId == null && targetId != null
|
||||
|
||||
entries.push({
|
||||
kind: p.reference.kind,
|
||||
resourceType: p.resourceType,
|
||||
sourceId: p.reference.sourceId,
|
||||
sourceLabel: p.sourceLabel,
|
||||
targetId,
|
||||
suggested,
|
||||
required: p.reference.required,
|
||||
candidates,
|
||||
// The full (unfiltered) target list for this kind hit the cap, so the picker is
|
||||
// showing a partial list - the UI tells the user to refine.
|
||||
candidatesTruncated: targetCandidates[p.reference.kind].length >= CANDIDATE_LIMIT,
|
||||
})
|
||||
}
|
||||
|
||||
return { entries }
|
||||
}
|
||||
|
||||
export interface ApplyForkMappingEntry {
|
||||
resourceType: ForkResourceType
|
||||
sourceId: string
|
||||
targetId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The first target two distinct sources are mapped to (same resourceType + targetId,
|
||||
* different sourceId), or null when every target is used by at most one source. Cleared
|
||||
* entries (null target) are ignored. Used by the PUSH path only: a push row is unique on
|
||||
* the parent (target) side, so such a pair collides on that unique index and one mapping
|
||||
* would be silently dropped - the caller rejects it instead. Pull is the inverse (many
|
||||
* parent sources may share one child target, which the resolver handles), so pull does not
|
||||
* use this guard.
|
||||
*/
|
||||
export function findDuplicateTargetEntry(
|
||||
entries: ApplyForkMappingEntry[]
|
||||
): { resourceType: ForkResourceType; targetId: string } | null {
|
||||
const sourcesByTarget = new Map<string, Set<string>>()
|
||||
for (const entry of entries) {
|
||||
if (entry.targetId == null) continue
|
||||
// Null-byte separator so a targetId containing ':' (e.g. credentialSet:...) can't
|
||||
// be confused with a different (resourceType, targetId) pair.
|
||||
const key = `${entry.resourceType}\u0000${entry.targetId}`
|
||||
const sources = sourcesByTarget.get(key)
|
||||
if (!sources) {
|
||||
sourcesByTarget.set(key, new Set([entry.sourceId]))
|
||||
continue
|
||||
}
|
||||
sources.add(entry.sourceId)
|
||||
if (sources.size > 1) return { resourceType: entry.resourceType, targetId: entry.targetId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist mapping edits for a direction. Pull maps a parent source to a child
|
||||
* target; push maps a child source to a parent target (clearing a push mapping
|
||||
* deletes the row).
|
||||
*/
|
||||
export async function applyForkMappingEntries(
|
||||
tx: DbOrTx,
|
||||
edge: ForkEdge,
|
||||
userId: string,
|
||||
direction: 'push' | 'pull',
|
||||
entries: ApplyForkMappingEntry[]
|
||||
): Promise<number> {
|
||||
if (entries.length === 0) return 0
|
||||
if (direction === 'pull') {
|
||||
// Pull maps a parent source to a child target - one batched upsert.
|
||||
await upsertEdgeMappings(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
userId,
|
||||
entries.map((entry) => ({
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.sourceId,
|
||||
childResourceId: entry.targetId,
|
||||
}))
|
||||
)
|
||||
return entries.length
|
||||
}
|
||||
// Push rows are unique on the parent (target) side, so two distinct sources mapped to
|
||||
// the same target would collide on that index and one would be silently dropped (its
|
||||
// reference then resolves unmapped). Reject loudly - on push each parent target can back
|
||||
// only one source. (Pull is the inverse: many parent sources may share one child target,
|
||||
// which the resolver handles, so pull skips this guard. The modal also disables an
|
||||
// already-taken target on push so users never reach this error normally.)
|
||||
const collision = findDuplicateTargetEntry(entries)
|
||||
if (collision) {
|
||||
const kind = resourceTypeToForkKind(collision.resourceType) ?? collision.resourceType
|
||||
throw new ForkError(
|
||||
`Two sources are mapped to the same ${kind} target. Each target can be mapped from only one source.`,
|
||||
400
|
||||
)
|
||||
}
|
||||
// Push rows are keyed by the child (source) side, but the table's unique key is on
|
||||
// the parent side - so clear any existing row for each source first (one grouped
|
||||
// delete), otherwise changing a push target leaves the old (parent, source) row
|
||||
// behind and resolution becomes ambiguous. Then upsert the new (target, source)
|
||||
// rows in one batch; a null target is a cleared mapping (delete only, no reinsert).
|
||||
await deleteEdgeMappingsByChildResources(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
entries.map((entry) => ({ resourceType: entry.resourceType, childResourceId: entry.sourceId }))
|
||||
)
|
||||
await upsertEdgeMappings(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
userId,
|
||||
entries
|
||||
.filter((entry) => entry.targetId != null)
|
||||
.map((entry) => ({
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.targetId as string,
|
||||
childResourceId: entry.sourceId,
|
||||
}))
|
||||
)
|
||||
return entries.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject mapping entries whose chosen target does not belong to the target
|
||||
* workspace, so a caller cannot point a remapped reference (or credential-access
|
||||
* propagation) at a resource in a workspace they do not administer. Entries whose
|
||||
* resource type is not user-mappable (only `workflow`, whose identity is
|
||||
* system-managed) are rejected outright. Credential targets must additionally share
|
||||
* the source credential's OAuth provider, so a Gmail reference can never be pointed
|
||||
* at a Google Calendar credential (the UI enforces this; this is the write-side
|
||||
* boundary that catches direct API calls and stale rows).
|
||||
*/
|
||||
export async function validateForkMappingTargets(
|
||||
sourceWorkspaceId: string,
|
||||
targetWorkspaceId: string,
|
||||
entries: ApplyForkMappingEntry[]
|
||||
): Promise<void> {
|
||||
const withTarget = entries.filter((entry) => entry.targetId != null)
|
||||
if (withTarget.length === 0) return
|
||||
|
||||
// Collect the exact target ids per kind so existence is checked by id, NOT against
|
||||
// the display-capped candidate list - a valid target that simply sits past the cap
|
||||
// must never be rejected on save.
|
||||
const targetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
let hasEnvVar = false
|
||||
for (const entry of withTarget) {
|
||||
const kind = resourceTypeToForkKind(entry.resourceType)
|
||||
if (!kind) {
|
||||
// `workflow` is the only null-kind type, and its identity is system-managed by
|
||||
// fork/promote/rollback. A non-null target for it here is an invalid (or
|
||||
// crafted) entry the editor must never persist.
|
||||
throw new ForkError(
|
||||
`Resource type "${entry.resourceType}" cannot be mapped via the mapping editor`,
|
||||
400
|
||||
)
|
||||
}
|
||||
if (kind === 'env-var') {
|
||||
hasEnvVar = true
|
||||
continue
|
||||
}
|
||||
;(targetIdsByKind[kind] ??= new Set()).add(entry.targetId as string)
|
||||
}
|
||||
|
||||
const credentialEntries = withTarget.filter(
|
||||
(entry) => resourceTypeToForkKind(entry.resourceType) === 'credential'
|
||||
)
|
||||
|
||||
const [existingTargets, targetEnvKeys, sourceProviders, targetProviders] = await Promise.all([
|
||||
filterExistingForkTargets(db, targetWorkspaceId, targetIdsByKind),
|
||||
hasEnvVar ? getWorkspaceEnvKeys(db, targetWorkspaceId) : Promise.resolve(new Set<string>()),
|
||||
getCredentialProvidersByIds(
|
||||
db,
|
||||
sourceWorkspaceId,
|
||||
credentialEntries.map((entry) => entry.sourceId)
|
||||
),
|
||||
getCredentialProvidersByIds(
|
||||
db,
|
||||
targetWorkspaceId,
|
||||
credentialEntries.map((entry) => entry.targetId as string)
|
||||
),
|
||||
])
|
||||
|
||||
for (const entry of withTarget) {
|
||||
const kind = resourceTypeToForkKind(entry.resourceType)
|
||||
if (!kind) continue
|
||||
const targetId = entry.targetId as string
|
||||
|
||||
if (kind === 'env-var') {
|
||||
if (!targetEnvKeys.has(targetId)) {
|
||||
throw new ForkError(
|
||||
`Mapping target "${targetId}" is not an environment variable in the target workspace`,
|
||||
400
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existingTargets[kind]?.has(targetId)) {
|
||||
throw new ForkError(
|
||||
`Mapping target "${targetId}" is not a valid ${kind} in the target workspace`,
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === 'credential') {
|
||||
// The source must be a real credential in the source workspace. A foreign id
|
||||
// (not present) would skip the provider check and let a crafted mapping drive
|
||||
// cross-workspace credential-access propagation on promote.
|
||||
if (!sourceProviders.has(entry.sourceId)) {
|
||||
throw new ForkError(
|
||||
`Source credential "${entry.sourceId}" is not a credential in the source workspace`,
|
||||
400
|
||||
)
|
||||
}
|
||||
const sourceProviderId = sourceProviders.get(entry.sourceId)
|
||||
const targetProviderId = targetProviders.get(targetId) ?? null
|
||||
if (sourceProviderId && targetProviderId !== sourceProviderId) {
|
||||
throw new ForkError(
|
||||
`Mapping target "${targetId}" must use the same provider as the source credential`,
|
||||
400
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildForkResolver, type ForkMappingRow } from '@/lib/workspaces/fork/mapping/mapping-store'
|
||||
|
||||
const credentialRow: ForkMappingRow = {
|
||||
id: 'm1',
|
||||
childWorkspaceId: 'ws-child',
|
||||
resourceType: 'oauth_credential',
|
||||
parentResourceId: 'cred-parent',
|
||||
childResourceId: 'cred-child',
|
||||
}
|
||||
|
||||
describe('buildForkResolver', () => {
|
||||
it('resolves source->target for a pull (source is parent)', () => {
|
||||
const resolve = buildForkResolver([credentialRow], { sourceIsParent: true })
|
||||
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
|
||||
})
|
||||
|
||||
it('resolves source->target for a push (source is child)', () => {
|
||||
const resolve = buildForkResolver([credentialRow], { sourceIsParent: false })
|
||||
expect(resolve('credential', 'cred-child')).toBe('cred-parent')
|
||||
})
|
||||
|
||||
it('skips unmapped rows (null childResourceId)', () => {
|
||||
const resolve = buildForkResolver([{ ...credentialRow, childResourceId: null }], {
|
||||
sourceIsParent: true,
|
||||
})
|
||||
expect(resolve('credential', 'cred-parent')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a mapped target that no longer exists in the target workspace', () => {
|
||||
const resolve = buildForkResolver([credentialRow], {
|
||||
sourceIsParent: true,
|
||||
// target cred-child was deleted after the mapping was saved
|
||||
validTargetIdsByKind: { credential: new Set<string>() },
|
||||
})
|
||||
expect(resolve('credential', 'cred-parent')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a mapped target that still exists in the target workspace', () => {
|
||||
const resolve = buildForkResolver([credentialRow], {
|
||||
sourceIsParent: true,
|
||||
validTargetIdsByKind: { credential: new Set(['cred-child']) },
|
||||
})
|
||||
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
|
||||
})
|
||||
|
||||
it('does not existence-check kinds absent from validTargetIdsByKind', () => {
|
||||
const resolve = buildForkResolver([credentialRow], {
|
||||
sourceIsParent: true,
|
||||
validTargetIdsByKind: { table: new Set<string>() },
|
||||
})
|
||||
expect(resolve('credential', 'cred-parent')).toBe('cred-child')
|
||||
})
|
||||
|
||||
it('falls back to identity for a workspace env key present in the target', () => {
|
||||
const resolve = buildForkResolver([], {
|
||||
sourceIsParent: true,
|
||||
sourceEnvKeys: new Set(['API_KEY']),
|
||||
targetEnvKeys: new Set(['API_KEY']),
|
||||
})
|
||||
expect(resolve('env-var', 'API_KEY')).toBe('API_KEY')
|
||||
})
|
||||
|
||||
it('leaves a personal (non-source-workspace) env key as-is', () => {
|
||||
const resolve = buildForkResolver([], {
|
||||
sourceIsParent: true,
|
||||
sourceEnvKeys: new Set(['WORKSPACE_KEY']),
|
||||
targetEnvKeys: new Set(),
|
||||
})
|
||||
expect(resolve('env-var', 'PERSONAL_KEY')).toBe('PERSONAL_KEY')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
import { workspaceForkResourceMap } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, asc, eq, inArray, or, sql } from 'drizzle-orm'
|
||||
import type { z } from 'zod'
|
||||
import type { forkResourceTypeSchema } from '@/lib/api/contracts/workspace-fork'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type {
|
||||
ForkReferenceResolver,
|
||||
ForkRemapKind,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
|
||||
/** Mapping rows per insert; each row binds ~8 params, keeping well under PG's limit. */
|
||||
const MAPPING_INSERT_CHUNK = 1000
|
||||
|
||||
/** Derived from the wire contract so the DB enum, Zod schema, and TS type stay in lockstep. */
|
||||
export type ForkResourceType = z.infer<typeof forkResourceTypeSchema>
|
||||
|
||||
export interface ForkMappingRow {
|
||||
id: string
|
||||
childWorkspaceId: string
|
||||
resourceType: ForkResourceType
|
||||
parentResourceId: string
|
||||
childResourceId: string | null
|
||||
}
|
||||
|
||||
export interface ForkMappingUpsert {
|
||||
resourceType: ForkResourceType
|
||||
parentResourceId: string
|
||||
childResourceId: string | null
|
||||
}
|
||||
|
||||
const RESOURCE_TYPE_TO_FORK_KIND: Record<ForkResourceType, ForkRemapKind | null> = {
|
||||
workflow: null,
|
||||
oauth_credential: 'credential',
|
||||
service_account_credential: 'credential',
|
||||
env_var: 'env-var',
|
||||
table: 'table',
|
||||
knowledge_base: 'knowledge-base',
|
||||
knowledge_document: 'knowledge-document',
|
||||
file: 'file',
|
||||
mcp_server: 'mcp-server',
|
||||
custom_tool: 'custom-tool',
|
||||
skill: 'skill',
|
||||
}
|
||||
|
||||
/** The remapper kind a stored resource type participates in, or null when it does not remap. */
|
||||
export function resourceTypeToForkKind(resourceType: ForkResourceType): ForkRemapKind | null {
|
||||
return RESOURCE_TYPE_TO_FORK_KIND[resourceType]
|
||||
}
|
||||
|
||||
const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE: Record<
|
||||
Exclude<ForkRemapKind, 'credential'>,
|
||||
Exclude<ForkResourceType, 'workflow'>
|
||||
> = {
|
||||
'env-var': 'env_var',
|
||||
table: 'table',
|
||||
'knowledge-base': 'knowledge_base',
|
||||
'knowledge-document': 'knowledge_document',
|
||||
file: 'file',
|
||||
'mcp-server': 'mcp_server',
|
||||
'custom-tool': 'custom_tool',
|
||||
skill: 'skill',
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored resource type for a non-credential remap kind. Credentials are resolved
|
||||
* separately via `classifyCredentialResourceType` since the type (oauth vs
|
||||
* service account) depends on the credential row.
|
||||
*/
|
||||
export function nonCredentialForkKindToResourceType(
|
||||
kind: Exclude<ForkRemapKind, 'credential'>
|
||||
): Exclude<ForkResourceType, 'workflow'> {
|
||||
return NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE[kind]
|
||||
}
|
||||
|
||||
export async function getEdgeMappingRows(
|
||||
executor: DbOrTx,
|
||||
childWorkspaceId: string
|
||||
): Promise<ForkMappingRow[]> {
|
||||
const rows = await executor
|
||||
.select({
|
||||
id: workspaceForkResourceMap.id,
|
||||
childWorkspaceId: workspaceForkResourceMap.childWorkspaceId,
|
||||
resourceType: workspaceForkResourceMap.resourceType,
|
||||
parentResourceId: workspaceForkResourceMap.parentResourceId,
|
||||
childResourceId: workspaceForkResourceMap.childResourceId,
|
||||
})
|
||||
.from(workspaceForkResourceMap)
|
||||
.where(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId))
|
||||
// Deterministic order so resolver/identity construction is stable if duplicates
|
||||
// ever exist (the push edit + rollback cleanup prevent them, this is defense).
|
||||
.orderBy(asc(workspaceForkResourceMap.createdAt), asc(workspaceForkResourceMap.id))
|
||||
return rows as ForkMappingRow[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workflow-identity mapping rows by the ids on one side (parent or child).
|
||||
* Used by rollback to dissolve the identity rows a promote created, so a later
|
||||
* re-promote of the same source converges instead of leaking a second row.
|
||||
*/
|
||||
export async function deleteWorkflowIdentityByIds(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
side: 'parent' | 'child',
|
||||
ids: string[]
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return
|
||||
const sideColumn =
|
||||
side === 'parent'
|
||||
? workspaceForkResourceMap.parentResourceId
|
||||
: workspaceForkResourceMap.childResourceId
|
||||
await tx
|
||||
.delete(workspaceForkResourceMap)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId),
|
||||
eq(workspaceForkResourceMap.resourceType, 'workflow'),
|
||||
inArray(sideColumn, ids)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert mapping rows that don't already exist (used at fork time to seed every
|
||||
* detected reference as unmapped). Existing rows are left untouched.
|
||||
*/
|
||||
export async function seedEdgeMappings(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
userId: string,
|
||||
entries: ForkMappingUpsert[]
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return
|
||||
const now = new Date()
|
||||
// Chunked so a fork copying many resources stays well under the Postgres bind
|
||||
// parameter limit (each row binds ~8 params).
|
||||
for (let i = 0; i < entries.length; i += MAPPING_INSERT_CHUNK) {
|
||||
const batch = entries.slice(i, i + MAPPING_INSERT_CHUNK)
|
||||
await tx
|
||||
.insert(workspaceForkResourceMap)
|
||||
.values(
|
||||
batch.map((entry) => ({
|
||||
id: generateId(),
|
||||
childWorkspaceId,
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.parentResourceId,
|
||||
childResourceId: entry.childResourceId,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
workspaceForkResourceMap.childWorkspaceId,
|
||||
workspaceForkResourceMap.resourceType,
|
||||
workspaceForkResourceMap.parentResourceId,
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update mapping rows in batched, chunked multi-row upserts, setting
|
||||
* `childResourceId` (the chosen target) from the incoming row. Used when a user
|
||||
* saves a mapping and to persist promote identity rows - one query per chunk
|
||||
* instead of one per row, so a large save stays a short transaction.
|
||||
*
|
||||
* Entries are deduped by the conflict key (resourceType, parentResourceId), keeping
|
||||
* the last (matching the prior per-row last-write-wins) so a batch can never trip
|
||||
* Postgres's "ON CONFLICT DO UPDATE cannot affect row a second time".
|
||||
*/
|
||||
export async function upsertEdgeMappings(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
userId: string,
|
||||
entries: ForkMappingUpsert[]
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return
|
||||
const now = new Date()
|
||||
const byConflictKey = new Map<string, ForkMappingUpsert>()
|
||||
for (const entry of entries) {
|
||||
byConflictKey.set(`${entry.resourceType}:${entry.parentResourceId}`, entry)
|
||||
}
|
||||
const deduped = Array.from(byConflictKey.values())
|
||||
for (let i = 0; i < deduped.length; i += MAPPING_INSERT_CHUNK) {
|
||||
const batch = deduped.slice(i, i + MAPPING_INSERT_CHUNK)
|
||||
await tx
|
||||
.insert(workspaceForkResourceMap)
|
||||
.values(
|
||||
batch.map((entry) => ({
|
||||
id: generateId(),
|
||||
childWorkspaceId,
|
||||
resourceType: entry.resourceType,
|
||||
parentResourceId: entry.parentResourceId,
|
||||
childResourceId: entry.childResourceId,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
workspaceForkResourceMap.childWorkspaceId,
|
||||
workspaceForkResourceMap.resourceType,
|
||||
workspaceForkResourceMap.parentResourceId,
|
||||
],
|
||||
set: { childResourceId: sql`excluded.child_resource_id`, updatedAt: now },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove mapping rows matched by their child-side (source) resource id, grouped by
|
||||
* resource type into a single OR-of-INs - one query for the whole push save (the
|
||||
* unique key is on the parent side, so a changed push target must drop the old
|
||||
* (parent, source) row before the new one is inserted).
|
||||
*/
|
||||
export async function deleteEdgeMappingsByChildResources(
|
||||
tx: DbOrTx,
|
||||
childWorkspaceId: string,
|
||||
pairs: Array<{ resourceType: ForkResourceType; childResourceId: string }>
|
||||
): Promise<void> {
|
||||
if (pairs.length === 0) return
|
||||
const idsByType = new Map<ForkResourceType, string[]>()
|
||||
for (const { resourceType, childResourceId } of pairs) {
|
||||
const list = idsByType.get(resourceType)
|
||||
if (list) list.push(childResourceId)
|
||||
else idsByType.set(resourceType, [childResourceId])
|
||||
}
|
||||
const conditions = Array.from(idsByType, ([resourceType, ids]) =>
|
||||
and(
|
||||
eq(workspaceForkResourceMap.resourceType, resourceType),
|
||||
inArray(workspaceForkResourceMap.childResourceId, ids)
|
||||
)
|
||||
)
|
||||
await tx
|
||||
.delete(workspaceForkResourceMap)
|
||||
.where(and(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId), or(...conditions)))
|
||||
}
|
||||
|
||||
export interface BuildForkResolverOptions {
|
||||
/** When the source side of the promote is the parent workspace (a pull). */
|
||||
sourceIsParent: boolean
|
||||
/**
|
||||
* Env keys present in the target workspace. A workspace-secret env reference with
|
||||
* no explicit mapping resolves to itself when the same key exists in the target.
|
||||
*/
|
||||
targetEnvKeys?: Set<string>
|
||||
/**
|
||||
* Env keys defined at the SOURCE workspace level. Only these are workspace secrets
|
||||
* that can be mapped; any other `{{KEY}}` is a personal (user-scoped) secret that
|
||||
* resolves identically in any workspace and is left as-is (never mapped/required).
|
||||
*/
|
||||
sourceEnvKeys?: Set<string>
|
||||
/**
|
||||
* Target ids that still EXIST in the target workspace, per kind, among the mapped
|
||||
* targets. When a kind is present, a mapped target NOT in its set is treated as
|
||||
* unmapped (the target was deleted after the mapping was saved), so a dead id is
|
||||
* never written into the promoted workflow. Kinds absent here are not existence-
|
||||
* checked (resolved as before).
|
||||
*/
|
||||
validTargetIdsByKind?: Partial<Record<ForkRemapKind, Set<string>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a reference resolver from persisted mapping rows for the chosen
|
||||
* direction. Translates a source-space resource id to its mapped target id;
|
||||
* rows whose `childResourceId` is null (unmapped) are skipped. Env keys fall
|
||||
* back to an identity mapping when the target workspace already has the key.
|
||||
*/
|
||||
export function buildForkResolver(
|
||||
rows: ForkMappingRow[],
|
||||
options: BuildForkResolverOptions
|
||||
): ForkReferenceResolver {
|
||||
const index = new Map<ForkRemapKind, Map<string, string>>()
|
||||
for (const row of rows) {
|
||||
const kind = resourceTypeToForkKind(row.resourceType)
|
||||
if (!kind) continue
|
||||
if (row.childResourceId == null) continue
|
||||
const sourceId = options.sourceIsParent ? row.parentResourceId : row.childResourceId
|
||||
const targetId = options.sourceIsParent ? row.childResourceId : row.parentResourceId
|
||||
let kindIndex = index.get(kind)
|
||||
if (!kindIndex) {
|
||||
kindIndex = new Map()
|
||||
index.set(kind, kindIndex)
|
||||
}
|
||||
kindIndex.set(sourceId, targetId)
|
||||
}
|
||||
|
||||
return (kind, sourceId) => {
|
||||
const mapped = index.get(kind)?.get(sourceId)
|
||||
if (mapped != null) {
|
||||
const validSet = options.validTargetIdsByKind?.[kind]
|
||||
if (!validSet || validSet.has(mapped)) return mapped
|
||||
// The mapped target was deleted from the target workspace after the mapping was
|
||||
// saved. Fall through so the reference resolves as unmapped (surfaced as required
|
||||
// / cleared if optional) instead of writing a dead id into the promoted workflow.
|
||||
}
|
||||
if (kind === 'env-var') {
|
||||
// Personal/global env vars (not a source workspace secret) are user-scoped and
|
||||
// resolve identically in any workspace - leave them as-is, never map them.
|
||||
if (options.sourceEnvKeys && !options.sourceEnvKeys.has(sourceId)) return sourceId
|
||||
// Workspace secret already present in the target by the same name → identity.
|
||||
if (options.targetEnvKeys?.has(sourceId)) return sourceId
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
credential,
|
||||
customTools,
|
||||
knowledgeBase,
|
||||
mcpServers,
|
||||
skill,
|
||||
userTableDefinitions,
|
||||
workflow,
|
||||
workflowDeploymentVersion,
|
||||
workspaceEnvironment,
|
||||
workspaceFiles,
|
||||
} from '@sim/db/schema'
|
||||
import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type { ForkResourceType } from '@/lib/workspaces/fork/mapping/mapping-store'
|
||||
import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references'
|
||||
|
||||
export interface ForkResourceCandidate {
|
||||
id: string
|
||||
label: string
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
export const CANDIDATE_LIMIT = 1000
|
||||
|
||||
/** The set of env-var keys defined in a workspace (for resolver identity + gating). */
|
||||
export async function getWorkspaceEnvKeys(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<Set<string>> {
|
||||
const [row] = await executor
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
const variables = row?.variables
|
||||
if (!variables || typeof variables !== 'object') return new Set()
|
||||
return new Set(Object.keys(variables as Record<string, unknown>))
|
||||
}
|
||||
|
||||
// Shared `{ id, label }` candidate queries for the content resource kinds that BOTH the
|
||||
// mapping-target picker and the fork-copy picker list - one source of the archived/deleted
|
||||
// filters so the two pickers can never drift apart. Credentials, env vars (mapping-only),
|
||||
// and files (copy-only) stay inline in their respective functions.
|
||||
const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
|
||||
executor
|
||||
.select({ id: userTableDefinitions.id, label: userTableDefinitions.name })
|
||||
.from(userTableDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableDefinitions.workspaceId, workspaceId),
|
||||
isNull(userTableDefinitions.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(CANDIDATE_LIMIT)
|
||||
|
||||
const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
|
||||
executor
|
||||
.select({ id: knowledgeBase.id, label: knowledgeBase.name })
|
||||
.from(knowledgeBase)
|
||||
.where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt)))
|
||||
.limit(CANDIDATE_LIMIT)
|
||||
|
||||
const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
|
||||
executor
|
||||
.select({ id: customTools.id, label: customTools.title })
|
||||
.from(customTools)
|
||||
.where(eq(customTools.workspaceId, workspaceId))
|
||||
.limit(CANDIDATE_LIMIT)
|
||||
|
||||
const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
|
||||
executor
|
||||
.select({ id: skill.id, label: skill.name })
|
||||
.from(skill)
|
||||
.where(eq(skill.workspaceId, workspaceId))
|
||||
.limit(CANDIDATE_LIMIT)
|
||||
|
||||
const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string) =>
|
||||
executor
|
||||
.select({ id: mcpServers.id, label: mcpServers.name })
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
|
||||
.limit(CANDIDATE_LIMIT)
|
||||
|
||||
/**
|
||||
* List the resources in a workspace that can serve as mapping targets, grouped by
|
||||
* remap kind. Used to populate the mapping UI's target pickers and to label the
|
||||
* source resources being mapped. `knowledge-document` and `file` are intentionally
|
||||
* left empty for v1 (optional kinds resolved manually).
|
||||
*/
|
||||
export async function listForkResourceCandidates(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<Record<ForkRemapKind, ForkResourceCandidate[]>> {
|
||||
const [creds, wsEnvRows, tables, kbs, servers, tools, skills] = await Promise.all([
|
||||
executor
|
||||
.select({
|
||||
id: credential.id,
|
||||
displayName: credential.displayName,
|
||||
providerId: credential.providerId,
|
||||
})
|
||||
.from(credential)
|
||||
// Only real connections are mappable credentials. `env_workspace`/`env_personal`
|
||||
// rows live in the same table but are environment variables (surfaced via the
|
||||
// 'env-var' kind), so they must never appear as credential targets.
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account'])
|
||||
)
|
||||
)
|
||||
.limit(CANDIDATE_LIMIT),
|
||||
executor
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1),
|
||||
tableCandidatesQuery(executor, workspaceId),
|
||||
knowledgeBaseCandidatesQuery(executor, workspaceId),
|
||||
mcpServerCandidatesQuery(executor, workspaceId),
|
||||
customToolCandidatesQuery(executor, workspaceId),
|
||||
skillCandidatesQuery(executor, workspaceId),
|
||||
])
|
||||
|
||||
const envVariables = wsEnvRows[0]?.variables
|
||||
const envKeys =
|
||||
envVariables && typeof envVariables === 'object'
|
||||
? Object.keys(envVariables as Record<string, unknown>)
|
||||
: []
|
||||
|
||||
return {
|
||||
credential: creds.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.displayName,
|
||||
providerId: c.providerId ?? undefined,
|
||||
})),
|
||||
'env-var': envKeys.map((key) => ({ id: key, label: key })),
|
||||
table: tables,
|
||||
'knowledge-base': kbs,
|
||||
'mcp-server': servers,
|
||||
'custom-tool': tools,
|
||||
skill: skills,
|
||||
'knowledge-document': [],
|
||||
file: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given mapped target ids grouped by kind, return the subset that still EXISTS in the
|
||||
* target workspace (same archived/deleted filters as `listForkResourceCandidates`).
|
||||
* Used at promote time so a mapping whose target was deleted after it was saved
|
||||
* resolves as unmapped (surfaced/cleared) instead of writing a dead id into the
|
||||
* promoted workflow. Queries the exact ids (not the capped candidate list) so a valid
|
||||
* target is never wrongly dropped, and only the DB-backed kinds are checked - env-var
|
||||
* existence is handled by the resolver's `targetEnvKeys`, and `file`/`workflow` are
|
||||
* resolved by other paths.
|
||||
*/
|
||||
export async function filterExistingForkTargets(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string,
|
||||
idsByKind: Partial<Record<ForkRemapKind, Set<string>>>
|
||||
): Promise<Partial<Record<ForkRemapKind, Set<string>>>> {
|
||||
const ids = (kind: ForkRemapKind): string[] => {
|
||||
const set = idsByKind[kind]
|
||||
return set && set.size > 0 ? Array.from(set) : []
|
||||
}
|
||||
const credIds = ids('credential')
|
||||
const tableIds = ids('table')
|
||||
const kbIds = ids('knowledge-base')
|
||||
const mcpIds = ids('mcp-server')
|
||||
const toolIds = ids('custom-tool')
|
||||
const skillIds = ids('skill')
|
||||
|
||||
const [creds, tables, kbs, servers, tools, skills] = await Promise.all([
|
||||
credIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: executor
|
||||
.select({ id: credential.id })
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account']),
|
||||
inArray(credential.id, credIds)
|
||||
)
|
||||
),
|
||||
tableIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: executor
|
||||
.select({ id: userTableDefinitions.id })
|
||||
.from(userTableDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(userTableDefinitions.workspaceId, workspaceId),
|
||||
isNull(userTableDefinitions.archivedAt),
|
||||
inArray(userTableDefinitions.id, tableIds)
|
||||
)
|
||||
),
|
||||
kbIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: executor
|
||||
.select({ id: knowledgeBase.id })
|
||||
.from(knowledgeBase)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBase.workspaceId, workspaceId),
|
||||
isNull(knowledgeBase.deletedAt),
|
||||
inArray(knowledgeBase.id, kbIds)
|
||||
)
|
||||
),
|
||||
mcpIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: executor
|
||||
.select({ id: mcpServers.id })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.workspaceId, workspaceId),
|
||||
isNull(mcpServers.deletedAt),
|
||||
inArray(mcpServers.id, mcpIds)
|
||||
)
|
||||
),
|
||||
toolIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: executor
|
||||
.select({ id: customTools.id })
|
||||
.from(customTools)
|
||||
.where(and(eq(customTools.workspaceId, workspaceId), inArray(customTools.id, toolIds))),
|
||||
skillIds.length === 0
|
||||
? Promise.resolve([] as Array<{ id: string }>)
|
||||
: executor
|
||||
.select({ id: skill.id })
|
||||
.from(skill)
|
||||
.where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds))),
|
||||
])
|
||||
|
||||
const result: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
if (credIds.length > 0) result.credential = new Set(creds.map((r) => r.id))
|
||||
if (tableIds.length > 0) result.table = new Set(tables.map((r) => r.id))
|
||||
if (kbIds.length > 0) result['knowledge-base'] = new Set(kbs.map((r) => r.id))
|
||||
if (mcpIds.length > 0) result['mcp-server'] = new Set(servers.map((r) => r.id))
|
||||
if (toolIds.length > 0) result['custom-tool'] = new Set(tools.map((r) => r.id))
|
||||
if (skillIds.length > 0) result.skill = new Set(skills.map((r) => r.id))
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider id for each given credential id in a workspace, looked up by exact id (no
|
||||
* candidate cap). Presence in the returned map means the credential exists in the
|
||||
* workspace, so this doubles as a cap-free existence + provider check for validation.
|
||||
*/
|
||||
export async function getCredentialProvidersByIds(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string,
|
||||
ids: string[]
|
||||
): Promise<Map<string, string | null>> {
|
||||
if (ids.length === 0) return new Map()
|
||||
const rows = await executor
|
||||
.select({ id: credential.id, providerId: credential.providerId })
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
inArray(credential.type, ['oauth', 'service_account']),
|
||||
inArray(credential.id, ids)
|
||||
)
|
||||
)
|
||||
return new Map(rows.map((row) => [row.id, row.providerId ?? null]))
|
||||
}
|
||||
|
||||
export interface ForkCopyableResources {
|
||||
files: ForkResourceCandidate[]
|
||||
tables: ForkResourceCandidate[]
|
||||
knowledgeBases: ForkResourceCandidate[]
|
||||
customTools: ForkResourceCandidate[]
|
||||
skills: ForkResourceCandidate[]
|
||||
mcpServers: ForkResourceCandidate[]
|
||||
/**
|
||||
* Count of deployed workflows that the fork would copy. When 0, the fork modal shows an
|
||||
* informational note (forking is never blocked) - create-fork seeds a blank starter
|
||||
* workflow so the child is still a usable workspace.
|
||||
*/
|
||||
deployedWorkflowCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* List the resources in a workspace that can be selected for copy at fork time
|
||||
* (the content kinds — never credentials or env vars). Powers the fork modal's
|
||||
* resource picker.
|
||||
*/
|
||||
export async function listForkCopyableResources(
|
||||
executor: DbOrTx,
|
||||
workspaceId: string
|
||||
): Promise<ForkCopyableResources> {
|
||||
const [files, tables, kbs, tools, skills, servers, deployed] = await Promise.all([
|
||||
executor
|
||||
.select({
|
||||
id: workspaceFiles.id,
|
||||
// displayName is nullable; fall back to the (non-null) original name.
|
||||
label: sql<string>`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`,
|
||||
})
|
||||
.from(workspaceFiles)
|
||||
// Only durable workspace files are forkable - chat/copilot/mothership uploads are
|
||||
// session-scoped attachments (and their chat-bound unique index can't be copied).
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.workspaceId, workspaceId),
|
||||
eq(workspaceFiles.context, 'workspace'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(CANDIDATE_LIMIT),
|
||||
tableCandidatesQuery(executor, workspaceId),
|
||||
knowledgeBaseCandidatesQuery(executor, workspaceId),
|
||||
customToolCandidatesQuery(executor, workspaceId),
|
||||
skillCandidatesQuery(executor, workspaceId),
|
||||
mcpServerCandidatesQuery(executor, workspaceId),
|
||||
executor
|
||||
.select({ value: count() })
|
||||
.from(workflow)
|
||||
// Match listDeployedWorkflows: a workflow only counts as copyable when it has an
|
||||
// actually-active deployment version, not just the isDeployed flag, so the fork
|
||||
// modal's preflight count never over-reports "ghost" deployed workflows.
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
eq(workflow.isDeployed, true),
|
||||
isNull(workflow.archivedAt),
|
||||
exists(
|
||||
executor
|
||||
.select({ one: sql`1` })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflow.id),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
return {
|
||||
files,
|
||||
tables,
|
||||
knowledgeBases: kbs,
|
||||
customTools: tools,
|
||||
skills,
|
||||
mcpServers: servers,
|
||||
deployedWorkflowCount: deployed[0]?.value ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a credential id to its stored mapping resource type. */
|
||||
export async function classifyCredentialResourceType(
|
||||
executor: DbOrTx,
|
||||
credentialId: string,
|
||||
workspaceId: string
|
||||
): Promise<Extract<ForkResourceType, 'oauth_credential' | 'service_account_credential'>> {
|
||||
const [row] = await executor
|
||||
.select({ type: credential.type })
|
||||
.from(credential)
|
||||
.where(and(eq(credential.id, credentialId), eq(credential.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
return row?.type === 'service_account' ? 'service_account_credential' : 'oauth_credential'
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPromoteWorkflowIdMap } from '@/lib/workspaces/fork/promote/promote-plan'
|
||||
|
||||
/**
|
||||
* `buildPromoteWorkflowIdMap` decides which cross-workflow references survive a
|
||||
* promote: the resulting map is handed to `remapWorkflowReferencesInSubBlocks`,
|
||||
* where a hit repoints the reference and a miss (with `clearUnmapped`) blanks it.
|
||||
* These cases lock in the seed/overlay matrix so the "mapped sibling not in this
|
||||
* push" repoint and the "deleted / archived / never-mapped" clears can't drift.
|
||||
*/
|
||||
describe('buildPromoteWorkflowIdMap', () => {
|
||||
it("overlays this push's items (replace + create)", () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map(),
|
||||
existingSourceIds: new Set(),
|
||||
targetActiveIds: new Set(),
|
||||
items: [
|
||||
{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' },
|
||||
{ sourceWorkflowId: 'b-src', targetWorkflowId: 'b-new' },
|
||||
],
|
||||
})
|
||||
expect(map.get('a-src')).toBe('a-tgt')
|
||||
expect(map.get('b-src')).toBe('b-new')
|
||||
expect(map.size).toBe(2)
|
||||
})
|
||||
|
||||
it('repoints a mapped sibling that is not in this push when source exists and target is active', () => {
|
||||
// B is mapped + still deployed in the target but undeployed in the source, so it
|
||||
// is not an item this push. A references B and must keep pointing at target-B.
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['b-src', 'b-tgt']]),
|
||||
existingSourceIds: new Set(['b-src']),
|
||||
targetActiveIds: new Set(['b-tgt']),
|
||||
items: [{ sourceWorkflowId: 'a-src', targetWorkflowId: 'a-tgt' }],
|
||||
})
|
||||
expect(map.get('b-src')).toBe('b-tgt')
|
||||
expect(map.get('a-src')).toBe('a-tgt')
|
||||
})
|
||||
|
||||
it('does not seed a mapped pair whose source was deleted (reference clears)', () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['b-src', 'b-tgt']]),
|
||||
existingSourceIds: new Set(), // b-src deleted in the source
|
||||
targetActiveIds: new Set(['b-tgt']),
|
||||
items: [],
|
||||
})
|
||||
expect(map.has('b-src')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not seed a mapped pair whose target was archived (reference clears)', () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['b-src', 'b-tgt']]),
|
||||
existingSourceIds: new Set(['b-src']),
|
||||
targetActiveIds: new Set(), // b-tgt archived by a prior push
|
||||
items: [],
|
||||
})
|
||||
expect(map.has('b-src')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not map a workflow that was never mapped (reference clears)', () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['b-src', 'b-tgt']]),
|
||||
existingSourceIds: new Set(['b-src', 'c-src']),
|
||||
targetActiveIds: new Set(['b-tgt']),
|
||||
items: [],
|
||||
})
|
||||
expect(map.has('c-src')).toBe(false)
|
||||
})
|
||||
|
||||
it('lets this push override a stale identity mapping (re-created target wins)', () => {
|
||||
const map = buildPromoteWorkflowIdMap({
|
||||
identityMap: new Map([['s', 't-old']]),
|
||||
existingSourceIds: new Set(['s']),
|
||||
targetActiveIds: new Set(['t-old']),
|
||||
items: [{ sourceWorkflowId: 's', targetWorkflowId: 't-new' }],
|
||||
})
|
||||
expect(map.get('s')).toBe('t-new')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import type { DeployedWorkflowSummary } from '@/lib/workspaces/fork/copy/deploy-bridge'
|
||||
import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage'
|
||||
import { detectForkCascadeReferences } from '@/lib/workspaces/fork/mapping/cascade'
|
||||
import {
|
||||
buildForkResolver,
|
||||
getEdgeMappingRows,
|
||||
resourceTypeToForkKind,
|
||||
} from '@/lib/workspaces/fork/mapping/mapping-store'
|
||||
import {
|
||||
filterExistingForkTargets,
|
||||
getWorkspaceEnvKeys,
|
||||
} from '@/lib/workspaces/fork/mapping/resources'
|
||||
import {
|
||||
type ForkReference,
|
||||
type ForkReferenceResolver,
|
||||
type ForkRemapKind,
|
||||
scanWorkflowReferences,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export interface ForkPromotePlanItem {
|
||||
sourceWorkflowId: string
|
||||
targetWorkflowId: string
|
||||
/** The matched target workflow's current name (for rename-aware mapping), null when creating. */
|
||||
targetName: string | null
|
||||
mode: 'create' | 'replace'
|
||||
sourceMeta: {
|
||||
name: string
|
||||
description: string | null
|
||||
folderId: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ForkPromotePlan {
|
||||
childWorkspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
resolver: ForkReferenceResolver
|
||||
items: ForkPromotePlanItem[]
|
||||
workflowIdMap: Map<string, string>
|
||||
/** Previously-mapped target workflows whose source no longer exists (to remove). */
|
||||
archivedTargetIds: string[]
|
||||
/** Same as `archivedTargetIds`, with the target workflow name for the preview. */
|
||||
archivedTargets: Array<{ id: string; name: string }>
|
||||
|
||||
references: ForkReference[]
|
||||
unmappedRequired: ForkReference[]
|
||||
unmappedOptional: ForkReference[]
|
||||
/** Source MCP server ids that use OAuth and need re-authorization in the target. */
|
||||
mcpReauthServerIds: string[]
|
||||
/** Review-only descriptions of inline secrets that cannot be id-mapped. */
|
||||
inlineSecretSources: string[]
|
||||
willUpdate: number
|
||||
willCreate: number
|
||||
willArchive: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cross-workflow reference map used to rewrite `workflow-selector`,
|
||||
* `manualWorkflowId`, and `workflow_input` references inside promoted workflows.
|
||||
*
|
||||
* Seeded from the persistent identity mappings - not just the workflows in THIS
|
||||
* push - so a reference to a mapped sibling that isn't part of the current push
|
||||
* (e.g. a workflow undeployed in the source but still existing and already
|
||||
* deployed in the target) repoints at the existing target instead of clearing.
|
||||
* Only pairs whose source still EXISTS and whose target is still ACTIVE are
|
||||
* seeded: a deleted source (whose target is archived this push) stays unmapped so
|
||||
* its inbound references clear, and a target archived by a prior push is never
|
||||
* re-pointed at. The push's own items are overlaid last, so a created workflow
|
||||
* contributes its fresh target id and a replaced one re-sets the same id.
|
||||
*/
|
||||
export function buildPromoteWorkflowIdMap(params: {
|
||||
identityMap: Map<string, string>
|
||||
existingSourceIds: Set<string>
|
||||
targetActiveIds: Set<string>
|
||||
items: Array<{ sourceWorkflowId: string; targetWorkflowId: string }>
|
||||
}): Map<string, string> {
|
||||
const { identityMap, existingSourceIds, targetActiveIds, items } = params
|
||||
const workflowIdMap = new Map<string, string>()
|
||||
for (const [sourceId, targetId] of identityMap) {
|
||||
if (existingSourceIds.has(sourceId) && targetActiveIds.has(targetId)) {
|
||||
workflowIdMap.set(sourceId, targetId)
|
||||
}
|
||||
}
|
||||
for (const item of items) workflowIdMap.set(item.sourceWorkflowId, item.targetWorkflowId)
|
||||
return workflowIdMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute everything a promote needs without mutating. Only the source's
|
||||
* **deployed** workflows participate; each plan item carries the source's active
|
||||
* deployed state. Targets matched by the persisted workflow identity map are
|
||||
* replaced; unmatched deployed sources create new targets. A target is archived
|
||||
* only when it was previously mapped and its source is no longer deployed -
|
||||
* target-native workflows are never touched. Shared by the diff preview and the
|
||||
* promote orchestrator.
|
||||
*/
|
||||
export async function computeForkPromotePlan(params: {
|
||||
executor: DbOrTx
|
||||
edge: ForkEdge
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
/**
|
||||
* Source deployed workflows + their states, read by the caller BEFORE its
|
||||
* transaction (see `loadSourceDeployedStates`) so the plan never checks out a
|
||||
* second pooled connection from inside a tx.
|
||||
*/
|
||||
deployedSourceWorkflows: DeployedWorkflowSummary[]
|
||||
sourceStates: Map<string, WorkflowState>
|
||||
}): Promise<ForkPromotePlan> {
|
||||
const {
|
||||
executor,
|
||||
edge,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
direction,
|
||||
deployedSourceWorkflows,
|
||||
sourceStates,
|
||||
} = params
|
||||
|
||||
const mappingRows = await getEdgeMappingRows(executor, edge.childWorkspaceId)
|
||||
const [targetEnvKeys, sourceEnvKeys] = await Promise.all([
|
||||
getWorkspaceEnvKeys(executor, targetWorkspaceId),
|
||||
getWorkspaceEnvKeys(executor, sourceWorkspaceId),
|
||||
])
|
||||
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
|
||||
|
||||
// Collect each mapping's chosen target id (per kind) and keep only those that still
|
||||
// exist in the target workspace, so a target deleted after the mapping was saved
|
||||
// resolves as unmapped instead of writing a dead id into the promoted workflow.
|
||||
const mappedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
|
||||
for (const row of mappingRows) {
|
||||
const kind = resourceTypeToForkKind(row.resourceType)
|
||||
if (!kind) continue
|
||||
const targetId = sourceIsParent ? row.childResourceId : row.parentResourceId
|
||||
if (targetId == null) continue
|
||||
const set = mappedTargetIdsByKind[kind] ?? new Set<string>()
|
||||
set.add(targetId)
|
||||
mappedTargetIdsByKind[kind] = set
|
||||
}
|
||||
const validTargetIdsByKind = await filterExistingForkTargets(
|
||||
executor,
|
||||
targetWorkspaceId,
|
||||
mappedTargetIdsByKind
|
||||
)
|
||||
|
||||
const resolver = buildForkResolver(mappingRows, {
|
||||
sourceIsParent,
|
||||
targetEnvKeys,
|
||||
sourceEnvKeys,
|
||||
validTargetIdsByKind,
|
||||
})
|
||||
|
||||
const identityMap = new Map<string, string>()
|
||||
for (const row of mappingRows) {
|
||||
if (row.resourceType !== 'workflow' || row.childResourceId == null) continue
|
||||
if (sourceIsParent) identityMap.set(row.parentResourceId, row.childResourceId)
|
||||
else identityMap.set(row.childResourceId, row.parentResourceId)
|
||||
}
|
||||
|
||||
const [targetWorkflows, sourceWorkflowRows] = await Promise.all([
|
||||
executor
|
||||
.select({ id: workflow.id, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, targetWorkspaceId), isNull(workflow.archivedAt))),
|
||||
executor
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, sourceWorkspaceId), isNull(workflow.archivedAt))),
|
||||
])
|
||||
|
||||
const targetActiveIds = new Set(targetWorkflows.map((w) => w.id))
|
||||
const targetNameById = new Map(targetWorkflows.map((w) => [w.id, w.name]))
|
||||
// Every source workflow that still EXISTS (deployed or not). A mapped target is
|
||||
// archived only when its source was DELETED - not merely undeployed. A fresh fork
|
||||
// leaves the child's workflows undeployed, so pushing back must not archive the
|
||||
// parent's originals; undeployed sources are simply skipped (target left as-is).
|
||||
const existingSourceIds = new Set(sourceWorkflowRows.map((w) => w.id))
|
||||
|
||||
// Build the items and scan references in one pass from the pre-read source states
|
||||
// (loaded before the caller's transaction; see loadSourceDeployedStates).
|
||||
const items: ForkPromotePlanItem[] = []
|
||||
const referenceByKey = new Map<string, ForkReference>()
|
||||
for (const source of deployedSourceWorkflows) {
|
||||
const sourceState = sourceStates.get(source.id)
|
||||
if (!sourceState) continue
|
||||
|
||||
const mappedTargetId = identityMap.get(source.id)
|
||||
const isReplace = Boolean(mappedTargetId && targetActiveIds.has(mappedTargetId))
|
||||
const targetWorkflowId = isReplace ? (mappedTargetId as string) : generateId()
|
||||
items.push({
|
||||
sourceWorkflowId: source.id,
|
||||
targetWorkflowId,
|
||||
targetName: isReplace ? (targetNameById.get(targetWorkflowId) ?? null) : null,
|
||||
mode: isReplace ? 'replace' : 'create',
|
||||
sourceMeta: {
|
||||
name: source.name,
|
||||
description: source.description,
|
||||
folderId: source.folderId,
|
||||
sortOrder: source.sortOrder,
|
||||
},
|
||||
})
|
||||
|
||||
const blocks = Object.values(sourceState.blocks).map((block) => ({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
subBlocks: block.subBlocks as unknown,
|
||||
}))
|
||||
for (const reference of scanWorkflowReferences(blocks, resolver).references) {
|
||||
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
|
||||
}
|
||||
}
|
||||
|
||||
const workflowIdMap = buildPromoteWorkflowIdMap({
|
||||
identityMap,
|
||||
existingSourceIds,
|
||||
targetActiveIds,
|
||||
items,
|
||||
})
|
||||
|
||||
const writtenTargetIds = new Set(items.map((item) => item.targetWorkflowId))
|
||||
const archivedTargetIds: string[] = []
|
||||
for (const row of mappingRows) {
|
||||
if (row.resourceType !== 'workflow' || row.childResourceId == null) continue
|
||||
const mappedSourceId = sourceIsParent ? row.parentResourceId : row.childResourceId
|
||||
const mappedTargetId = sourceIsParent ? row.childResourceId : row.parentResourceId
|
||||
if (existingSourceIds.has(mappedSourceId)) continue
|
||||
if (writtenTargetIds.has(mappedTargetId)) continue
|
||||
if (targetActiveIds.has(mappedTargetId)) archivedTargetIds.push(mappedTargetId)
|
||||
}
|
||||
const archivedTargets = archivedTargetIds.map((id) => ({
|
||||
id,
|
||||
name: targetNameById.get(id) ?? id,
|
||||
}))
|
||||
|
||||
const cascade = await detectForkCascadeReferences({
|
||||
executor,
|
||||
sourceWorkspaceId,
|
||||
references: Array.from(referenceByKey.values()),
|
||||
resolve: resolver,
|
||||
})
|
||||
for (const reference of cascade.references) {
|
||||
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
|
||||
}
|
||||
|
||||
const allReferences = Array.from(referenceByKey.values())
|
||||
const allUnmapped = allReferences.filter(
|
||||
(reference) => resolver(reference.kind, reference.sourceId) == null
|
||||
)
|
||||
const unmappedRequired = allUnmapped.filter((reference) => reference.required)
|
||||
const unmappedOptional = allUnmapped.filter((reference) => !reference.required)
|
||||
|
||||
const willUpdate = items.filter((i) => i.mode === 'replace').length
|
||||
const willCreate = items.filter((i) => i.mode === 'create').length
|
||||
|
||||
return {
|
||||
childWorkspaceId: edge.childWorkspaceId,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
direction,
|
||||
resolver,
|
||||
items,
|
||||
workflowIdMap,
|
||||
archivedTargetIds,
|
||||
archivedTargets,
|
||||
references: allReferences,
|
||||
unmappedRequired,
|
||||
unmappedOptional,
|
||||
mcpReauthServerIds: cascade.mcpReauthServerIds,
|
||||
inlineSecretSources: cascade.inlineSecretSources,
|
||||
willUpdate,
|
||||
willCreate,
|
||||
willArchive: archivedTargetIds.length,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { workspaceForkPromoteRun } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
|
||||
/**
|
||||
* A target workflow's pre-promote deployed-version reference. Rollback reactivates
|
||||
* `priorVersion` (and loads it into the draft); `null` means the target was not
|
||||
* deployed before the promote, so rollback undeploys it instead.
|
||||
*/
|
||||
export interface PromoteRunWorkflowSnapshot {
|
||||
workflowId: string
|
||||
priorVersion: number | null
|
||||
}
|
||||
|
||||
export interface PromoteRunSnapshot {
|
||||
/** Replaced targets: reactivate their prior deployed version on rollback. */
|
||||
updated: PromoteRunWorkflowSnapshot[]
|
||||
/** Targets the promote created: undeploy + archive on rollback. */
|
||||
created: string[]
|
||||
/** Orphan targets the promote archived: un-archive + reactivate on rollback. */
|
||||
archived: PromoteRunWorkflowSnapshot[]
|
||||
}
|
||||
|
||||
export interface PromoteRunRow {
|
||||
id: string
|
||||
childWorkspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
snapshot: PromoteRunSnapshot
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
/** Replace the edge's undo point with a new run (single-level history). */
|
||||
export async function upsertPromoteRun(
|
||||
tx: DbOrTx,
|
||||
params: {
|
||||
childWorkspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
snapshot: PromoteRunSnapshot
|
||||
userId: string
|
||||
}
|
||||
): Promise<string> {
|
||||
const now = new Date()
|
||||
const id = generateId()
|
||||
await tx
|
||||
.insert(workspaceForkPromoteRun)
|
||||
.values({
|
||||
id,
|
||||
childWorkspaceId: params.childWorkspaceId,
|
||||
sourceWorkspaceId: params.sourceWorkspaceId,
|
||||
targetWorkspaceId: params.targetWorkspaceId,
|
||||
direction: params.direction,
|
||||
snapshot: params.snapshot,
|
||||
createdBy: params.userId,
|
||||
createdAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceForkPromoteRun.childWorkspaceId, workspaceForkPromoteRun.targetWorkspaceId],
|
||||
set: {
|
||||
id,
|
||||
sourceWorkspaceId: params.sourceWorkspaceId,
|
||||
direction: params.direction,
|
||||
snapshot: params.snapshot,
|
||||
createdBy: params.userId,
|
||||
createdAt: now,
|
||||
},
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove EVERY undo point targeting this workspace. Called after a rollback so the
|
||||
* undo is single-level: only the latest sync into a target is ever undoable, and
|
||||
* once it is undone there is no stack of older syncs to walk back into.
|
||||
*/
|
||||
export async function deleteAllPromoteRunsForTarget(
|
||||
tx: DbOrTx,
|
||||
targetWorkspaceId: string
|
||||
): Promise<void> {
|
||||
await tx
|
||||
.delete(workspaceForkPromoteRun)
|
||||
.where(eq(workspaceForkPromoteRun.targetWorkspaceId, targetWorkspaceId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest undo point targeting this workspace. A workspace can be the target of
|
||||
* several edges (pushes from its children, a pull from its parent), so order by
|
||||
* recency: this is the ONLY undoable sync - older ones are stale the moment a newer
|
||||
* sync lands, and rollback refuses them.
|
||||
*/
|
||||
export async function getLatestPromoteRunForTarget(
|
||||
executor: DbOrTx,
|
||||
targetWorkspaceId: string
|
||||
): Promise<PromoteRunRow | null> {
|
||||
const [row] = await executor
|
||||
.select({
|
||||
id: workspaceForkPromoteRun.id,
|
||||
childWorkspaceId: workspaceForkPromoteRun.childWorkspaceId,
|
||||
sourceWorkspaceId: workspaceForkPromoteRun.sourceWorkspaceId,
|
||||
targetWorkspaceId: workspaceForkPromoteRun.targetWorkspaceId,
|
||||
direction: workspaceForkPromoteRun.direction,
|
||||
snapshot: workspaceForkPromoteRun.snapshot,
|
||||
createdAt: workspaceForkPromoteRun.createdAt,
|
||||
})
|
||||
.from(workspaceForkPromoteRun)
|
||||
.where(eq(workspaceForkPromoteRun.targetWorkspaceId, targetWorkspaceId))
|
||||
.orderBy(desc(workspaceForkPromoteRun.createdAt))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
return { ...row, snapshot: row.snapshot as PromoteRunSnapshot }
|
||||
}
|
||||
|
||||
/**
|
||||
* The "other" workspace and direction of the latest sync into this target, for the
|
||||
* UI's undo affordance. `sourceWorkspaceId` is the workspace the sync came from
|
||||
* (rollback resolves the edge from target + other).
|
||||
*/
|
||||
export async function getUndoableRunForTarget(
|
||||
executor: DbOrTx,
|
||||
targetWorkspaceId: string
|
||||
): Promise<{ sourceWorkspaceId: string; direction: 'push' | 'pull' } | null> {
|
||||
const run = await getLatestPromoteRunForTarget(executor, targetWorkspaceId)
|
||||
return run ? { sourceWorkspaceId: run.sourceWorkspaceId, direction: run.direction } : null
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
import { db } from '@sim/db'
|
||||
import { credential, credentialMember, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import {
|
||||
enqueueWorkflowUndeploySideEffects,
|
||||
processWorkflowDeploymentOutboxEvent,
|
||||
} from '@/lib/workflows/deployment-outbox'
|
||||
import { performFullDeploy } from '@/lib/workflows/orchestration/deploy'
|
||||
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
copyWorkflowStateIntoTarget,
|
||||
loadTargetDraftSubBlocks,
|
||||
loadWorkflowNameRegistry,
|
||||
resolveForkFolderMapping,
|
||||
} from '@/lib/workspaces/fork/copy/copy-workflows'
|
||||
import {
|
||||
getActiveDeploymentVersionNumbers,
|
||||
loadSourceDeployedStates,
|
||||
} from '@/lib/workspaces/fork/copy/deploy-bridge'
|
||||
import {
|
||||
acquireForkEdgeLock,
|
||||
acquireForkTargetLock,
|
||||
type ForkEdge,
|
||||
setForkLockTimeout,
|
||||
} from '@/lib/workspaces/fork/lineage/lineage'
|
||||
import {
|
||||
type ForkBlockPair,
|
||||
loadForkBlockMap,
|
||||
reconcileForkBlockPairs,
|
||||
toForkBlockPairs,
|
||||
} from '@/lib/workspaces/fork/mapping/block-map-store'
|
||||
import {
|
||||
type ForkDependentValue,
|
||||
loadForkDependentValues,
|
||||
reconcileForkDependentValues,
|
||||
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
|
||||
import {
|
||||
deleteWorkflowIdentityByIds,
|
||||
type ForkMappingUpsert,
|
||||
upsertEdgeMappings,
|
||||
} from '@/lib/workspaces/fork/mapping/mapping-store'
|
||||
import {
|
||||
computeForkPromotePlan,
|
||||
type ForkPromotePlan,
|
||||
} from '@/lib/workspaces/fork/promote/promote-plan'
|
||||
import {
|
||||
type PromoteRunWorkflowSnapshot,
|
||||
upsertPromoteRun,
|
||||
} from '@/lib/workspaces/fork/promote/promote-run-store'
|
||||
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
|
||||
import {
|
||||
createForkSubBlockTransform,
|
||||
type ForkReference,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
import { notifyForkWorkflowChanged } from '@/lib/workspaces/fork/socket'
|
||||
import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceForkPromote')
|
||||
|
||||
export interface PromoteForkParams {
|
||||
edge: ForkEdge
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
direction: 'push' | 'pull'
|
||||
userId: string
|
||||
/**
|
||||
* The full stored mapping of dependent-field values the caller is committing (target
|
||||
* workflow id + deterministic block id + subblock key -> value). Applied to the target
|
||||
* blocks during the merge and persisted as the stored mapping. OMITTING the field (passing
|
||||
* `undefined`) leaves the existing stored mapping untouched - the store is the sole source
|
||||
* of truth and is loaded + applied as-is; an explicit `[]` clears the written replace
|
||||
* targets' mapping. This distinction keeps a programmatic promote that omits the field from
|
||||
* silently wiping the user's saved selections.
|
||||
*/
|
||||
dependentValues?: Array<{
|
||||
workflowId: string
|
||||
blockId: string
|
||||
subBlockKey: string
|
||||
value: string
|
||||
}>
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface PromoteForkResult {
|
||||
promoteRunId: string
|
||||
updated: number
|
||||
created: number
|
||||
archived: number
|
||||
redeployed: number
|
||||
/**
|
||||
* Targets whose state was written but whose post-transaction deploy failed. The
|
||||
* draft holds the synced state; the active deployment still runs the prior version
|
||||
* until a redeploy. Surfaced (rather than swallowed) so the caller can warn.
|
||||
*/
|
||||
deployFailed: number
|
||||
unmappedRequired: Array<Pick<ForkReference, 'kind' | 'sourceId' | 'required' | 'blockName'>>
|
||||
blocked: 'unmapped' | null
|
||||
/** Names of the workflows the sync changed, by action, for the activity report. */
|
||||
updatedNames: string[]
|
||||
createdNames: string[]
|
||||
archivedNames: string[]
|
||||
/**
|
||||
* Workflows whose required dependent fields a parent change cleared - the target
|
||||
* must re-pick them. These were written but intentionally NOT redeployed (the prior
|
||||
* version keeps running), so the sync never deploys a broken workflow.
|
||||
*/
|
||||
needsConfiguration: Array<{ workflowName: string; blocks: string[] }>
|
||||
/**
|
||||
* Workflows whose OPTIONAL dependent fields a parent change cleared (e.g. a trigger
|
||||
* label filter). Redeployed as-is, but surfaced so a cleared filter that would broaden
|
||||
* behavior is never silent.
|
||||
*/
|
||||
clearedOptional: Array<{ workflowName: string; blocks: string[] }>
|
||||
}
|
||||
|
||||
function collectCredentialPairs(plan: ForkPromotePlan): Array<[string, string]> {
|
||||
const pairs = new Map<string, string>()
|
||||
for (const reference of plan.references) {
|
||||
if (reference.kind !== 'credential') continue
|
||||
const target = plan.resolver('credential', reference.sourceId)
|
||||
if (target) pairs.set(reference.sourceId, target)
|
||||
}
|
||||
return Array.from(pairs.entries())
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant each mapped target credential the same active members its source credential has,
|
||||
* intersected with the target workspace's members, so a promoted workflow's collaborators
|
||||
* can use the remapped credential. Both sides are validated to actually exist in their
|
||||
* workspace first (so a crafted/stale mapping can't drive cross-workspace access), member
|
||||
* reads are batched, and inserts are conflict-safe. Side-effect only on `credentialMember`.
|
||||
*/
|
||||
async function propagateCredentialAccess(
|
||||
tx: DbOrTx,
|
||||
params: {
|
||||
plan: ForkPromotePlan
|
||||
sourceWorkspaceId: string
|
||||
targetWorkspaceId: string
|
||||
targetMembers: string[]
|
||||
now: Date
|
||||
}
|
||||
): Promise<void> {
|
||||
const { plan, sourceWorkspaceId, targetWorkspaceId, targetMembers, now } = params
|
||||
const credentialPairs = collectCredentialPairs(plan)
|
||||
const propagationTargetIds = credentialPairs.map(([, targetCredId]) => targetCredId)
|
||||
const propagationSourceIds = credentialPairs.map(([sourceCredId]) => sourceCredId)
|
||||
|
||||
const validTargetCredentialIds = new Set<string>()
|
||||
if (propagationTargetIds.length > 0) {
|
||||
const validRows = await tx
|
||||
.select({ id: credential.id })
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
inArray(credential.id, propagationTargetIds),
|
||||
eq(credential.workspaceId, targetWorkspaceId)
|
||||
)
|
||||
)
|
||||
for (const row of validRows) validTargetCredentialIds.add(row.id)
|
||||
}
|
||||
|
||||
const validSourceCredentialIds = new Set<string>()
|
||||
if (propagationSourceIds.length > 0) {
|
||||
const validRows = await tx
|
||||
.select({ id: credential.id })
|
||||
.from(credential)
|
||||
.where(
|
||||
and(
|
||||
inArray(credential.id, propagationSourceIds),
|
||||
eq(credential.workspaceId, sourceWorkspaceId)
|
||||
)
|
||||
)
|
||||
for (const row of validRows) validSourceCredentialIds.add(row.id)
|
||||
}
|
||||
|
||||
const validPairs = credentialPairs.filter(
|
||||
([sourceCredId, targetCredId]) =>
|
||||
validSourceCredentialIds.has(sourceCredId) && validTargetCredentialIds.has(targetCredId)
|
||||
)
|
||||
if (validPairs.length === 0) return
|
||||
|
||||
// Batch all source credentials' active members in one query (instead of one per pair),
|
||||
// then build a single insert. `targetMembers` becomes a Set for O(1) membership checks.
|
||||
const targetMemberSet = new Set(targetMembers)
|
||||
const memberRows = await tx
|
||||
.select({
|
||||
credentialId: credentialMember.credentialId,
|
||||
userId: credentialMember.userId,
|
||||
role: credentialMember.role,
|
||||
})
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
credentialMember.credentialId,
|
||||
validPairs.map(([sourceCredId]) => sourceCredId)
|
||||
),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
const membersBySource = new Map<
|
||||
string,
|
||||
Array<Pick<(typeof memberRows)[number], 'userId' | 'role'>>
|
||||
>()
|
||||
for (const row of memberRows) {
|
||||
if (!targetMemberSet.has(row.userId)) continue
|
||||
const list = membersBySource.get(row.credentialId)
|
||||
if (list) list.push({ userId: row.userId, role: row.role })
|
||||
else membersBySource.set(row.credentialId, [{ userId: row.userId, role: row.role }])
|
||||
}
|
||||
const memberInserts = validPairs.flatMap(([sourceCredId, targetCredId]) =>
|
||||
(membersBySource.get(sourceCredId) ?? []).map((member) => ({
|
||||
id: generateId(),
|
||||
credentialId: targetCredId,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
status: 'active' as const,
|
||||
joinedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
if (memberInserts.length > 0) {
|
||||
await tx
|
||||
.insert(credentialMember)
|
||||
.values(memberInserts)
|
||||
.onConflictDoNothing({
|
||||
target: [credentialMember.credentialId, credentialMember.userId],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface PromoteTxBlocked {
|
||||
blocked: 'unmapped'
|
||||
unmappedRequired: PromoteForkResult['unmappedRequired']
|
||||
}
|
||||
|
||||
interface PromoteTxApplied {
|
||||
blocked: null
|
||||
promoteRunId: string
|
||||
deployTargetIds: string[]
|
||||
/** Actual written/archived counts (post-skip), not the pre-copy plan totals. */
|
||||
updated: number
|
||||
created: number
|
||||
archived: number
|
||||
/** Source workflows skipped because their deployment vanished between plan and apply. */
|
||||
skippedItems: Array<{ id: string; name: string }>
|
||||
/** Target workflow id -> source name, so the deploy-failure report can show names. */
|
||||
writtenNames: Record<string, string>
|
||||
/** Names of the changed workflows, by action, for the activity report. */
|
||||
updatedNames: string[]
|
||||
createdNames: string[]
|
||||
archivedNames: string[]
|
||||
/** Outbox event ids enqueued for archived orphans' undeploy side-effects. */
|
||||
undeployEventIds: string[]
|
||||
/**
|
||||
* Per-target required dependents a parent change cleared (with workflow id so the
|
||||
* post-commit deploy loop can skip them, keeping the prior version running).
|
||||
*/
|
||||
needsConfiguration: Array<{ workflowId: string; workflowName: string; blocks: string[] }>
|
||||
/** Per-workflow optional dependents a parent change cleared (surfaced, not gated). */
|
||||
clearedOptional: Array<{ workflowName: string; blocks: string[] }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Group flat dependent values into the apply map `target workflow -> block id -> subblock -> value`
|
||||
* that {@link copyWorkflowStateIntoTarget} consumes. Pure (no DB), so the provided-value path can
|
||||
* build it before the transaction (it doesn't depend on the plan); the omitted path feeds it the
|
||||
* loaded store rows inside the tx, where the plan's replace targets are known.
|
||||
*/
|
||||
function groupDependentOverrides(
|
||||
values: ForkDependentValue[]
|
||||
): Map<string, Map<string, Map<string, string>>> {
|
||||
const byWorkflow = new Map<string, Map<string, Map<string, string>>>()
|
||||
for (const entry of values) {
|
||||
let byBlock = byWorkflow.get(entry.targetWorkflowId)
|
||||
if (!byBlock) {
|
||||
byBlock = new Map()
|
||||
byWorkflow.set(entry.targetWorkflowId, byBlock)
|
||||
}
|
||||
let byKey = byBlock.get(entry.targetBlockId)
|
||||
if (!byKey) {
|
||||
byKey = new Map()
|
||||
byBlock.set(entry.targetBlockId, byKey)
|
||||
}
|
||||
byKey.set(entry.subBlockKey, entry.value)
|
||||
}
|
||||
return byWorkflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a force promote along the edge. Only the source's deployed workflows
|
||||
* participate: each one's active deployed state is remapped into the target
|
||||
* (replacing mapped targets in place with deterministic block ids, creating new
|
||||
* ones, archiving previously-mapped orphans whose source is no longer deployed),
|
||||
* a version-reference rollback snapshot is captured, credential access is
|
||||
* propagated, and every promoted target is deployed. The plan is computed inside
|
||||
* the edge lock so concurrent promotes serialize. A sync always force-replaces the
|
||||
* target's deployed state (the modal confirms the overwrite up front); it blocks
|
||||
* without mutating only when required references are unmapped.
|
||||
*/
|
||||
export async function promoteFork(params: PromoteForkParams): Promise<PromoteForkResult> {
|
||||
const { edge, sourceWorkspaceId, targetWorkspaceId, direction, userId } = params
|
||||
const requestId = params.requestId ?? 'unknown'
|
||||
|
||||
// Distinguish an OMITTED dependent mapping (leave the store as-is) from an explicit empty
|
||||
// array (clear it). When values are PROVIDED the apply map is plan-independent, so build it
|
||||
// here - BEFORE the transaction - to keep the advisory lock tight (pure in-memory, no DB),
|
||||
// mirroring how the source states are pre-loaded above. The OMITTED path needs the plan's
|
||||
// replace targets, so it loads + builds inside the tx below.
|
||||
const dependentValuesProvided = params.dependentValues !== undefined
|
||||
const providedOverridesByWorkflow = dependentValuesProvided
|
||||
? groupDependentOverrides(
|
||||
(params.dependentValues ?? []).map((entry) => ({
|
||||
targetWorkflowId: entry.workflowId,
|
||||
targetBlockId: entry.blockId,
|
||||
subBlockKey: entry.subBlockKey,
|
||||
value: entry.value,
|
||||
}))
|
||||
)
|
||||
: null
|
||||
|
||||
const targetMembers = (await getUsersWithPermissions(targetWorkspaceId)).map((m) => m.userId)
|
||||
|
||||
// Read the source's deployed workflows + states BEFORE the transaction so these
|
||||
// heavy per-workflow reads never check out a second pooled connection from inside
|
||||
// the promote tx (which can deadlock the pool at saturation). The source is
|
||||
// read-only here, so this pre-tx snapshot is exactly what gets force-pushed.
|
||||
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(sourceWorkspaceId)
|
||||
|
||||
const txResult: PromoteTxBlocked | PromoteTxApplied = await db.transaction(async (tx) => {
|
||||
// Bound lock waits so a contended sync into this target fails fast instead of
|
||||
// stagnating the pool. Must run before acquiring the advisory locks below.
|
||||
await setForkLockTimeout(tx)
|
||||
// Target lock before edge lock (consistent ordering): the target lock serializes
|
||||
// every sync into this target so sibling forks can't interleave writes, and so
|
||||
// rollback's "newest sync" check stays race-free against a concurrent promote.
|
||||
await acquireForkTargetLock(tx, targetWorkspaceId)
|
||||
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
|
||||
|
||||
const plan = await computeForkPromotePlan({
|
||||
executor: tx,
|
||||
edge,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
direction,
|
||||
deployedSourceWorkflows: deployedWorkflows,
|
||||
sourceStates,
|
||||
})
|
||||
|
||||
if (plan.unmappedRequired.length > 0) {
|
||||
return {
|
||||
blocked: 'unmapped',
|
||||
unmappedRequired: plan.unmappedRequired.map((reference) => ({
|
||||
kind: reference.kind,
|
||||
sourceId: reference.sourceId,
|
||||
required: reference.required,
|
||||
blockName: reference.blockName,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const transform = createForkSubBlockTransform(plan.resolver)
|
||||
const folderIdMap = await resolveForkFolderMapping({
|
||||
tx,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
now,
|
||||
})
|
||||
|
||||
// Batch every prior-version read (replace + archive targets) into one query before any
|
||||
// write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so
|
||||
// they still reflect the active version each target had before this sync.
|
||||
const priorVersionByTarget = await getActiveDeploymentVersionNumbers(tx, [
|
||||
...plan.items.filter((item) => item.mode === 'replace').map((item) => item.targetWorkflowId),
|
||||
...plan.archivedTargetIds,
|
||||
])
|
||||
|
||||
// Preload the target's active workflow names so per-workflow collision checks read from
|
||||
// memory instead of one query each inside this locked tx. The DB unique index remains
|
||||
// the correctness backstop (a stale snapshot only risks a rare, retry-able conflict).
|
||||
const nameRegistry = await loadWorkflowNameRegistry(tx, targetWorkspaceId)
|
||||
|
||||
// Replace targets (the only mode with a prior target state) - reused by the draft preload
|
||||
// and the dependent-value apply/load below.
|
||||
const replaceTargetIds = plan.items
|
||||
.filter((item) => item.mode === 'replace')
|
||||
.map((item) => item.targetWorkflowId)
|
||||
|
||||
// Preload the target's current draft subBlocks (replace targets only) so the copy can
|
||||
// detect dependent fields a parent change cleared that the stored mapping didn't refill
|
||||
// (surfaced as needs-configuration). One batched query pre-write, so it reflects the
|
||||
// pre-sync target state.
|
||||
const targetDraftByWorkflow = await loadTargetDraftSubBlocks(tx, replaceTargetIds)
|
||||
|
||||
// The dependent-value apply map (target workflow -> block id -> subblock -> value). When the
|
||||
// caller PROVIDED values it was built pre-tx (plan-independent); apply it and reconcile the
|
||||
// store below. When OMITTED the store is the sole source of truth - load the existing values
|
||||
// for the plan's replace targets (one indexed query) and build the map, skipping the reconcile
|
||||
// below so an omitted field never wipes the saved mapping.
|
||||
const overridesByWorkflow =
|
||||
providedOverridesByWorkflow ??
|
||||
groupDependentOverrides(
|
||||
await loadForkDependentValues(tx, edge.childWorkspaceId, replaceTargetIds)
|
||||
)
|
||||
|
||||
// Resolve each source block to its counterpart's EXISTING id (via the persisted block
|
||||
// map) instead of re-deriving, so a push keeps the parent's original block ids - and the
|
||||
// webhook URLs derived from them - stable. Falls back to derive for blocks with no pair
|
||||
// yet (added since the last sync), which are recorded below.
|
||||
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
|
||||
const blockMap = await loadForkBlockMap(tx, edge.childWorkspaceId)
|
||||
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
|
||||
const blockPairs: ForkBlockPair[] = []
|
||||
|
||||
const updatedSnapshots: PromoteRunWorkflowSnapshot[] = []
|
||||
const createdTargetIds: string[] = []
|
||||
const writtenItems: typeof plan.items = []
|
||||
const needsConfiguration: PromoteTxApplied['needsConfiguration'] = []
|
||||
const clearedOptional: PromoteTxApplied['clearedOptional'] = []
|
||||
for (const item of plan.items) {
|
||||
// Use the pre-read source state (loaded above, before the tx). An item only
|
||||
// exists when its state was present at read time, so this lookup hits; the
|
||||
// guard stays as defense so the written counts below never over-report.
|
||||
const sourceState = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!sourceState) continue
|
||||
if (item.mode === 'replace') {
|
||||
const priorVersion = priorVersionByTarget.get(item.targetWorkflowId) ?? null
|
||||
updatedSnapshots.push({ workflowId: item.targetWorkflowId, priorVersion })
|
||||
} else {
|
||||
createdTargetIds.push(item.targetWorkflowId)
|
||||
}
|
||||
const copyResult = await copyWorkflowStateIntoTarget({
|
||||
tx,
|
||||
targetWorkflowId: item.targetWorkflowId,
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
mode: item.mode,
|
||||
now,
|
||||
sourceState,
|
||||
sourceMeta: item.sourceMeta,
|
||||
workflowIdMap: plan.workflowIdMap,
|
||||
folderIdMap,
|
||||
transformSubBlocks: transform,
|
||||
targetCurrentBlocks:
|
||||
item.mode === 'replace' ? targetDraftByWorkflow.get(item.targetWorkflowId) : undefined,
|
||||
dependentOverrides: overridesByWorkflow.get(item.targetWorkflowId),
|
||||
nameRegistry,
|
||||
resolveBlockId,
|
||||
requestId,
|
||||
})
|
||||
blockPairs.push(
|
||||
...toForkBlockPairs(
|
||||
copyResult.blockIdMapping,
|
||||
sourceIsParent,
|
||||
item.sourceWorkflowId,
|
||||
item.targetWorkflowId
|
||||
)
|
||||
)
|
||||
const requiredCleared = copyResult.clearedDependents.filter((field) => field.required)
|
||||
const optionalCleared = copyResult.clearedDependents.filter((field) => !field.required)
|
||||
if (requiredCleared.length > 0) {
|
||||
needsConfiguration.push({
|
||||
workflowId: item.targetWorkflowId,
|
||||
workflowName: item.sourceMeta.name,
|
||||
// Surface the block names (deduped) - the field titles ("Label") aren't useful.
|
||||
blocks: [...new Set(requiredCleared.map((field) => field.blockName))],
|
||||
})
|
||||
}
|
||||
if (optionalCleared.length > 0) {
|
||||
clearedOptional.push({
|
||||
workflowName: item.sourceMeta.name,
|
||||
blocks: [...new Set(optionalCleared.map((field) => field.blockName))],
|
||||
})
|
||||
}
|
||||
writtenItems.push(item)
|
||||
}
|
||||
|
||||
// Reconcile block-identity pairs for the written source workflows: clears pairs for
|
||||
// blocks the source dropped (e.g. a deleted trigger) and any stale pair from a re-created
|
||||
// target, then records the live ones - so the next promote resolves these blocks to these
|
||||
// same ids and never re-homes one onto an archived workflow's block.
|
||||
await reconcileForkBlockPairs(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
sourceIsParent,
|
||||
writtenItems.map((item) => item.sourceWorkflowId),
|
||||
blockPairs
|
||||
)
|
||||
|
||||
// Persist / prune the stored dependent mapping. When the caller PROVIDED values, replace
|
||||
// every written replace-target's stored set (cleared/removed fields drop out so the store
|
||||
// equals exactly what was sent) AND prune the archived targets' now-dead rows (their
|
||||
// workflow no longer exists and has no FK to cascade). Scope the inserted values to the
|
||||
// delete's workflows so a value for a workflow skipped this pass (its source state
|
||||
// vanished) can't be inserted without first clearing its old row and trip the unique
|
||||
// constraint. When OMITTED, the store stays the source of truth (already applied above) -
|
||||
// only prune archived targets, never touch the live replace targets' mapping.
|
||||
const dependentTargetIds = new Set(
|
||||
writtenItems.filter((item) => item.mode === 'replace').map((item) => item.targetWorkflowId)
|
||||
)
|
||||
if (dependentValuesProvided) {
|
||||
await reconcileForkDependentValues(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
[...dependentTargetIds, ...plan.archivedTargetIds],
|
||||
(params.dependentValues ?? [])
|
||||
.filter((entry) => dependentTargetIds.has(entry.workflowId))
|
||||
.map((entry) => ({
|
||||
targetWorkflowId: entry.workflowId,
|
||||
targetBlockId: entry.blockId,
|
||||
subBlockKey: entry.subBlockKey,
|
||||
value: entry.value,
|
||||
}))
|
||||
)
|
||||
} else if (plan.archivedTargetIds.length > 0) {
|
||||
await reconcileForkDependentValues(tx, edge.childWorkspaceId, plan.archivedTargetIds, [])
|
||||
}
|
||||
|
||||
const archivedNames =
|
||||
plan.archivedTargetIds.length > 0
|
||||
? (
|
||||
await tx
|
||||
.select({ name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(inArray(workflow.id, plan.archivedTargetIds))
|
||||
).map((row) => row.name)
|
||||
: []
|
||||
|
||||
const undeployEventIds: string[] = []
|
||||
const archivedSnapshots: PromoteRunWorkflowSnapshot[] = []
|
||||
for (const targetWorkflowId of plan.archivedTargetIds) {
|
||||
const priorVersion = priorVersionByTarget.get(targetWorkflowId) ?? null
|
||||
archivedSnapshots.push({ workflowId: targetWorkflowId, priorVersion })
|
||||
// Enqueue undeploy side-effects (webhook + MCP-tool cleanup) so an archived orphan
|
||||
// doesn't leak its subscriptions/registrations - mirrors rollback's undeploy path.
|
||||
await undeployWorkflow({
|
||||
workflowId: targetWorkflowId,
|
||||
tx,
|
||||
onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => {
|
||||
if (deploymentVersionIds.length === 0) return
|
||||
const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, {
|
||||
workflowId: targetWorkflowId,
|
||||
deploymentVersionIds,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
undeployEventIds.push(eventId)
|
||||
},
|
||||
})
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ archivedAt: now, updatedAt: now })
|
||||
.where(eq(workflow.id, targetWorkflowId))
|
||||
}
|
||||
|
||||
const identityEntries: ForkMappingUpsert[] = writtenItems.map((item) => ({
|
||||
resourceType: 'workflow' as const,
|
||||
parentResourceId: direction === 'pull' ? item.sourceWorkflowId : item.targetWorkflowId,
|
||||
childResourceId: direction === 'pull' ? item.targetWorkflowId : item.sourceWorkflowId,
|
||||
}))
|
||||
// The identity upsert keys on the parent side, which on push is the TARGET. A
|
||||
// source whose previously-mapped target was archived gets a freshly-generated
|
||||
// target id here, so its old (stale-target) identity row wouldn't be overwritten
|
||||
// and would leak a second mapping for the same source. Delete every prior identity
|
||||
// row for these sources (by the source side) first so exactly one row per source
|
||||
// remains - this also converges any pre-existing duplicates.
|
||||
await deleteWorkflowIdentityByIds(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
direction === 'pull' ? 'parent' : 'child',
|
||||
writtenItems.map((item) => item.sourceWorkflowId)
|
||||
)
|
||||
await upsertEdgeMappings(tx, edge.childWorkspaceId, userId, identityEntries)
|
||||
|
||||
await propagateCredentialAccess(tx, {
|
||||
plan,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
targetMembers,
|
||||
now,
|
||||
})
|
||||
|
||||
const promoteRunId = await upsertPromoteRun(tx, {
|
||||
childWorkspaceId: edge.childWorkspaceId,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
direction,
|
||||
userId,
|
||||
snapshot: {
|
||||
updated: updatedSnapshots,
|
||||
created: createdTargetIds,
|
||||
archived: archivedSnapshots,
|
||||
},
|
||||
})
|
||||
|
||||
// A source whose active deployment vanished between plan and copy is skipped
|
||||
// above, so report what was actually written - the plan totals would overstate.
|
||||
const writtenSourceIds = new Set(writtenItems.map((item) => item.sourceWorkflowId))
|
||||
const skippedItems = plan.items
|
||||
.filter((item) => !writtenSourceIds.has(item.sourceWorkflowId))
|
||||
.map((item) => ({ id: item.sourceWorkflowId, name: item.sourceMeta.name }))
|
||||
if (skippedItems.length > 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] Promote skipped ${skippedItems.length} source workflow(s) whose deployment disappeared between plan and apply`,
|
||||
{ sourceWorkspaceId, targetWorkspaceId, skipped: skippedItems.length }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: null,
|
||||
promoteRunId,
|
||||
deployTargetIds: writtenItems.map((item) => item.targetWorkflowId),
|
||||
updated: updatedSnapshots.length,
|
||||
created: createdTargetIds.length,
|
||||
archived: archivedSnapshots.length,
|
||||
skippedItems,
|
||||
writtenNames: Object.fromEntries(
|
||||
writtenItems.map((item) => [item.targetWorkflowId, item.sourceMeta.name])
|
||||
),
|
||||
updatedNames: writtenItems
|
||||
.filter((item) => item.mode === 'replace')
|
||||
.map((item) => item.sourceMeta.name),
|
||||
createdNames: writtenItems
|
||||
.filter((item) => item.mode !== 'replace')
|
||||
.map((item) => item.sourceMeta.name),
|
||||
archivedNames,
|
||||
undeployEventIds,
|
||||
needsConfiguration,
|
||||
clearedOptional,
|
||||
}
|
||||
})
|
||||
|
||||
if (txResult.blocked !== null) {
|
||||
const unmappedRequired = txResult.blocked === 'unmapped' ? txResult.unmappedRequired : []
|
||||
return {
|
||||
promoteRunId: '',
|
||||
updated: 0,
|
||||
created: 0,
|
||||
archived: 0,
|
||||
redeployed: 0,
|
||||
deployFailed: 0,
|
||||
unmappedRequired,
|
||||
blocked: txResult.blocked,
|
||||
updatedNames: [],
|
||||
createdNames: [],
|
||||
archivedNames: [],
|
||||
needsConfiguration: [],
|
||||
clearedOptional: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Process archived orphans' undeploy side-effects after commit (durably retried by the
|
||||
// outbox cron if this dies first), so the locked transaction never held a network call.
|
||||
for (const eventId of txResult.undeployEventIds) {
|
||||
try {
|
||||
await processWorkflowDeploymentOutboxEvent(eventId)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Deferred archive undeploy side-effect failed (will retry)`, {
|
||||
eventId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let redeployed = 0
|
||||
const deployFailures: string[] = []
|
||||
const deployWarnings: string[] = []
|
||||
// Targets whose required dependents a parent change cleared: their draft holds the
|
||||
// synced state, but we intentionally skip the redeploy so the prior deployed version
|
||||
// keeps running instead of going live with an empty required field. The user re-picks
|
||||
// the field (surfaced via `needsConfiguration`), then a redeploy/next sync deploys it.
|
||||
const needsConfigTargetIds = new Set(txResult.needsConfiguration.map((n) => n.workflowId))
|
||||
// Deploy in a deterministic (sorted) order so this UNLOCKED loop acquires workflow
|
||||
// row locks in the same order as a concurrent rollback's atomic tx (and a sibling
|
||||
// promote's deploy loop), avoiding deadlocks - see rollback.ts lock ordering.
|
||||
const deployTargetIds = [...txResult.deployTargetIds].sort((a, b) => a.localeCompare(b))
|
||||
for (const targetWorkflowId of deployTargetIds) {
|
||||
// The transaction already force-replaced this target's draft state, so connected
|
||||
// canvas clients must adopt it (mothership-edit semantics) whether or not the
|
||||
// subsequent deploy succeeds - otherwise they keep, and may clobber, stale state.
|
||||
void notifyForkWorkflowChanged(targetWorkflowId)
|
||||
if (needsConfigTargetIds.has(targetWorkflowId)) continue
|
||||
try {
|
||||
const result = await performFullDeploy({ workflowId: targetWorkflowId, userId, requestId })
|
||||
if (result.success) {
|
||||
redeployed += 1
|
||||
// A deploy can succeed but defer/queue some side-effects (trigger/schedule/MCP
|
||||
// sync). Surface those instead of swallowing them into a clean success.
|
||||
if (result.warnings?.length) {
|
||||
const name = txResult.writtenNames[targetWorkflowId] ?? targetWorkflowId
|
||||
for (const warning of result.warnings) deployWarnings.push(`${name}: ${warning}`)
|
||||
}
|
||||
} else {
|
||||
deployFailures.push(targetWorkflowId)
|
||||
logger.warn(`[${requestId}] Deploy after promote failed`, {
|
||||
workflowId: targetWorkflowId,
|
||||
error: result.error,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
deployFailures.push(targetWorkflowId)
|
||||
logger.error(`[${requestId}] Deploy after promote threw`, {
|
||||
workflowId: targetWorkflowId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (deployFailures.length > 0) {
|
||||
logger.warn(`[${requestId}] Promote wrote state but some targets failed to deploy`, {
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
deployFailed: deployFailures.length,
|
||||
deployFailures,
|
||||
})
|
||||
}
|
||||
|
||||
if (deployWarnings.length > 0) {
|
||||
logger.warn(`[${requestId}] Promote deploys emitted warnings`, { deployWarnings })
|
||||
}
|
||||
if (txResult.skippedItems.length > 0) {
|
||||
logger.warn(`[${requestId}] Promote skipped undeployed source workflows`, {
|
||||
skipped: txResult.skippedItems.map((item) => item.name),
|
||||
})
|
||||
}
|
||||
if (txResult.needsConfiguration.length > 0) {
|
||||
logger.warn(`[${requestId}] Promote left required dependent fields needing configuration`, {
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
needsConfiguration: txResult.needsConfiguration.map((n) => n.workflowName),
|
||||
})
|
||||
}
|
||||
logger.info(`[${requestId}] Promoted ${sourceWorkspaceId} -> ${targetWorkspaceId}`, {
|
||||
updated: txResult.updated,
|
||||
created: txResult.created,
|
||||
archived: txResult.archived,
|
||||
redeployed,
|
||||
deployFailed: deployFailures.length,
|
||||
needsConfiguration: txResult.needsConfiguration.length,
|
||||
})
|
||||
|
||||
return {
|
||||
promoteRunId: txResult.promoteRunId,
|
||||
updated: txResult.updated,
|
||||
created: txResult.created,
|
||||
archived: txResult.archived,
|
||||
redeployed,
|
||||
deployFailed: deployFailures.length,
|
||||
unmappedRequired: [],
|
||||
blocked: null,
|
||||
updatedNames: txResult.updatedNames,
|
||||
createdNames: txResult.createdNames,
|
||||
archivedNames: txResult.archivedNames,
|
||||
needsConfiguration: txResult.needsConfiguration.map(({ workflowName, blocks }) => ({
|
||||
workflowName,
|
||||
blocks,
|
||||
})),
|
||||
clearedOptional: txResult.clearedOptional,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { enqueueWorkflowDeploymentSideEffects } from '@/lib/workflows/deployment-outbox'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
interface ReactivateDeployedVersionParams {
|
||||
tx: DbOrTx
|
||||
workflowId: string
|
||||
version: number
|
||||
userId: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface ReactivateDeployedVersionResult {
|
||||
deploymentVersionId: string
|
||||
/**
|
||||
* Outbox event id enqueued inside the transaction. Process it AFTER the tx commits
|
||||
* (or rely on the outbox cron/reaper if the process dies first).
|
||||
*/
|
||||
outboxEventId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a prior deployment version AND restore the workflow's draft to it using
|
||||
* ONLY DB writes against the provided transaction, enqueuing the deployment
|
||||
* side-effect (webhook / schedule / MCP re-subscription) to the outbox for processing
|
||||
* AFTER the tx commits. This composes the DB halves of {@link activateWorkflowVersion}
|
||||
* and `performRevertToVersion` so a fork rollback can run atomically under its fork
|
||||
* advisory lock - the heavy side-effects never run inside the locked tx.
|
||||
*
|
||||
* Deliberately does NOT call `assertWorkflowMutable`: a rollback is an admin force-undo
|
||||
* and must not be blocked by a workflow/folder lock (that check is also not tx-safe).
|
||||
* Idempotent: deactivate-all + activate-target + overwrite-draft yield the same state
|
||||
* on retry.
|
||||
*
|
||||
* Returns null when the target version row no longer exists, so the caller can mark the
|
||||
* workflow skipped rather than failing the whole rollback.
|
||||
*/
|
||||
export async function reactivateDeployedVersionInTx(
|
||||
params: ReactivateDeployedVersionParams
|
||||
): Promise<ReactivateDeployedVersionResult | null> {
|
||||
const { tx, workflowId, version, userId, requestId } = params
|
||||
const now = new Date()
|
||||
|
||||
// Lock the workflow row so this serializes with a concurrent (unlocked) promote
|
||||
// deploy loop, which locks the same row in deployWorkflow - guaranteeing the final
|
||||
// (active version, draft) pair is always coherent regardless of commit order.
|
||||
await tx
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
.for('update')
|
||||
|
||||
const [versionRow] = await tx
|
||||
.select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.version, version)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!versionRow) return null
|
||||
|
||||
const deployedState = versionRow.state as {
|
||||
blocks?: Record<string, unknown>
|
||||
edges?: unknown[]
|
||||
loops?: Record<string, unknown>
|
||||
parallels?: Record<string, unknown>
|
||||
variables?: WorkflowState['variables']
|
||||
}
|
||||
if (!deployedState.blocks || !deployedState.edges) {
|
||||
throw new Error(
|
||||
`Deployment version ${version} for workflow ${workflowId} has an invalid state structure`
|
||||
)
|
||||
}
|
||||
|
||||
// Activate the target version (deactivate every other), mark the workflow deployed.
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ isActive: false })
|
||||
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({ isActive: true })
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.version, version)
|
||||
)
|
||||
)
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ isDeployed: true, deployedAt: now })
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
// Restore the draft to the deployed version's state.
|
||||
const hasVariables = Object.hasOwn(deployedState, 'variables')
|
||||
const restoredState: WorkflowState = {
|
||||
blocks: deployedState.blocks,
|
||||
edges: deployedState.edges,
|
||||
loops: deployedState.loops || {},
|
||||
parallels: deployedState.parallels || {},
|
||||
lastSaved: now.getTime(),
|
||||
} as WorkflowState
|
||||
if (hasVariables) {
|
||||
restoredState.variables = deployedState.variables || {}
|
||||
}
|
||||
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, restoredState, tx)
|
||||
if (!saveResult.success) {
|
||||
throw new Error(saveResult.error || `Failed to restore draft for workflow ${workflowId}`)
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
...(hasVariables ? { variables: deployedState.variables || {} } : {}),
|
||||
lastSynced: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
const outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
|
||||
workflowId,
|
||||
deploymentVersionId: versionRow.id,
|
||||
userId,
|
||||
requestId,
|
||||
forceRecreateSubscriptions: true,
|
||||
})
|
||||
|
||||
return { deploymentVersionId: versionRow.id, outboxEventId }
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockResolveForkEdge,
|
||||
mockAcquireTargetLock,
|
||||
mockAcquireEdgeLock,
|
||||
mockGetLatestRun,
|
||||
mockDeleteAllRuns,
|
||||
mockReactivate,
|
||||
mockUndeploy,
|
||||
mockDeleteIdentity,
|
||||
mockEnqueueUndeploy,
|
||||
mockProcessOutbox,
|
||||
mockNotify,
|
||||
} = vi.hoisted(() => ({
|
||||
mockResolveForkEdge: vi.fn(),
|
||||
mockAcquireTargetLock: vi.fn(),
|
||||
mockAcquireEdgeLock: vi.fn(),
|
||||
mockGetLatestRun: vi.fn(),
|
||||
mockDeleteAllRuns: vi.fn(),
|
||||
mockReactivate: vi.fn(),
|
||||
mockUndeploy: vi.fn(),
|
||||
mockDeleteIdentity: vi.fn(),
|
||||
mockEnqueueUndeploy: vi.fn(),
|
||||
mockProcessOutbox: vi.fn(),
|
||||
mockNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/fork/lineage/lineage', () => ({
|
||||
resolveForkEdge: mockResolveForkEdge,
|
||||
acquireForkTargetLock: mockAcquireTargetLock,
|
||||
acquireForkEdgeLock: mockAcquireEdgeLock,
|
||||
setForkLockTimeout: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/fork/promote/promote-run-store', () => ({
|
||||
getLatestPromoteRunForTarget: mockGetLatestRun,
|
||||
deleteAllPromoteRunsForTarget: mockDeleteAllRuns,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/fork/promote/reactivate-in-tx', () => ({
|
||||
reactivateDeployedVersionInTx: mockReactivate,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
undeployWorkflow: mockUndeploy,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/fork/mapping/mapping-store', () => ({
|
||||
deleteWorkflowIdentityByIds: mockDeleteIdentity,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/deployment-outbox', () => ({
|
||||
enqueueWorkflowUndeploySideEffects: mockEnqueueUndeploy,
|
||||
processWorkflowDeploymentOutboxEvent: mockProcessOutbox,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/fork/socket', () => ({
|
||||
notifyForkWorkflowChanged: mockNotify,
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { rollbackFork } from '@/lib/workspaces/fork/promote/rollback'
|
||||
|
||||
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
|
||||
|
||||
/** A fake transaction whose existence query returns the given undeploy ids. */
|
||||
function makeTx(existingUndeployIds: string[] = []) {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve(existingUndeployIds.map((id) => ({ id })))),
|
||||
})),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn(() => Promise.resolve(undefined)) })),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function setTx(existingUndeployIds: string[] = []) {
|
||||
vi.mocked(db.transaction).mockImplementation(
|
||||
async (cb: (tx: unknown) => unknown) => cb(makeTx(existingUndeployIds)) as never
|
||||
)
|
||||
}
|
||||
|
||||
function makeRun(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'run-1',
|
||||
childWorkspaceId: EDGE.childWorkspaceId,
|
||||
direction: 'push' as const,
|
||||
snapshot: { updated: [], created: [], archived: [] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('rollbackFork', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveForkEdge.mockResolvedValue(EDGE)
|
||||
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv', outboxEventId: 'evt' })
|
||||
mockUndeploy.mockResolvedValue({ success: true })
|
||||
mockProcessOutbox.mockResolvedValue('completed')
|
||||
setTx([])
|
||||
})
|
||||
|
||||
it('reactivates updated workflows and processes side-effects after commit', async () => {
|
||||
const run = makeRun({
|
||||
snapshot: {
|
||||
updated: [
|
||||
{ workflowId: 'wf-b', priorVersion: 5 },
|
||||
{ workflowId: 'wf-a', priorVersion: 3 },
|
||||
],
|
||||
created: [],
|
||||
archived: [],
|
||||
},
|
||||
})
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) => ({
|
||||
deploymentVersionId: `dv-${workflowId}`,
|
||||
outboxEventId: `evt-${workflowId}`,
|
||||
}))
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
restored: 2,
|
||||
archived: 0,
|
||||
unarchived: 0,
|
||||
skipped: 0,
|
||||
skippedIds: [],
|
||||
})
|
||||
// Deterministic (sorted) order: wf-a before wf-b.
|
||||
expect(mockReactivate.mock.calls.map((c) => c[0].workflowId)).toEqual(['wf-a', 'wf-b'])
|
||||
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-a')
|
||||
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-wf-b')
|
||||
expect(mockDeleteAllRuns).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotify).toHaveBeenCalledWith('wf-a')
|
||||
expect(mockNotify).toHaveBeenCalledWith('wf-b')
|
||||
})
|
||||
|
||||
it('un-archives and reactivates an archived orphan (prior version restored)', async () => {
|
||||
const run = makeRun({
|
||||
snapshot: { updated: [], created: [], archived: [{ workflowId: 'wf-x', priorVersion: 2 }] },
|
||||
})
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
mockReactivate.mockResolvedValue({ deploymentVersionId: 'dv-x', outboxEventId: 'evt-x' })
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result.unarchived).toBe(1)
|
||||
expect(result.restored).toBe(0)
|
||||
expect(result.archived).toBe(0)
|
||||
expect(result.skipped).toBe(0)
|
||||
expect(mockReactivate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workflowId: 'wf-x', version: 2 })
|
||||
)
|
||||
expect(mockProcessOutbox).toHaveBeenCalledWith('evt-x')
|
||||
expect(mockNotify).toHaveBeenCalledWith('wf-x')
|
||||
})
|
||||
|
||||
it('aborts with 409 and writes nothing when a newer sync supersedes it mid-flight', async () => {
|
||||
const run = makeRun({
|
||||
snapshot: { updated: [{ workflowId: 'wf-a', priorVersion: 3 }], created: [], archived: [] },
|
||||
})
|
||||
// Unlocked read returns our run; the in-tx re-check sees a newer run.
|
||||
mockGetLatestRun.mockResolvedValueOnce(run).mockResolvedValueOnce(makeRun({ id: 'run-2' }))
|
||||
|
||||
await expect(
|
||||
rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
).rejects.toMatchObject({ statusCode: 409 })
|
||||
|
||||
// No partial restore: nothing reactivated, no undo point consumed.
|
||||
expect(mockReactivate).not.toHaveBeenCalled()
|
||||
expect(mockUndeploy).not.toHaveBeenCalled()
|
||||
expect(mockDeleteAllRuns).not.toHaveBeenCalled()
|
||||
expect(mockProcessOutbox).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a skipped reactivation when the version is gone (never silent)', async () => {
|
||||
const run = makeRun({
|
||||
snapshot: {
|
||||
updated: [
|
||||
{ workflowId: 'wf-a', priorVersion: 3 },
|
||||
{ workflowId: 'wf-b', priorVersion: 5 },
|
||||
],
|
||||
created: [],
|
||||
archived: [],
|
||||
},
|
||||
})
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
mockReactivate.mockImplementation(async ({ workflowId }: { workflowId: string }) =>
|
||||
workflowId === 'wf-b' ? null : { deploymentVersionId: 'dv', outboxEventId: 'evt-wf-a' }
|
||||
)
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result.restored).toBe(1)
|
||||
expect(result.skipped).toBe(1)
|
||||
expect(result.skippedIds).toEqual(['wf-b'])
|
||||
expect(mockNotify).not.toHaveBeenCalledWith('wf-b')
|
||||
})
|
||||
|
||||
it('undeploys + archives created workflows and dissolves their identity rows', async () => {
|
||||
setTx(['wf-c'])
|
||||
const run = makeRun({
|
||||
direction: 'push',
|
||||
snapshot: { updated: [], created: ['wf-c'], archived: [] },
|
||||
})
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
mockUndeploy.mockImplementation(
|
||||
async ({
|
||||
onUndeployTransaction,
|
||||
}: {
|
||||
onUndeployTransaction?: (
|
||||
tx: unknown,
|
||||
r: { deploymentVersionIds: string[] }
|
||||
) => Promise<void>
|
||||
}) => {
|
||||
await onUndeployTransaction?.(makeTx(), { deploymentVersionIds: ['dv-c'] })
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
mockEnqueueUndeploy.mockResolvedValue('undeploy-evt')
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(result.archived).toBe(1)
|
||||
expect(result.skipped).toBe(0)
|
||||
expect(mockUndeploy).toHaveBeenCalledTimes(1)
|
||||
expect(mockDeleteIdentity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
EDGE.childWorkspaceId,
|
||||
'parent',
|
||||
['wf-c']
|
||||
)
|
||||
expect(mockProcessOutbox).toHaveBeenCalledWith('undeploy-evt')
|
||||
})
|
||||
|
||||
it('skips a created workflow that was hard-deleted (not archived, surfaced)', async () => {
|
||||
setTx([]) // wf-c no longer exists
|
||||
const run = makeRun({ snapshot: { updated: [], created: ['wf-c'], archived: [] } })
|
||||
mockGetLatestRun.mockResolvedValue(run)
|
||||
|
||||
const result = await rollbackFork({
|
||||
targetWorkspaceId: 'target-ws',
|
||||
otherWorkspaceId: 'other-ws',
|
||||
userId: 'user-1',
|
||||
})
|
||||
|
||||
expect(mockUndeploy).not.toHaveBeenCalled()
|
||||
expect(result.skipped).toBe(1)
|
||||
expect(result.skippedIds).toEqual(['wf-c'])
|
||||
expect(result.archived).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import {
|
||||
enqueueWorkflowUndeploySideEffects,
|
||||
processWorkflowDeploymentOutboxEvent,
|
||||
} from '@/lib/workflows/deployment-outbox'
|
||||
import { undeployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import { ForkError } from '@/lib/workspaces/fork/lineage/authz'
|
||||
import {
|
||||
acquireForkEdgeLock,
|
||||
acquireForkTargetLock,
|
||||
resolveForkEdge,
|
||||
setForkLockTimeout,
|
||||
} from '@/lib/workspaces/fork/lineage/lineage'
|
||||
import { deleteWorkflowIdentityByIds } from '@/lib/workspaces/fork/mapping/mapping-store'
|
||||
import {
|
||||
deleteAllPromoteRunsForTarget,
|
||||
getLatestPromoteRunForTarget,
|
||||
} from '@/lib/workspaces/fork/promote/promote-run-store'
|
||||
import { reactivateDeployedVersionInTx } from '@/lib/workspaces/fork/promote/reactivate-in-tx'
|
||||
import { notifyForkWorkflowChanged } from '@/lib/workspaces/fork/socket'
|
||||
|
||||
const logger = createLogger('WorkspaceForkRollback')
|
||||
|
||||
export interface RollbackForkParams {
|
||||
targetWorkspaceId: string
|
||||
otherWorkspaceId: string
|
||||
userId: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface RollbackForkResult {
|
||||
restored: number
|
||||
archived: number
|
||||
unarchived: number
|
||||
/** Snapshot workflows that no longer exist and so couldn't be restored. */
|
||||
skipped: number
|
||||
/** Ids of the skipped workflows (surfaced so the partial restore is never silent). */
|
||||
skippedIds: string[]
|
||||
}
|
||||
|
||||
/** A single restore action, sorted by workflow id for a deterministic lock order. */
|
||||
type RollbackOp =
|
||||
| { workflowId: string; kind: 'reactivate'; version: number }
|
||||
| { workflowId: string; kind: 'undeploy' }
|
||||
|
||||
/**
|
||||
* Undo the most recent promote into `targetWorkspaceId` in ONE atomic, fork-locked,
|
||||
* DB-only transaction. Because a concurrent promote takes the same target advisory
|
||||
* lock for its write transaction, it cannot interleave with the rollback: it runs
|
||||
* fully before or fully after. If a newer sync superseded our undo point, we abort
|
||||
* with 409 BEFORE any write, so the operation is strictly all-or-nothing - it never
|
||||
* leaves a partially reverted target.
|
||||
*
|
||||
* The heavy webhook / schedule / MCP re-subscription work is enqueued to the
|
||||
* deployment outbox INSIDE the transaction and processed AFTER commit (and durably
|
||||
* retried by the outbox cron/reaper if this process dies first), so the locked
|
||||
* transaction never holds across a network call. No draft blobs are stored - the
|
||||
* deployed version is the source of truth.
|
||||
*/
|
||||
export async function rollbackFork(params: RollbackForkParams): Promise<RollbackForkResult> {
|
||||
const { targetWorkspaceId, otherWorkspaceId, userId } = params
|
||||
const requestId = params.requestId ?? 'unknown'
|
||||
|
||||
const edge = await resolveForkEdge(targetWorkspaceId, otherWorkspaceId)
|
||||
if (!edge) {
|
||||
throw new ForkError('These workspaces are not a direct fork edge', 400)
|
||||
}
|
||||
|
||||
// Only the most recent sync into the target is undoable. Undoing an older sibling's
|
||||
// sync while a newer one stands would partially revert the target and strand the
|
||||
// newer sync's changes.
|
||||
const run = await getLatestPromoteRunForTarget(db, targetWorkspaceId)
|
||||
if (!run) {
|
||||
throw new ForkError('There is no promote to undo for this workspace', 404)
|
||||
}
|
||||
if (run.childWorkspaceId !== edge.childWorkspaceId) {
|
||||
throw new ForkError(
|
||||
'A newer sync into this workspace exists; reopen and undo the most recent sync.',
|
||||
409
|
||||
)
|
||||
}
|
||||
|
||||
const { updated, created, archived } = run.snapshot
|
||||
|
||||
// Build the restore ops: reactivate a prior version, or undeploy (created targets +
|
||||
// updated targets that had no prior deployment). Sort by workflow id so the locked
|
||||
// transaction acquires workflow row locks in a deterministic order, avoiding
|
||||
// deadlocks with the (unlocked) promote deploy loop, which locks the same rows.
|
||||
const undeployIds = [
|
||||
...created,
|
||||
...updated.filter((i) => i.priorVersion == null).map((i) => i.workflowId),
|
||||
]
|
||||
const toReactivateOps = (
|
||||
list: Array<{ workflowId: string; priorVersion: number | null }>
|
||||
): RollbackOp[] =>
|
||||
list
|
||||
.filter((item) => item.priorVersion != null)
|
||||
.map((item) => ({
|
||||
workflowId: item.workflowId,
|
||||
kind: 'reactivate' as const,
|
||||
version: item.priorVersion as number,
|
||||
}))
|
||||
const ops: RollbackOp[] = [
|
||||
...toReactivateOps(updated),
|
||||
...toReactivateOps(archived),
|
||||
...undeployIds.map((workflowId) => ({ workflowId, kind: 'undeploy' as const })),
|
||||
].sort((a, b) => a.workflowId.localeCompare(b.workflowId))
|
||||
|
||||
const skipped = new Set<string>()
|
||||
const outboxEventIds: string[] = []
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await setForkLockTimeout(tx)
|
||||
await acquireForkTargetLock(tx, targetWorkspaceId)
|
||||
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
|
||||
|
||||
// Re-confirm our run is still the newest sync, now under the lock. If a promote
|
||||
// landed since the unlocked read above, abort with NO writes (tx rolls back).
|
||||
const current = await getLatestPromoteRunForTarget(tx, targetWorkspaceId)
|
||||
if (!current || current.id !== run.id) {
|
||||
throw new ForkError(
|
||||
'A newer sync into this workspace exists; reopen and undo the most recent sync.',
|
||||
409
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Un-archive the orphans the promote archived BEFORE reactivating them.
|
||||
if (archived.length > 0) {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ archivedAt: null, updatedAt: now })
|
||||
.where(
|
||||
inArray(
|
||||
workflow.id,
|
||||
archived.map((i) => i.workflowId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Which undeploy targets still exist (created targets can be hard-deleted after the
|
||||
// promote; a missing one is already gone, so skip rather than fail the rollback).
|
||||
const existingUndeploy =
|
||||
undeployIds.length === 0
|
||||
? new Set<string>()
|
||||
: new Set(
|
||||
(
|
||||
await tx
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(inArray(workflow.id, undeployIds))
|
||||
).map((row) => row.id)
|
||||
)
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.kind === 'reactivate') {
|
||||
const result = await reactivateDeployedVersionInTx({
|
||||
tx,
|
||||
workflowId: op.workflowId,
|
||||
version: op.version,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
// A null result means the workflow / version was hard-deleted since the
|
||||
// promote - record it so the partial restore is surfaced, never silent.
|
||||
if (!result) {
|
||||
skipped.add(op.workflowId)
|
||||
continue
|
||||
}
|
||||
outboxEventIds.push(result.outboxEventId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existingUndeploy.has(op.workflowId)) {
|
||||
skipped.add(op.workflowId)
|
||||
continue
|
||||
}
|
||||
const undeployResult = await undeployWorkflow({
|
||||
workflowId: op.workflowId,
|
||||
tx,
|
||||
onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => {
|
||||
if (deploymentVersionIds.length === 0) return
|
||||
const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, {
|
||||
workflowId: op.workflowId,
|
||||
deploymentVersionIds,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
outboxEventIds.push(eventId)
|
||||
},
|
||||
})
|
||||
if (!undeployResult.success) {
|
||||
// The workflow exists but couldn't be undeployed - abort so we never leave a
|
||||
// partial undo. The whole tx rolls back and the undo point is preserved.
|
||||
throw new ForkError(
|
||||
`Rollback could not undeploy workflow ${op.workflowId}: ${undeployResult.error ?? 'unknown error'}. The undo point is preserved - retry the rollback.`,
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Archive the workflows the promote created and dissolve their identity rows.
|
||||
if (created.length > 0) {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ archivedAt: now, updatedAt: now })
|
||||
.where(inArray(workflow.id, created))
|
||||
// A created target is the child side on pull and the parent side on push.
|
||||
await deleteWorkflowIdentityByIds(
|
||||
tx,
|
||||
edge.childWorkspaceId,
|
||||
run.direction === 'pull' ? 'child' : 'parent',
|
||||
created
|
||||
)
|
||||
}
|
||||
|
||||
// Single-level undo: drop every undo point for this target so no older sibling
|
||||
// sync becomes undoable once this one is undone.
|
||||
await deleteAllPromoteRunsForTarget(tx, targetWorkspaceId)
|
||||
})
|
||||
|
||||
// After commit: process the enqueued side-effects (webhooks / schedules / MCP). These
|
||||
// are durable outbox rows, so a crash here is recovered by the outbox cron/reaper -
|
||||
// failures only warn, they never undo the (committed) restore.
|
||||
for (const eventId of outboxEventIds) {
|
||||
try {
|
||||
await processWorkflowDeploymentOutboxEvent(eventId)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${requestId}] Deferred rollback side-effect processing failed (will retry via outbox)`,
|
||||
{ eventId, error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.size > 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] Rollback skipped ${skipped.size} workflow(s) no longer in the database`,
|
||||
{
|
||||
targetWorkspaceId,
|
||||
skipped: Array.from(skipped),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Notify connected canvases to adopt the restored state (reactivated drafts + the
|
||||
// undeployed/archived created targets). Skipped (gone) workflows have no room.
|
||||
const notifyIds = new Set<string>()
|
||||
for (const op of ops) {
|
||||
if (!skipped.has(op.workflowId)) notifyIds.add(op.workflowId)
|
||||
}
|
||||
for (const workflowId of notifyIds) void notifyForkWorkflowChanged(workflowId)
|
||||
|
||||
// Attribute each skip to its bucket (a workflow is in exactly one) so the counts
|
||||
// reflect what was actually restored, not the snapshot size.
|
||||
const createdSet = new Set(created)
|
||||
const archivedSet = new Set(archived.map((i) => i.workflowId))
|
||||
const updatedSet = new Set(updated.map((i) => i.workflowId))
|
||||
let skippedUpdated = 0
|
||||
let skippedCreated = 0
|
||||
let skippedArchived = 0
|
||||
for (const id of skipped) {
|
||||
if (updatedSet.has(id)) skippedUpdated += 1
|
||||
else if (createdSet.has(id)) skippedCreated += 1
|
||||
else if (archivedSet.has(id)) skippedArchived += 1
|
||||
}
|
||||
|
||||
const restored = updated.length - skippedUpdated
|
||||
const archivedCount = created.length - skippedCreated
|
||||
const unarchived = archived.length - skippedArchived
|
||||
|
||||
const result: RollbackForkResult = {
|
||||
restored,
|
||||
archived: archivedCount,
|
||||
unarchived,
|
||||
skipped: skipped.size,
|
||||
skippedIds: Array.from(skipped),
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Rolled back promote into ${targetWorkspaceId}`, {
|
||||
restored: result.restored,
|
||||
archived: result.archived,
|
||||
unarchived: result.unarchived,
|
||||
skipped: result.skipped,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -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 '@/lib/workspaces/fork/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,95 @@
|
||||
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.
|
||||
*/
|
||||
function uuidV5(name: string, namespace: string): string {
|
||||
const hash = createHash('sha1')
|
||||
hash.update(uuidToBytes(namespace))
|
||||
hash.update(Buffer.from(name, 'utf8'))
|
||||
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,30 @@
|
||||
import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import {
|
||||
clearDependentsOnRemap,
|
||||
type ForkRemapKind,
|
||||
remapForkSubBlocks,
|
||||
} from '@/lib/workspaces/fork/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
|
||||
): (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord {
|
||||
return (subBlocks, blockType) => {
|
||||
const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create')
|
||||
return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { remapForkFileUploadValue } from '@/lib/workspaces/fork/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,77 @@
|
||||
/**
|
||||
* `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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
// The indexer resolves a tool's params via the tool registry; stub it so the
|
||||
// injected blockConfigs subBlocks drive resolution deterministically in tests.
|
||||
vi.mock('@/tools/params', () => ({
|
||||
getToolIdForOperation: () => undefined,
|
||||
getToolParametersConfig: () => null,
|
||||
getSubBlocksForToolInput: (
|
||||
_toolId: string,
|
||||
_type: string,
|
||||
_values: unknown,
|
||||
_modes: unknown,
|
||||
provided?: { subBlocks?: SubBlockConfig[] }
|
||||
) => ({ subBlocks: provided?.subBlocks ?? [] }),
|
||||
formatParameterLabel: (label: string) => label,
|
||||
}))
|
||||
|
||||
import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import {
|
||||
applyDependentOverrides,
|
||||
collectClearedDependents,
|
||||
parseNestedDependentKey,
|
||||
readTargetDraftDependentValue,
|
||||
remapForkSubBlocks,
|
||||
remapToolBlockResources,
|
||||
} from '@/lib/workspaces/fork/remap/remap-references'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const blockConfigs: Record<string, { subBlocks: SubBlockConfig[] }> = {
|
||||
testblock: {
|
||||
subBlocks: [
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input', serviceId: 'gmail' },
|
||||
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
||||
{ id: 'channel', title: 'Channel', type: 'channel-selector', serviceId: 'slack' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
describe('remapToolBlockResources', () => {
|
||||
it('remaps nested credential + knowledge-base ids and leaves external selectors', () => {
|
||||
const tool = {
|
||||
type: 'testblock',
|
||||
toolId: 'testblock_run',
|
||||
params: { credential: 'cred-src', knowledgeBaseId: 'kb-src', channel: 'C123' },
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
'credential:cred-src': 'cred-dst',
|
||||
'knowledge-base:kb-src': 'kb-dst',
|
||||
}
|
||||
const result = remapToolBlockResources(tool, {
|
||||
resolve: (kind, id) => map[`${kind}:${id}`] ?? null,
|
||||
resolveFileKey: () => null,
|
||||
clearUnresolved: false,
|
||||
blockConfigs,
|
||||
})
|
||||
expect(result.params).toEqual({
|
||||
credential: 'cred-dst',
|
||||
knowledgeBaseId: 'kb-dst',
|
||||
channel: 'C123',
|
||||
})
|
||||
})
|
||||
|
||||
it('clears unresolved copyable refs when clearUnresolved is set (fork)', () => {
|
||||
const tool = {
|
||||
type: 'testblock',
|
||||
toolId: 'testblock_run',
|
||||
params: { credential: 'cred-src', knowledgeBaseId: 'kb-src', channel: 'C123' },
|
||||
}
|
||||
const result = remapToolBlockResources(tool, {
|
||||
resolve: () => null,
|
||||
resolveFileKey: () => null,
|
||||
clearUnresolved: true,
|
||||
blockConfigs,
|
||||
})
|
||||
expect(result.params).toEqual({ credential: '', knowledgeBaseId: '', channel: 'C123' })
|
||||
})
|
||||
|
||||
it('keeps unresolved refs and records them when not clearing (promote)', () => {
|
||||
const tool = {
|
||||
type: 'testblock',
|
||||
toolId: 'testblock_run',
|
||||
params: { credential: 'cred-src', channel: 'C123' },
|
||||
}
|
||||
const recorded: Array<{ kind: string; id: string; mapped: boolean }> = []
|
||||
const result = remapToolBlockResources(tool, {
|
||||
resolve: () => null,
|
||||
resolveFileKey: () => null,
|
||||
record: (kind, id, mapped) => recorded.push({ kind, id, mapped }),
|
||||
clearUnresolved: false,
|
||||
blockConfigs,
|
||||
})
|
||||
expect((result.params as Record<string, unknown>).credential).toBe('cred-src')
|
||||
expect(recorded).toContainEqual({ kind: 'credential', id: 'cred-src', mapped: false })
|
||||
})
|
||||
|
||||
it('returns the tool unchanged when it has no params', () => {
|
||||
const tool = { type: 'testblock' }
|
||||
expect(
|
||||
remapToolBlockResources(tool, {
|
||||
resolve: () => null,
|
||||
resolveFileKey: () => null,
|
||||
clearUnresolved: true,
|
||||
blockConfigs,
|
||||
})
|
||||
).toBe(tool)
|
||||
})
|
||||
|
||||
it('leaves an advanced-mode manualCredential id untouched (escape hatch)', () => {
|
||||
const tool = {
|
||||
type: 'testblock',
|
||||
toolId: 'testblock_run',
|
||||
params: { manualCredential: 'mc-src', knowledgeBaseId: 'kb-src' },
|
||||
}
|
||||
const result = remapToolBlockResources(tool, {
|
||||
resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
||||
resolveFileKey: () => null,
|
||||
clearUnresolved: true,
|
||||
blockConfigs,
|
||||
})
|
||||
expect(result.params).toEqual({ manualCredential: 'mc-src', knowledgeBaseId: 'kb-dst' })
|
||||
})
|
||||
|
||||
it('preserves an org-scoped credentialSet ref without remapping or recording it', () => {
|
||||
const tool = {
|
||||
type: 'testblock',
|
||||
toolId: 'testblock_run',
|
||||
params: { credential: 'credentialSet:cs-1' },
|
||||
}
|
||||
const recorded: Array<{ kind: string; id: string; mapped: boolean }> = []
|
||||
const result = remapToolBlockResources(tool, {
|
||||
resolve: () => null,
|
||||
resolveFileKey: () => null,
|
||||
record: (kind, id, mapped) => recorded.push({ kind, id, mapped }),
|
||||
clearUnresolved: true,
|
||||
blockConfigs,
|
||||
})
|
||||
expect((result.params as Record<string, string>).credential).toBe('credentialSet:cs-1')
|
||||
expect(recorded).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops only the uncopied entry in a mixed multi-value field', () => {
|
||||
const tool = {
|
||||
type: 'testblock',
|
||||
toolId: 'testblock_run',
|
||||
params: { knowledgeBaseId: 'kb1,kb2' },
|
||||
}
|
||||
const result = remapToolBlockResources(tool, {
|
||||
resolve: (_kind, id) => (id === 'kb1' ? 'kb1-dst' : null),
|
||||
resolveFileKey: () => null,
|
||||
clearUnresolved: true,
|
||||
blockConfigs,
|
||||
})
|
||||
const value = (result.params as Record<string, string>).knowledgeBaseId
|
||||
expect(value.split(',').filter(Boolean)).toEqual(['kb1-dst'])
|
||||
})
|
||||
|
||||
it('resolves a credential param by id even when its config is filtered out (reactive)', () => {
|
||||
// blockConfigs has no `credential` subBlock (simulating a reactive-gated field
|
||||
// hidden from getToolInputParamConfigs); the raw id-scan must still catch it.
|
||||
const tool = {
|
||||
type: 'reactiveblock',
|
||||
toolId: 'reactiveblock_run',
|
||||
params: { credential: 'cred-src' },
|
||||
}
|
||||
const result = remapToolBlockResources(tool, {
|
||||
resolve: (kind, id) => (kind === 'credential' && id === 'cred-src' ? 'cred-dst' : null),
|
||||
resolveFileKey: () => null,
|
||||
clearUnresolved: false,
|
||||
blockConfigs: { reactiveblock: { subBlocks: [] } },
|
||||
})
|
||||
expect((result.params as Record<string, string>).credential).toBe('cred-dst')
|
||||
})
|
||||
|
||||
it('clears a dependent tool param when its parent resource is remapped', () => {
|
||||
const tool = {
|
||||
type: 'depblock',
|
||||
toolId: 'depblock_run',
|
||||
params: { knowledgeBaseId: 'kb-src', documentId: 'doc-src' },
|
||||
}
|
||||
const result = remapToolBlockResources(tool, {
|
||||
resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
||||
resolveFileKey: () => null,
|
||||
clearUnresolved: false,
|
||||
blockConfigs: {
|
||||
depblock: {
|
||||
subBlocks: [
|
||||
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
||||
{
|
||||
id: 'documentId',
|
||||
title: 'Doc',
|
||||
type: 'document-selector',
|
||||
dependsOn: ['knowledgeBaseId'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.params).toEqual({ knowledgeBaseId: 'kb-dst', documentId: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('remapForkSubBlocks', () => {
|
||||
const subBlocks = (): SubBlockRecord => ({
|
||||
credential: { id: 'credential', type: 'oauth-input', value: 'c-src' },
|
||||
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-src' },
|
||||
manualCredential: { id: 'manualCredential', type: 'short-input', value: 'mc-src' },
|
||||
})
|
||||
|
||||
it('create mode: clears unresolved credentials and remaps copied resources', () => {
|
||||
const result = remapForkSubBlocks(
|
||||
subBlocks(),
|
||||
(kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
||||
'create'
|
||||
)
|
||||
expect(result.subBlocks.credential.value).toBe('')
|
||||
expect(result.subBlocks.knowledgeBaseId.value).toBe('kb-dst')
|
||||
expect(result.subBlocks.manualCredential.value).toBe('mc-src')
|
||||
expect(result.references).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('promote mode: keeps + records the basic credential; manual id is escape hatch', () => {
|
||||
const result = remapForkSubBlocks(
|
||||
subBlocks(),
|
||||
(kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
||||
'promote'
|
||||
)
|
||||
// The basic credential is cleared (never carry an invalid cross-workspace id) but
|
||||
// still surfaced as required so the sync blocks; the advanced manualCredential is an
|
||||
// escape hatch - preserved verbatim, not recorded.
|
||||
expect(result.subBlocks.credential.value).toBe('')
|
||||
expect(result.subBlocks.manualCredential.value).toBe('mc-src')
|
||||
expect(result.subBlocks.knowledgeBaseId.value).toBe('kb-dst')
|
||||
const unmappedKinds = result.unmapped.map((r) => `${r.kind}:${r.sourceId}`)
|
||||
expect(unmappedKinds).toContain('credential:c-src')
|
||||
expect(unmappedKinds).not.toContain('credential:mc-src')
|
||||
expect(result.unmapped.every((r) => r.kind !== 'knowledge-base')).toBe(true)
|
||||
})
|
||||
|
||||
it('promote mode: preserves a credentialSet ref without flagging it', () => {
|
||||
const sb: SubBlockRecord = {
|
||||
triggerCredentials: {
|
||||
id: 'triggerCredentials',
|
||||
type: 'oauth-input',
|
||||
value: 'credentialSet:cs-1',
|
||||
},
|
||||
}
|
||||
const result = remapForkSubBlocks(sb, () => null, 'promote')
|
||||
expect(result.subBlocks.triggerCredentials.value).toBe('credentialSet:cs-1')
|
||||
expect(result.references).toHaveLength(0)
|
||||
expect(result.unmapped).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('create mode: keeps a credentialSet ref (org-scoped, not cleared)', () => {
|
||||
const sb: SubBlockRecord = {
|
||||
triggerCredentials: {
|
||||
id: 'triggerCredentials',
|
||||
type: 'oauth-input',
|
||||
value: 'credentialSet:cs-1',
|
||||
},
|
||||
}
|
||||
const result = remapForkSubBlocks(sb, () => null, 'create')
|
||||
expect(result.subBlocks.triggerCredentials.value).toBe('credentialSet:cs-1')
|
||||
expect(result.references).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('promote mode: rewrites {{ENV}} nested in an array-form tool param', () => {
|
||||
const sb: SubBlockRecord = {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [{ type: 'genericblock', params: { subject: 'Hi {{OLD}}' } }],
|
||||
},
|
||||
}
|
||||
const result = remapForkSubBlocks(
|
||||
sb,
|
||||
(kind, id) => (kind === 'env-var' && id === 'OLD' ? 'NEW' : null),
|
||||
'promote'
|
||||
)
|
||||
const tools = result.subBlocks.tools.value as Array<{ params: { subject: string } }>
|
||||
expect(tools[0].params.subject).toBe('Hi {{NEW}}')
|
||||
})
|
||||
})
|
||||
|
||||
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
|
||||
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
|
||||
|
||||
const entry = (id: string, type: string, value: unknown) => ({ id, type, value })
|
||||
|
||||
describe('collectClearedDependents', () => {
|
||||
it('flags a required dependent the target had set but the merge left empty', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
const targetDraft: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-target'),
|
||||
folder: entry('folder', 'folder-selector', 'INBOX'),
|
||||
}
|
||||
const merged: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-new'),
|
||||
folder: entry('folder', 'folder-selector', ''),
|
||||
}
|
||||
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([
|
||||
{
|
||||
blockId: 'b1',
|
||||
blockName: 'Send Email',
|
||||
subBlockKey: 'folder',
|
||||
title: 'Label',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an optional cleared dependent flagged required:false', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
|
||||
])
|
||||
)
|
||||
const targetDraft: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-target'),
|
||||
folder: entry('folder', 'folder-selector', 'INBOX'),
|
||||
}
|
||||
const merged: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-new'),
|
||||
folder: entry('folder', 'folder-selector', ''),
|
||||
}
|
||||
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([
|
||||
{
|
||||
blockId: 'b1',
|
||||
blockName: 'Send Email',
|
||||
subBlockKey: 'folder',
|
||||
title: 'Label',
|
||||
required: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not flag a dependent the target never configured (only the source carried it)', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
|
||||
])
|
||||
)
|
||||
// The target's fork never set this label, so the merge leaving it empty is not a loss -
|
||||
// this is the pull case where the parent carried a filter the fork never had.
|
||||
const targetDraft: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-target'),
|
||||
folder: entry('folder', 'folder-selector', ''),
|
||||
}
|
||||
const merged: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-new'),
|
||||
folder: entry('folder', 'folder-selector', ''),
|
||||
}
|
||||
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not flag a dependent that ended up with a value (preserved or overridden)', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
const targetDraft: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-target'),
|
||||
folder: entry('folder', 'folder-selector', 'INBOX'),
|
||||
}
|
||||
const merged: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-target'),
|
||||
folder: entry('folder', 'folder-selector', 'INBOX'),
|
||||
}
|
||||
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not flag a cleared dependent gated off by its condition', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{ id: 'operation', title: 'Operation', type: 'dropdown' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
},
|
||||
])
|
||||
)
|
||||
// The operation is 'send', so the read-only folder field is inactive - a stale value
|
||||
// it carried must not be flagged as required.
|
||||
const targetDraft: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-target'),
|
||||
operation: entry('operation', 'dropdown', 'send'),
|
||||
folder: entry('folder', 'folder-selector', 'INBOX'),
|
||||
}
|
||||
const merged: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-new'),
|
||||
operation: entry('operation', 'dropdown', 'send'),
|
||||
folder: entry('folder', 'folder-selector', ''),
|
||||
}
|
||||
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a cleared dependent nested inside a tool-input tool', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail')
|
||||
return blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const targetDraft: SubBlockRecord = {
|
||||
tools: entry('tools', 'tool-input', [
|
||||
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-target', folder: 'INBOX' } },
|
||||
]),
|
||||
}
|
||||
const merged: SubBlockRecord = {
|
||||
tools: entry('tools', 'tool-input', [
|
||||
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
|
||||
]),
|
||||
}
|
||||
expect(collectClearedDependents('agent', 'b1', 'Agent', targetDraft, merged)).toEqual([
|
||||
{
|
||||
blockId: 'b1',
|
||||
blockName: 'Agent',
|
||||
subBlockKey: 'tools[0].folder',
|
||||
title: 'Gmail: Label',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDependentOverrides', () => {
|
||||
const gmailConfig = () =>
|
||||
blockWith([
|
||||
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
||||
{
|
||||
id: 'folder',
|
||||
title: 'Label',
|
||||
type: 'folder-selector',
|
||||
dependsOn: ['credential'],
|
||||
selectorKey: 'gmail.labels',
|
||||
},
|
||||
])
|
||||
|
||||
it('applies a top-level re-pick value', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(gmailConfig())
|
||||
const subBlocks: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-new'),
|
||||
folder: entry('folder', 'folder-selector', ''),
|
||||
}
|
||||
const result = applyDependentOverrides(subBlocks, 'gmail', new Map([['folder', 'Label_42']]))
|
||||
expect((result.folder as { value: unknown }).value).toBe('Label_42')
|
||||
})
|
||||
|
||||
it('rejects an override for a non-dependent / parent key (allowlist)', () => {
|
||||
vi.mocked(getBlock).mockReturnValue(gmailConfig())
|
||||
const subBlocks: SubBlockRecord = {
|
||||
credential: entry('credential', 'oauth-input', 'c-new'),
|
||||
folder: entry('folder', 'folder-selector', ''),
|
||||
}
|
||||
// 'credential' is a parent (no selectorKey) - must never be writable via override.
|
||||
const result = applyDependentOverrides(subBlocks, 'gmail', new Map([['credential', 'evil']]))
|
||||
expect(result).toBe(subBlocks)
|
||||
expect((subBlocks.credential as { value: unknown }).value).toBe('c-new')
|
||||
})
|
||||
|
||||
it('applies a nested tool-input re-pick onto the matching tool param', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail') return gmailConfig()
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: entry('tools', 'tool-input', [
|
||||
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
|
||||
]),
|
||||
}
|
||||
const result = applyDependentOverrides(
|
||||
subBlocks,
|
||||
'agent',
|
||||
new Map([['tools[0].folder', 'Label_99']])
|
||||
)
|
||||
const tools = (result.tools as { value: Array<{ params: { folder: string } }> }).value
|
||||
expect(tools[0].params.folder).toBe('Label_99')
|
||||
})
|
||||
|
||||
it('rejects a nested override for a non-allowlisted tool param', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail') return gmailConfig()
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: entry('tools', 'tool-input', [
|
||||
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
|
||||
]),
|
||||
}
|
||||
// 'credential' inside the tool is a parent - not overridable.
|
||||
const result = applyDependentOverrides(
|
||||
subBlocks,
|
||||
'agent',
|
||||
new Map([['tools[0].credential', 'evil']])
|
||||
)
|
||||
expect(result).toBe(subBlocks)
|
||||
})
|
||||
|
||||
it('ignores a nested override whose tool index is out of range', () => {
|
||||
vi.mocked(getBlock).mockImplementation((type) => {
|
||||
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
||||
if (type === 'gmail') return gmailConfig()
|
||||
return undefined as unknown as BlockConfig
|
||||
})
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: entry('tools', 'tool-input', [
|
||||
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
|
||||
]),
|
||||
}
|
||||
const result = applyDependentOverrides(
|
||||
subBlocks,
|
||||
'agent',
|
||||
new Map([['tools[5].folder', 'Label_99']])
|
||||
)
|
||||
expect(result).toBe(subBlocks)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNestedDependentKey', () => {
|
||||
it('parses a nested tool-input key with a numeric index', () => {
|
||||
expect(parseNestedDependentKey('tools[0].folder')).toEqual({
|
||||
toolInputId: 'tools',
|
||||
index: 0,
|
||||
paramId: 'folder',
|
||||
})
|
||||
expect(parseNestedDependentKey('tools[12].channel')).toEqual({
|
||||
toolInputId: 'tools',
|
||||
index: 12,
|
||||
paramId: 'channel',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for a plain top-level key', () => {
|
||||
expect(parseNestedDependentKey('folder')).toBeNull()
|
||||
expect(parseNestedDependentKey('credential')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTargetDraftDependentValue', () => {
|
||||
it('reads a top-level draft value', () => {
|
||||
const draft: SubBlockRecord = { folder: { value: 'INBOX' } }
|
||||
expect(readTargetDraftDependentValue(draft, undefined, 'folder')).toBe('INBOX')
|
||||
})
|
||||
|
||||
it('returns empty for a missing or non-string top-level value', () => {
|
||||
expect(readTargetDraftDependentValue({ folder: { value: 42 } }, undefined, 'folder')).toBe('')
|
||||
expect(readTargetDraftDependentValue({}, undefined, 'folder')).toBe('')
|
||||
expect(readTargetDraftDependentValue(undefined, undefined, 'folder')).toBe('')
|
||||
})
|
||||
|
||||
it('reads the target draft nested param when the source/target tool types match at that index', () => {
|
||||
const target: SubBlockRecord = {
|
||||
tools: { value: [{ type: 'gmail', params: { folder: 'INBOX' } }] },
|
||||
}
|
||||
const source: SubBlockRecord = {
|
||||
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
|
||||
}
|
||||
// Reads the TARGET draft's value (INBOX), gated on a same-type tool at the index.
|
||||
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('INBOX')
|
||||
})
|
||||
|
||||
it('identity guard: returns empty when the target draft tool type differs from the source dependent tool', () => {
|
||||
// The source dependent hangs off a Gmail tool at index 0, but the target draft holds a Slack
|
||||
// tool there - its param value is not this field's value, so nothing is seeded.
|
||||
const target: SubBlockRecord = {
|
||||
tools: { value: [{ type: 'slack', params: { folder: 'INBOX' } }] },
|
||||
}
|
||||
const source: SubBlockRecord = {
|
||||
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
|
||||
}
|
||||
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty when the target draft has no tool at the index', () => {
|
||||
const target: SubBlockRecord = { tools: { value: [] } }
|
||||
const source: SubBlockRecord = {
|
||||
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
|
||||
}
|
||||
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty when the source tool type cannot be verified', () => {
|
||||
const target: SubBlockRecord = {
|
||||
tools: { value: [{ type: 'gmail', params: { folder: 'INBOX' } }] },
|
||||
}
|
||||
// No source subBlocks (or no tool at the index) -> identity unverifiable -> do not seed.
|
||||
expect(readTargetDraftDependentValue(target, undefined, 'tools[0].folder')).toBe('')
|
||||
expect(readTargetDraftDependentValue(target, { tools: { value: [] } }, 'tools[0].folder')).toBe(
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
it('handles the JSON-string stored tool array shape', () => {
|
||||
const target: SubBlockRecord = {
|
||||
tools: { value: JSON.stringify([{ type: 'gmail', params: { folder: 'INBOX' } }]) },
|
||||
}
|
||||
const source: SubBlockRecord = {
|
||||
tools: { value: JSON.stringify([{ type: 'gmail', params: { folder: 'SENT' } }]) },
|
||||
}
|
||||
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('INBOX')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,921 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { SubBlockType } from '@sim/workflow-types/blocks'
|
||||
import type { z } from 'zod'
|
||||
import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
import {
|
||||
coerceObjectArray,
|
||||
isRecord,
|
||||
type SubBlockRecord,
|
||||
} from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils'
|
||||
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
|
||||
import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer'
|
||||
import {
|
||||
getWorkflowSearchSubBlockResourceDefinition,
|
||||
parseWorkflowSearchSubBlockResources,
|
||||
type StructuredWorkflowSearchResourceKind,
|
||||
} from '@/lib/workflows/search-replace/resources/registry'
|
||||
import {
|
||||
buildSubBlockValues,
|
||||
evaluateSubBlockCondition,
|
||||
isNonEmptyValue,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import type { ParsedStoredTool } from '@/lib/workflows/tool-input/types'
|
||||
import { remapForkFileUploadValue } from '@/lib/workspaces/fork/remap/remap-files'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* Resource kinds the fork remapper rewrites across workspaces, derived from the
|
||||
* wire contract so the union can't drift from `forkRemapKindSchema`. `workflow`,
|
||||
* `mcp-tool`, and the service-specific `selector-resource` kinds are deliberately
|
||||
* excluded: workflow references are remapped via the workflow identity map, and
|
||||
* MCP tool / selector ids are not workspace-local so they carry over unchanged.
|
||||
*/
|
||||
export type ForkRemapKind = z.infer<typeof forkRemapKindSchema>
|
||||
|
||||
const logger = createLogger('WorkspaceForkRemapReferences')
|
||||
|
||||
const REQUIRED_KINDS = new Set<ForkRemapKind>(['credential', 'env-var'])
|
||||
|
||||
/**
|
||||
* Id-based override kind for a TOOL param's credential, resolved by subblock id so a
|
||||
* basic `credential` / `triggerCredentials` is caught even when its config is filtered
|
||||
* out by a reactive condition (the registry path would otherwise skip it). Advanced
|
||||
* `manual*` ids are an escape hatch - the user owns them (e.g. via a `{{SECRET}}`), so
|
||||
* they are never auto-remapped.
|
||||
*/
|
||||
function getToolParamOverrideKind(paramId: string): ForkRemapKind | null {
|
||||
const base = paramId.replace(/_\d+$/, '')
|
||||
if (base === 'manualCredential') return null
|
||||
if (CREDENTIAL_SUBBLOCK_IDS.has(base)) return 'credential'
|
||||
return null
|
||||
}
|
||||
|
||||
export const REGISTRY_KIND_TO_FORK_KIND: Partial<
|
||||
Record<StructuredWorkflowSearchResourceKind, ForkRemapKind>
|
||||
> = {
|
||||
'oauth-credential': 'credential',
|
||||
'knowledge-base': 'knowledge-base',
|
||||
table: 'table',
|
||||
'mcp-server': 'mcp-server',
|
||||
}
|
||||
// `file` and `knowledge-document` are intentionally excluded from the generic
|
||||
// registry path. `file-upload` (workspace files) is remapped by storage key via
|
||||
// `remapForkFileUploadValue`; `file-selector` (external provider file ids,
|
||||
// credential-scoped) carries over unchanged; `document-selector` is cleared by the
|
||||
// `dependsOn` rule (clearDependentsOnRemap) when its parent knowledge base is remapped.
|
||||
// `mcp-tool-selector` is likewise cleared by `dependsOn` when its `mcp-server-selector`
|
||||
// parent is remapped - the tool list is server-scoped and may differ in the target.
|
||||
|
||||
/** Matches `{{ENV_KEY}}` references inside subblock values; shared with cascade detection. */
|
||||
export const ENV_REF_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g
|
||||
|
||||
/**
|
||||
* A `credentialSet:<id>` reference points at an ORG-scoped credential set. A fork
|
||||
* inherits its parent's org, so the set is already valid in the target — these refs
|
||||
* are preserved verbatim and never treated as a workspace credential to remap/flag.
|
||||
*/
|
||||
function isCredentialSetRef(value: string): boolean {
|
||||
return value.startsWith('credentialSet:')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a source-workspace resource id (or env key, for `env-var`) to its
|
||||
* mapped target id. Returns the target id (which may equal the source for env
|
||||
* keys that exist under the same name), or null/undefined when there is no
|
||||
* mapping — which surfaces the reference as unmapped.
|
||||
*/
|
||||
export type ForkReferenceResolver = (
|
||||
kind: ForkRemapKind,
|
||||
sourceId: string
|
||||
) => string | null | undefined
|
||||
|
||||
export interface ForkReference {
|
||||
kind: ForkRemapKind
|
||||
sourceId: string
|
||||
blockId?: string
|
||||
blockName?: string
|
||||
subBlockKey: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
export interface RemapSubBlocksResult {
|
||||
subBlocks: SubBlockRecord
|
||||
references: ForkReference[]
|
||||
unmapped: ForkReference[]
|
||||
/** Subblock keys whose resource id was rewritten/cleared this pass (the `dependsOn` parents). */
|
||||
remappedKeys: Set<string>
|
||||
}
|
||||
|
||||
function remapEnvInValue(
|
||||
value: unknown,
|
||||
resolve: ForkReferenceResolver,
|
||||
record: (sourceId: string, mapped: boolean) => void
|
||||
): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(ENV_REF_PATTERN, (full, key: string) => {
|
||||
const target = resolve('env-var', key)
|
||||
if (target == null) {
|
||||
record(key, false)
|
||||
return full
|
||||
}
|
||||
record(key, true)
|
||||
return `{{${target}}}`
|
||||
})
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => remapEnvInValue(item, resolve, record))
|
||||
}
|
||||
// Recurse plain objects so `{{ENV}}` nested in array-form tool params (and other
|
||||
// object-valued subblocks) is rewritten, not just top-level strings/arrays.
|
||||
if (isRecord(value)) {
|
||||
let changed = false
|
||||
const next: Record<string, unknown> = {}
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
const remapped = remapEnvInValue(nested, resolve, record)
|
||||
if (remapped !== nested) changed = true
|
||||
next[key] = remapped
|
||||
}
|
||||
return changed ? next : value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
interface ToolBlockRemapOptions {
|
||||
resolve: ForkReferenceResolver
|
||||
/** Resolve a copied file storage key; null when the file was not copied. */
|
||||
resolveFileKey: (sourceKey: string) => string | null
|
||||
/** Record a detected reference so it surfaces in the mapping UI / cascade. */
|
||||
record?: (kind: ForkRemapKind, sourceId: string, mapped: boolean) => void
|
||||
/** Fork-create clears unresolved copyable refs; promote keeps them (surfaced as unmapped). */
|
||||
clearUnresolved: boolean
|
||||
/** Injected block configs (production falls back to the block registry). */
|
||||
blockConfigs?: Parameters<typeof getToolInputParamConfigs>[0]['blockConfigs']
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the workspace-scoped resource ids nested inside a block tool's `params`
|
||||
* (credentials, KBs, tables, files, MCP servers). Param→subBlock-config resolution
|
||||
* reuses `getToolInputParamConfigs` so it matches exactly what the editor/search
|
||||
* index sees. Custom-tool / MCP / workflow_input tools carry their ids in dedicated
|
||||
* fields (handled by the callers / the workflow id map), not block params, so they
|
||||
* pass through here untouched. Returns a new tool object only when something changed.
|
||||
* After remapping, dependent params (via `dependsOn`) of any changed resource are
|
||||
* cleared with the same {@link getWorkflowSearchDependentClears} walk search-replace
|
||||
* uses, so a child scoped to the old parent isn't left stale.
|
||||
*/
|
||||
export function remapToolBlockResources(
|
||||
tool: Record<string, unknown>,
|
||||
opts: ToolBlockRemapOptions
|
||||
): Record<string, unknown> {
|
||||
if (typeof tool.type !== 'string') return tool
|
||||
const params = tool.params
|
||||
if (!isRecord(params)) return tool
|
||||
|
||||
let nextParams: Record<string, unknown> | null = null
|
||||
const setParam = (paramId: string, value: unknown) => {
|
||||
nextParams ??= { ...params }
|
||||
nextParams[paramId] = value
|
||||
}
|
||||
const remappedParamIds = new Set<string>()
|
||||
|
||||
// Id-keyed resource params (credential / triggerCredentials / manual* overrides):
|
||||
// walked from the raw params so they're caught even when their config is filtered
|
||||
// out by a reactive condition (the registry loop below would otherwise miss them).
|
||||
for (const paramId of Object.keys(params)) {
|
||||
const overrideKind = getToolParamOverrideKind(paramId)
|
||||
if (!overrideKind) continue
|
||||
const currentValue = params[paramId]
|
||||
if (typeof currentValue !== 'string' || !currentValue) continue
|
||||
if (overrideKind === 'credential' && isCredentialSetRef(currentValue)) continue
|
||||
const target = opts.resolve(overrideKind, currentValue)
|
||||
opts.record?.(overrideKind, currentValue, target != null)
|
||||
if (target != null) {
|
||||
if (target !== currentValue) {
|
||||
setParam(paramId, target)
|
||||
remappedParamIds.add(paramId)
|
||||
}
|
||||
} else if (opts.clearUnresolved) {
|
||||
setParam(paramId, '')
|
||||
remappedParamIds.add(paramId)
|
||||
}
|
||||
}
|
||||
|
||||
const toolView: ParsedStoredTool = {
|
||||
type: tool.type,
|
||||
operation: typeof tool.operation === 'string' ? tool.operation : undefined,
|
||||
toolId: typeof tool.toolId === 'string' ? tool.toolId : undefined,
|
||||
customToolId: typeof tool.customToolId === 'string' ? tool.customToolId : undefined,
|
||||
params,
|
||||
}
|
||||
let configs: ReturnType<typeof getToolInputParamConfigs>
|
||||
try {
|
||||
configs = getToolInputParamConfigs({ tool: toolView, blockConfigs: opts.blockConfigs })
|
||||
} catch (error) {
|
||||
// Unknown block / resolver failure: don't crash the fork/promote, but log so a
|
||||
// real bug isn't masked. Nested resource ids in this tool stay as-is.
|
||||
logger.warn('Could not resolve tool params for fork remap', {
|
||||
toolType: tool.type,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return nextParams ? { ...tool, params: nextParams } : tool
|
||||
}
|
||||
|
||||
for (const { paramId, config } of configs) {
|
||||
if (getToolParamOverrideKind(paramId)) continue
|
||||
const definition = getWorkflowSearchSubBlockResourceDefinition(config)
|
||||
if (!definition) continue
|
||||
const currentValue = (nextParams ?? params)[paramId]
|
||||
|
||||
if (definition.kind === 'file') {
|
||||
// file-upload (workspace file) remaps by storage key; file-selector (external
|
||||
// provider id) carries over unchanged.
|
||||
if (config.type !== 'file-upload') continue
|
||||
const remapped = remapForkFileUploadValue(currentValue, opts.resolveFileKey)
|
||||
if (remapped !== currentValue) {
|
||||
setParam(paramId, remapped)
|
||||
remappedParamIds.add(paramId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const forkKind = REGISTRY_KIND_TO_FORK_KIND[definition.kind]
|
||||
if (!forkKind) continue
|
||||
|
||||
const refs = parseWorkflowSearchSubBlockResources(currentValue, config)
|
||||
if (refs.length === 0) continue
|
||||
|
||||
let value = currentValue
|
||||
const seen = new Set<string>()
|
||||
for (const ref of refs) {
|
||||
if (seen.has(ref.rawValue)) continue
|
||||
seen.add(ref.rawValue)
|
||||
if (forkKind === 'credential' && isCredentialSetRef(ref.rawValue)) continue
|
||||
const target = opts.resolve(forkKind, ref.rawValue)
|
||||
const mapped = target != null
|
||||
opts.record?.(forkKind, ref.rawValue, mapped)
|
||||
if (mapped) {
|
||||
if (target !== ref.rawValue) {
|
||||
const replaced = definition.codec.replace(value, ref.rawValue, target)
|
||||
if (replaced.success) value = replaced.nextValue
|
||||
}
|
||||
} else if (opts.clearUnresolved) {
|
||||
// Drop only this unresolved entry (blank it - empties are filtered at parse
|
||||
// time), so a mixed copied/uncopied multi-value field keeps its copied refs.
|
||||
const replaced = definition.codec.replace(value, ref.rawValue, '')
|
||||
if (replaced.success) value = replaced.nextValue
|
||||
}
|
||||
}
|
||||
|
||||
if (value !== currentValue) {
|
||||
setParam(paramId, value)
|
||||
remappedParamIds.add(paramId)
|
||||
}
|
||||
}
|
||||
|
||||
if (remappedParamIds.size > 0) {
|
||||
const toolBlockConfig = opts.blockConfigs?.[tool.type] ?? getBlock(tool.type)
|
||||
const toolSubBlocks = toolBlockConfig?.subBlocks
|
||||
if (toolSubBlocks) {
|
||||
const currentParams = nextParams ?? params
|
||||
for (const paramId of remappedParamIds) {
|
||||
for (const clear of getWorkflowSearchDependentClears(toolSubBlocks, paramId)) {
|
||||
if (remappedParamIds.has(clear.subBlockId)) continue
|
||||
const existing = currentParams[clear.subBlockId]
|
||||
if (existing === '' || existing == null) continue
|
||||
setParam(clear.subBlockId, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextParams) return tool
|
||||
return { ...tool, params: nextParams }
|
||||
}
|
||||
|
||||
interface ForkToolInputOptions {
|
||||
/** Fork-create drops unresolved tools / clears params; promote keeps + records. */
|
||||
clearUnresolved: boolean
|
||||
record?: (kind: ForkRemapKind, sourceId: string, mapped: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite resource references inside a `tool-input` subblock (an array of
|
||||
* StoredTool). Custom-tool and MCP-server ids live in dedicated fields; every
|
||||
* other workspace-scoped id (credential, KB, table, file, MCP server) is nested in
|
||||
* a block tool's `params` and rewritten via {@link remapToolBlockResources}. The
|
||||
* MCP entry's derived `toolId` is rebuilt when the server id changes. On fork an
|
||||
* unresolved custom-tool/MCP tool is dropped; on promote it's kept and recorded.
|
||||
*/
|
||||
function remapForkToolInputValue(
|
||||
value: unknown,
|
||||
resolve: ForkReferenceResolver,
|
||||
opts: ForkToolInputOptions
|
||||
): unknown {
|
||||
const { array, wasString } = coerceObjectArray(value)
|
||||
if (!array) return value
|
||||
let changed = false
|
||||
const next = array.flatMap((tool) => {
|
||||
if (!isRecord(tool) || typeof tool.type !== 'string') return [tool]
|
||||
if (tool.type === 'custom-tool' && typeof tool.customToolId === 'string') {
|
||||
const target = resolve('custom-tool', tool.customToolId)
|
||||
opts.record?.('custom-tool', tool.customToolId, target != null)
|
||||
if (target != null) {
|
||||
if (target !== tool.customToolId) {
|
||||
changed = true
|
||||
return [{ ...tool, customToolId: target }]
|
||||
}
|
||||
return [tool]
|
||||
}
|
||||
if (opts.clearUnresolved) {
|
||||
changed = true
|
||||
return []
|
||||
}
|
||||
return [tool]
|
||||
}
|
||||
if (tool.type === 'mcp' && isRecord(tool.params) && typeof tool.params.serverId === 'string') {
|
||||
const serverId = tool.params.serverId
|
||||
const target = resolve('mcp-server', serverId)
|
||||
opts.record?.('mcp-server', serverId, target != null)
|
||||
if (target != null) {
|
||||
if (target !== serverId) {
|
||||
changed = true
|
||||
const toolName =
|
||||
typeof tool.params.toolName === 'string' ? tool.params.toolName : undefined
|
||||
return [
|
||||
{
|
||||
...tool,
|
||||
params: { ...tool.params, serverId: target },
|
||||
toolId: toolName ? createMcpToolId(target, toolName) : tool.toolId,
|
||||
},
|
||||
]
|
||||
}
|
||||
return [tool]
|
||||
}
|
||||
if (opts.clearUnresolved) {
|
||||
changed = true
|
||||
return []
|
||||
}
|
||||
return [tool]
|
||||
}
|
||||
const remapped = remapToolBlockResources(tool, {
|
||||
resolve,
|
||||
resolveFileKey: (key) => resolve('file', key) ?? null,
|
||||
record: opts.record,
|
||||
clearUnresolved: opts.clearUnresolved,
|
||||
})
|
||||
if (remapped !== tool) changed = true
|
||||
return [remapped]
|
||||
})
|
||||
if (!changed) return value
|
||||
return wasString ? JSON.stringify(next) : next
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite skill references inside a `skill-input` subblock (an array of
|
||||
* StoredSkill). Builtin skills (`builtin-*` ids) are workspace-agnostic and left
|
||||
* unchanged. On fork an unresolved skill is dropped; on promote it's kept + recorded.
|
||||
*/
|
||||
function remapForkSkillInputValue(
|
||||
value: unknown,
|
||||
resolve: ForkReferenceResolver,
|
||||
opts: ForkToolInputOptions
|
||||
): unknown {
|
||||
const { array, wasString } = coerceObjectArray(value)
|
||||
if (!array) return value
|
||||
let changed = false
|
||||
const next = array.flatMap((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.skillId !== 'string') return [entry]
|
||||
if (entry.skillId.startsWith('builtin-')) return [entry]
|
||||
const target = resolve('skill', entry.skillId)
|
||||
opts.record?.('skill', entry.skillId, target != null)
|
||||
if (target != null) {
|
||||
if (target !== entry.skillId) {
|
||||
changed = true
|
||||
return [{ ...entry, skillId: target }]
|
||||
}
|
||||
return [entry]
|
||||
}
|
||||
if (opts.clearUnresolved) {
|
||||
changed = true
|
||||
return []
|
||||
}
|
||||
return [entry]
|
||||
})
|
||||
if (!changed) return value
|
||||
return wasString ? JSON.stringify(next) : next
|
||||
}
|
||||
|
||||
/**
|
||||
* Single subblock remapper shared by fork-create and promote (the `mode` selects
|
||||
* the policy - see below). Structured selectors use the search-replace registry
|
||||
* codecs; advanced-mode `manual*` overrides and nested tool params are handled by
|
||||
* id; `{{ENV}}` refs are inline string references. Returns the rewritten subBlocks
|
||||
* plus, in promote mode, the detected and still-unmapped references.
|
||||
*/
|
||||
export function remapForkSubBlocks(
|
||||
subBlocks: SubBlockRecord,
|
||||
resolve: ForkReferenceResolver,
|
||||
mode: 'create' | 'promote',
|
||||
context?: { blockId?: string; blockName?: string }
|
||||
): RemapSubBlocksResult {
|
||||
const clearUnresolved = true
|
||||
const result: SubBlockRecord = {}
|
||||
const references = new Map<string, ForkReference>()
|
||||
const unmapped = new Map<string, ForkReference>()
|
||||
const remappedKeys = new Set<string>()
|
||||
|
||||
const recordReference = (key: string, reference: ForkReference, mapped: boolean) => {
|
||||
if (mode !== 'promote') return
|
||||
references.set(key, reference)
|
||||
if (!mapped) unmapped.set(key, reference)
|
||||
}
|
||||
|
||||
for (const [subBlockKey, subBlock] of Object.entries(subBlocks)) {
|
||||
if (!subBlock || typeof subBlock !== 'object') {
|
||||
result[subBlockKey] = subBlock
|
||||
continue
|
||||
}
|
||||
|
||||
let value = subBlock.value
|
||||
const valueBeforeResource = value
|
||||
const subBlockType = typeof subBlock.type === 'string' ? subBlock.type : undefined
|
||||
|
||||
const definition = getWorkflowSearchSubBlockResourceDefinition(
|
||||
subBlockType ? { type: subBlockType as SubBlockType } : undefined
|
||||
)
|
||||
const forkKind = definition ? REGISTRY_KIND_TO_FORK_KIND[definition.kind] : undefined
|
||||
|
||||
if (definition && forkKind && subBlockType) {
|
||||
const parsed = parseWorkflowSearchSubBlockResources(value, {
|
||||
type: subBlockType as SubBlockType,
|
||||
})
|
||||
const seen = new Set<string>()
|
||||
for (const ref of parsed) {
|
||||
if (seen.has(ref.rawValue)) continue
|
||||
seen.add(ref.rawValue)
|
||||
if (forkKind === 'credential' && isCredentialSetRef(ref.rawValue)) continue
|
||||
const required = REQUIRED_KINDS.has(forkKind)
|
||||
const reference: ForkReference = {
|
||||
kind: forkKind,
|
||||
sourceId: ref.rawValue,
|
||||
blockId: context?.blockId,
|
||||
blockName: context?.blockName,
|
||||
subBlockKey,
|
||||
required,
|
||||
}
|
||||
const target = resolve(forkKind, ref.rawValue)
|
||||
const mapped = target != null
|
||||
recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped)
|
||||
if (mapped) {
|
||||
if (target !== ref.rawValue) {
|
||||
const replaceResult = definition.codec.replace(value, ref.rawValue, target)
|
||||
if (replaceResult.success) value = replaceResult.nextValue
|
||||
}
|
||||
} else if (clearUnresolved) {
|
||||
// Drop only this unresolved entry (blank it - empties are filtered at
|
||||
// parse time) so a mixed copied/uncopied multi-value field keeps its rest.
|
||||
const replaceResult = definition.codec.replace(value, ref.rawValue, '')
|
||||
if (replaceResult.success) value = replaceResult.nextValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (subBlockType === 'file-upload') {
|
||||
// Workspace-file refs don't sync on promote (the target lacks the source's
|
||||
// blob); clear them rather than carry a cross-workspace key. On fork, the
|
||||
// resolver returns the copied key. `file-selector` (external) is untouched.
|
||||
value = remapForkFileUploadValue(value, (sourceKey) => resolve('file', sourceKey) ?? null)
|
||||
} else if (subBlockType === 'tool-input' || subBlockType === 'skill-input') {
|
||||
const record = (kind: ForkRemapKind, sourceId: string, mapped: boolean) =>
|
||||
recordReference(
|
||||
`${kind}:${sourceId}`,
|
||||
{
|
||||
kind,
|
||||
sourceId,
|
||||
blockId: context?.blockId,
|
||||
blockName: context?.blockName,
|
||||
subBlockKey,
|
||||
required: REQUIRED_KINDS.has(kind),
|
||||
},
|
||||
mapped
|
||||
)
|
||||
value =
|
||||
subBlockType === 'tool-input'
|
||||
? remapForkToolInputValue(value, resolve, { clearUnresolved, record })
|
||||
: remapForkSkillInputValue(value, resolve, { clearUnresolved, record })
|
||||
}
|
||||
|
||||
if (value !== valueBeforeResource) remappedKeys.add(subBlockKey)
|
||||
|
||||
// Promote rewrites `{{ENV}}` refs via the resolver; fork preserves them by name.
|
||||
if (mode === 'promote') {
|
||||
value = remapEnvInValue(value, resolve, (sourceId, mapped) => {
|
||||
recordReference(
|
||||
`env-var:${sourceId}`,
|
||||
{
|
||||
kind: 'env-var',
|
||||
sourceId,
|
||||
blockId: context?.blockId,
|
||||
blockName: context?.blockName,
|
||||
subBlockKey,
|
||||
required: true,
|
||||
},
|
||||
mapped
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
result[subBlockKey] = { ...subBlock, value }
|
||||
}
|
||||
|
||||
return {
|
||||
subBlocks: result,
|
||||
references: Array.from(references.values()),
|
||||
unmapped: Array.from(unmapped.values()),
|
||||
remappedKeys,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear every subblock whose `dependsOn` parent was remapped to a different
|
||||
* target this pass, so a child scoped to the old parent (a KB's document, a
|
||||
* Slack channel, a sheet tab) never carries a stale id into the target. Reuses
|
||||
* the search-replace dependent-clear walk (canonical-pair aware, transitive over
|
||||
* `dependsOn` chains) so fork/promote and in-editor search-replace clear
|
||||
* identically. Children of an unchanged parent are preserved; a no-op for
|
||||
* unknown block types or when nothing was remapped.
|
||||
*/
|
||||
export function clearDependentsOnRemap(
|
||||
subBlocks: SubBlockRecord,
|
||||
blockType: string,
|
||||
remappedKeys: ReadonlySet<string>
|
||||
): SubBlockRecord {
|
||||
if (remappedKeys.size === 0) return subBlocks
|
||||
const config = getBlock(blockType)
|
||||
if (!config) return subBlocks
|
||||
|
||||
const toClear = new Set<string>()
|
||||
for (const key of remappedKeys) {
|
||||
for (const clear of getWorkflowSearchDependentClears(config.subBlocks, key)) {
|
||||
if (!remappedKeys.has(clear.subBlockId)) toClear.add(clear.subBlockId)
|
||||
}
|
||||
}
|
||||
|
||||
let next: SubBlockRecord | null = null
|
||||
for (const id of toClear) {
|
||||
const existing = subBlocks[id]
|
||||
if (!existing || typeof existing !== 'object') continue
|
||||
if (existing.value === '' || existing.value == null) continue
|
||||
next ??= { ...subBlocks }
|
||||
next[id] = { ...existing, value: '' }
|
||||
}
|
||||
return next ?? subBlocks
|
||||
}
|
||||
|
||||
/**
|
||||
* A dependent field a sync left empty (it had a source value). `required` ones gate Sync
|
||||
* and skip redeploy; optional ones are surfaced so a cleared filter never broadens silently.
|
||||
*/
|
||||
export interface NeedsConfigurationField {
|
||||
blockId: string
|
||||
blockName: string
|
||||
subBlockKey: string
|
||||
title: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
/** Evaluate a subblock's `required` (boolean | condition | fn) against a value map. */
|
||||
export function isSubBlockRequired(
|
||||
required: SubBlockConfig['required'],
|
||||
values: Record<string, unknown>
|
||||
): boolean {
|
||||
if (required === true) return true
|
||||
if (!required) return false
|
||||
// The object/function forms are structurally a SubBlockCondition.
|
||||
return evaluateSubBlockCondition(
|
||||
required as Parameters<typeof evaluateSubBlockCondition>[0],
|
||||
values
|
||||
)
|
||||
}
|
||||
|
||||
/** Nested `tool-input` dependents (Agent/tool blocks) the TARGET configured that a remap cleared. */
|
||||
function collectClearedToolParamDependents(
|
||||
toolInputKey: string,
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
targetCurrentValue: unknown,
|
||||
mergedValue: unknown,
|
||||
out: NeedsConfigurationField[]
|
||||
): void {
|
||||
const { array: targetTools } = coerceObjectArray(targetCurrentValue)
|
||||
const { array: mergedTools } = coerceObjectArray(mergedValue)
|
||||
if (!mergedTools || !targetTools) return
|
||||
// Index pairing is only safe when the tool sets line up; otherwise skip rather than
|
||||
// pair a cleared param against the wrong tool.
|
||||
if (targetTools.length !== mergedTools.length) return
|
||||
for (let index = 0; index < mergedTools.length; index++) {
|
||||
const tool = mergedTools[index]
|
||||
const targetTool = targetTools[index]
|
||||
if (!isRecord(tool) || typeof tool.type !== 'string') continue
|
||||
if (!isRecord(targetTool) || targetTool.type !== tool.type) continue
|
||||
const toolConfig = getBlock(tool.type)
|
||||
if (!toolConfig) continue
|
||||
const targetParams = isRecord(targetTool.params) ? targetTool.params : {}
|
||||
const mergedParams = isRecord(tool.params) ? tool.params : {}
|
||||
// A tool's `operation` lives at the tool level, not in params, but conditions
|
||||
// reference it - merge it in so condition/required gating matches the editor.
|
||||
const mergedValues =
|
||||
typeof tool.operation === 'string'
|
||||
? { operation: tool.operation, ...mergedParams }
|
||||
: mergedParams
|
||||
const toolLabel = typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name
|
||||
for (const cfg of toolConfig.subBlocks) {
|
||||
if (!cfg.dependsOn || !cfg.id) continue
|
||||
// Only flag a param the TARGET tool had configured (not one the source carried in).
|
||||
if (!isNonEmptyValue(targetParams[cfg.id])) continue
|
||||
if (isNonEmptyValue(mergedParams[cfg.id])) continue
|
||||
// Skip fields gated off by their `condition` (a stale value under an inactive
|
||||
// operation isn't actually required now).
|
||||
if (cfg.condition && !evaluateSubBlockCondition(cfg.condition, mergedValues)) continue
|
||||
out.push({
|
||||
blockId,
|
||||
blockName,
|
||||
subBlockKey: `${toolInputKey}[${index}].${cfg.id}`,
|
||||
title: `${toolLabel}: ${cfg.title ?? cfg.id}`,
|
||||
required: isSubBlockRequired(cfg.required, mergedValues),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `dependsOn` children the TARGET workspace had configured (in its draft) that the merge
|
||||
* left empty - the parent they hang off was swapped/remapped and the value wasn't restored
|
||||
* or re-picked. Keyed on the TARGET draft (not the source) so a field the source carries
|
||||
* but the target never configured is NOT flagged (e.g. a pull bringing in the parent's
|
||||
* label filter the fork never set). Covers top-level subblocks AND nested `tool-input`
|
||||
* params. `required` ones must be re-picked before the workflow can run (promote skips
|
||||
* their redeploy); optional ones are surfaced so a filter the swap cleared never broadens
|
||||
* behavior silently. Pure; `mergedSubBlocks` is the final state about to be written,
|
||||
* `targetCurrentSubBlocks` the target's pre-sync draft.
|
||||
*/
|
||||
export function collectClearedDependents(
|
||||
blockType: string,
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
targetCurrentSubBlocks: SubBlockRecord,
|
||||
mergedSubBlocks: SubBlockRecord
|
||||
): NeedsConfigurationField[] {
|
||||
const config = getBlock(blockType)
|
||||
if (!config) return []
|
||||
const targetValues = buildSubBlockValues(targetCurrentSubBlocks)
|
||||
const mergedValues = buildSubBlockValues(mergedSubBlocks)
|
||||
const fields: NeedsConfigurationField[] = []
|
||||
for (const cfg of config.subBlocks) {
|
||||
if (!cfg.id) continue
|
||||
// Only flag a field the target had configured (so the user lost their own selection),
|
||||
// still empty after merge, and currently active (a value under a now-inactive
|
||||
// `condition`/operation isn't really in play).
|
||||
if (
|
||||
cfg.dependsOn &&
|
||||
isNonEmptyValue(targetValues[cfg.id]) &&
|
||||
!isNonEmptyValue(mergedValues[cfg.id]) &&
|
||||
(!cfg.condition || evaluateSubBlockCondition(cfg.condition, mergedValues))
|
||||
) {
|
||||
fields.push({
|
||||
blockId,
|
||||
blockName,
|
||||
subBlockKey: cfg.id,
|
||||
title: cfg.title ?? cfg.id,
|
||||
required: isSubBlockRequired(cfg.required, mergedValues),
|
||||
})
|
||||
}
|
||||
if (cfg.type === 'tool-input') {
|
||||
collectClearedToolParamDependents(
|
||||
cfg.id,
|
||||
blockId,
|
||||
blockName,
|
||||
targetValues[cfg.id],
|
||||
mergedValues[cfg.id],
|
||||
fields
|
||||
)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a nested dependent/override key `toolInput[index].paramId` into its parts (the index
|
||||
* coerced to a number). Returns null for a plain top-level subblock key. Shared by the diff's
|
||||
* first-sync draft reader and the override apply so both parse the `toolInput[index].paramId`
|
||||
* shape identically.
|
||||
*/
|
||||
export function parseNestedDependentKey(
|
||||
key: string
|
||||
): { toolInputId: string; index: number; paramId: string } | null {
|
||||
const match = /^([^[]+)\[(\d+)\]\.(.+)$/.exec(key)
|
||||
if (!match) return null
|
||||
const [, toolInputId, indexStr, paramId] = match
|
||||
return { toolInputId, index: Number(indexStr), paramId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a dependent field's currently-configured value from a target block's draft subBlocks -
|
||||
* the first-sync fallback used when the stored mapping has no entry yet. Seeds the diff pre-fill
|
||||
* from the TARGET (never the source, which would overwrite the target's own selection on an edge
|
||||
* that predates the store). Identity-aware: for a nested `toolInput[index].paramId` key it only
|
||||
* reads the target draft's param when the target tool at that index is the SAME tool type the
|
||||
* SOURCE dependent hangs off; otherwise that index holds a different tool whose value isn't this
|
||||
* field's. Returns '' when unset or when identity can't be verified.
|
||||
*
|
||||
* Both records are read structurally (only each subblock's `value`), so callers can pass either a
|
||||
* persisted {@link SubBlockRecord} (the target draft) or an in-memory `WorkflowState` block's
|
||||
* subblocks (the source) without a cast.
|
||||
*/
|
||||
export function readTargetDraftDependentValue(
|
||||
targetDraftSubBlocks: Record<string, { value?: unknown }> | undefined,
|
||||
sourceSubBlocks: Record<string, { value?: unknown }> | undefined,
|
||||
subBlockKey: string
|
||||
): string {
|
||||
if (!targetDraftSubBlocks) return ''
|
||||
const nested = parseNestedDependentKey(subBlockKey)
|
||||
if (nested) {
|
||||
const { toolInputId, index, paramId } = nested
|
||||
const targetTool = coerceObjectArray(targetDraftSubBlocks[toolInputId]?.value).array?.[index]
|
||||
if (!isRecord(targetTool) || typeof targetTool.type !== 'string') return ''
|
||||
const sourceTool = coerceObjectArray(sourceSubBlocks?.[toolInputId]?.value).array?.[index]
|
||||
if (!isRecord(sourceTool) || sourceTool.type !== targetTool.type) return ''
|
||||
const params = isRecord(targetTool.params) ? targetTool.params : {}
|
||||
const value = params[paramId]
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
// TODO(fork): identity-guard top-level reads too - only seed when the target draft's parent
|
||||
// (credential/KB/table) still equals the mapped target. Threading the parent subblock id and
|
||||
// mapped target value here is invasive, and a changed parent is already blanked by the modal's
|
||||
// `parentChanged` logic, leaving only a narrow same-index/different-parent first-sync edge.
|
||||
const value = targetDraftSubBlocks[subBlockKey]?.value
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply nested `tool-input` overrides onto one tool array, matching by index and
|
||||
* allowlisting each param to the tool's own reconfigurable dependents (dependsOn +
|
||||
* selectorKey). Returns the same reference when nothing applied. Handles the array and
|
||||
* JSON-string stored shapes.
|
||||
*/
|
||||
function applyNestedToolOverrides(
|
||||
value: unknown,
|
||||
items: ReadonlyArray<{ index: number; paramId: string; value: string }>
|
||||
): unknown {
|
||||
const { array, wasString } = coerceObjectArray(value)
|
||||
if (!array) return value
|
||||
let changed = false
|
||||
const merged = array.map((tool, index) => {
|
||||
const forTool = items.filter((item) => item.index === index)
|
||||
if (forTool.length === 0) return tool
|
||||
if (!isRecord(tool) || typeof tool.type !== 'string') return tool
|
||||
const toolConfig = getBlock(tool.type)
|
||||
if (!toolConfig) return tool
|
||||
const allowed = new Set(
|
||||
toolConfig.subBlocks
|
||||
.filter((cfg) => cfg.id && cfg.dependsOn && cfg.selectorKey)
|
||||
.map((cfg) => cfg.id)
|
||||
)
|
||||
const params = isRecord(tool.params) ? tool.params : {}
|
||||
let nextParams: Record<string, unknown> | null = null
|
||||
for (const item of forTool) {
|
||||
if (!allowed.has(item.paramId)) continue
|
||||
nextParams ??= { ...params }
|
||||
nextParams[item.paramId] = item.value
|
||||
}
|
||||
if (!nextParams) return tool
|
||||
changed = true
|
||||
return { ...tool, params: nextParams }
|
||||
})
|
||||
if (!changed) return value
|
||||
return wasString ? JSON.stringify(merged) : merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the stored dependent mapping onto the merged subBlocks (called last, after the
|
||||
* reference transform cleared the source's dependent values, so the stored mapping is the sole
|
||||
* source of truth for what each selector resolves to). Allowlisted to the block's
|
||||
* reconfigurable dependent selectors (`dependsOn` + `selectorKey`) - top-level subblocks AND
|
||||
* nested `tool-input` params keyed `toolInput[index].paramId` - so a crafted value can never
|
||||
* set a parent/credential field (bypassing mapping validation) or inject a bogus subblock.
|
||||
* Returns a new record only when something applied.
|
||||
*/
|
||||
export function applyDependentOverrides(
|
||||
subBlocks: SubBlockRecord,
|
||||
blockType: string,
|
||||
overrides: ReadonlyMap<string, string>
|
||||
): SubBlockRecord {
|
||||
const config = getBlock(blockType)
|
||||
if (!config || overrides.size === 0) return subBlocks
|
||||
|
||||
const allowedTopLevel = new Set<string>()
|
||||
const toolInputIds = new Set<string>()
|
||||
for (const cfg of config.subBlocks) {
|
||||
if (!cfg.id) continue
|
||||
if (cfg.dependsOn && cfg.selectorKey) allowedTopLevel.add(cfg.id)
|
||||
if (cfg.type === 'tool-input') toolInputIds.add(cfg.id)
|
||||
}
|
||||
|
||||
const nestedByTool = new Map<string, Array<{ index: number; paramId: string; value: string }>>()
|
||||
let next: SubBlockRecord | null = null
|
||||
|
||||
for (const [key, value] of overrides) {
|
||||
const nested = parseNestedDependentKey(key)
|
||||
if (nested) {
|
||||
if (!toolInputIds.has(nested.toolInputId)) continue
|
||||
const list = nestedByTool.get(nested.toolInputId) ?? []
|
||||
list.push({ index: nested.index, paramId: nested.paramId, value })
|
||||
nestedByTool.set(nested.toolInputId, list)
|
||||
continue
|
||||
}
|
||||
if (!allowedTopLevel.has(key)) continue
|
||||
next ??= { ...subBlocks }
|
||||
// The allowlist already proves this is a real dependent selector, so create the entry
|
||||
// if the merge dropped it (don't silently skip a legitimate re-pick).
|
||||
const existing = next[key]
|
||||
next[key] = existing && typeof existing === 'object' ? { ...existing, value } : { value }
|
||||
}
|
||||
|
||||
for (const [toolInputId, items] of nestedByTool) {
|
||||
const existing = (next ?? subBlocks)[toolInputId]
|
||||
if (!existing || typeof existing !== 'object') continue
|
||||
const updated = applyNestedToolOverrides(existing.value, items)
|
||||
if (updated === existing.value) continue
|
||||
next ??= { ...subBlocks }
|
||||
next[toolInputId] = { ...existing, value: updated }
|
||||
}
|
||||
|
||||
return next ?? subBlocks
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote-mode remap (keep + record unmapped references). Thin wrapper over
|
||||
* {@link remapForkSubBlocks}; also used by the reference scan.
|
||||
*/
|
||||
export function remapSubBlocks(
|
||||
subBlocks: SubBlockRecord,
|
||||
resolve: ForkReferenceResolver,
|
||||
context?: { blockId?: string; blockName?: string }
|
||||
): RemapSubBlocksResult {
|
||||
return remapForkSubBlocks(subBlocks, resolve, 'promote', context)
|
||||
}
|
||||
|
||||
/** A `copyWorkflowStateIntoTarget` subBlock transform that rewrites references via the resolver. */
|
||||
export function createForkSubBlockTransform(
|
||||
resolve: ForkReferenceResolver
|
||||
): (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord {
|
||||
return (subBlocks, blockType) => {
|
||||
const result = remapSubBlocks(subBlocks, resolve)
|
||||
return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowReferenceScan {
|
||||
references: ForkReference[]
|
||||
unmapped: ForkReference[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a set of blocks for all remappable references, aggregating unique
|
||||
* (kind, sourceId) pairs across the workflow. Used by the mapping/diff/promote
|
||||
* paths to surface what needs mapping and to block on unmapped required refs.
|
||||
*/
|
||||
export function scanWorkflowReferences(
|
||||
blocks: Array<{ id: string; name: string; subBlocks: unknown }>,
|
||||
resolve: ForkReferenceResolver
|
||||
): WorkflowReferenceScan {
|
||||
const references = new Map<string, ForkReference>()
|
||||
const unmapped = new Map<string, ForkReference>()
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.subBlocks || typeof block.subBlocks !== 'object' || Array.isArray(block.subBlocks)) {
|
||||
continue
|
||||
}
|
||||
const blockResult = remapSubBlocks(block.subBlocks as SubBlockRecord, resolve, {
|
||||
blockId: block.id,
|
||||
blockName: block.name,
|
||||
})
|
||||
for (const reference of blockResult.references) {
|
||||
const key = `${reference.kind}:${reference.sourceId}`
|
||||
if (!references.has(key)) references.set(key, reference)
|
||||
}
|
||||
for (const reference of blockResult.unmapped) {
|
||||
const key = `${reference.kind}:${reference.sourceId}`
|
||||
if (!unmapped.has(key)) unmapped.set(key, reference)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
references: Array.from(references.values()),
|
||||
unmapped: Array.from(unmapped.values()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TableSchema } from '@/lib/table/types'
|
||||
import { deriveForkBlockId } from '@/lib/workspaces/fork/remap/block-identity'
|
||||
import { remapForkTableWorkflowGroups } from '@/lib/workspaces/fork/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')
|
||||
})
|
||||
|
||||
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,53 @@
|
||||
import type { TableSchema } from '@/lib/table/types'
|
||||
import { deriveForkBlockId } from '@/lib/workspaces/fork/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 to the deterministic forked block id (matching
|
||||
* `copyWorkflowStateIntoTarget`). 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>
|
||||
): 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
|
||||
? deriveForkBlockId(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 }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getSocketServerUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('WorkspaceForkSocket')
|
||||
|
||||
async function postToRealtime(path: string, workflowId: string): Promise<void> {
|
||||
const response = await fetch(`${getSocketServerUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET },
|
||||
body: JSON.stringify({ workflowId }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path} responded ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify connected canvas clients that a fork promote/rollback force-replaced a
|
||||
* workflow's state. This mirrors a mothership edit rather than a passive ping:
|
||||
*
|
||||
* - `workflow-updated` makes each client reload the workflow from the API the same
|
||||
* way it reacts to an external full-state edit (copilot / state route), deferring
|
||||
* while a local diff or unsaved operations are pending so it never clobbers
|
||||
* in-flight work. Without this the canvas keeps the stale state and a later local
|
||||
* edit would overwrite the freshly-synced state.
|
||||
* - `workflow-deployed` refreshes the deployment indicator (the promote/rollback also
|
||||
* changed which version is deployed).
|
||||
*
|
||||
* Best-effort and independent: each notification is attempted regardless of the
|
||||
* other, and a failure only warns - it never blocks the promote/rollback.
|
||||
*/
|
||||
export async function notifyForkWorkflowChanged(workflowId: string): Promise<void> {
|
||||
const results = await Promise.allSettled([
|
||||
postToRealtime('/api/workflow-updated', workflowId),
|
||||
postToRealtime('/api/workflow-deployed', workflowId),
|
||||
])
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn('Fork sync socket notification failed', { workflowId, error: result.reason })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,45 @@ describe('getWorkspaceCreationPolicy', () => {
|
||||
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('without pinning, a null active org falls back to the caller membership org', async () => {
|
||||
mockFeatureFlags.isBillingEnabled = false
|
||||
mockGetUserOrganization.mockResolvedValue({
|
||||
organizationId: 'user-org',
|
||||
role: 'admin',
|
||||
memberId: 'member-1',
|
||||
})
|
||||
mockDbResults.value = [[{ userId: 'owner-1' }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({
|
||||
userId: 'user-1',
|
||||
activeOrganizationId: null,
|
||||
})
|
||||
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION)
|
||||
expect(result.organizationId).toBe('user-org')
|
||||
})
|
||||
|
||||
it('pins to the source org: a personal source (null) stays personal regardless of caller org', async () => {
|
||||
mockFeatureFlags.isBillingEnabled = false
|
||||
mockGetUserOrganization.mockResolvedValue({
|
||||
organizationId: 'user-org',
|
||||
role: 'admin',
|
||||
memberId: 'member-1',
|
||||
})
|
||||
mockDbResults.value = [[{ value: 0 }]]
|
||||
|
||||
const result = await getWorkspaceCreationPolicy({
|
||||
userId: 'user-1',
|
||||
activeOrganizationId: null,
|
||||
pinOrganization: true,
|
||||
})
|
||||
|
||||
expect(result.canCreate).toBe(true)
|
||||
expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL)
|
||||
expect(result.organizationId).toBeNull()
|
||||
expect(result.billedAccountUserId).toBe('user-1')
|
||||
})
|
||||
|
||||
it('allows org admins on a team plan to create organization workspaces', async () => {
|
||||
mockGetUserOrganization.mockResolvedValueOnce({
|
||||
organizationId: 'org-1',
|
||||
|
||||
@@ -54,6 +54,13 @@ export interface WorkspaceCreationPolicy {
|
||||
interface GetWorkspaceCreationPolicyParams {
|
||||
userId: string
|
||||
activeOrganizationId?: string | null
|
||||
/**
|
||||
* When true, `activeOrganizationId` is authoritative: it is used exactly as given
|
||||
* (including `null`, which means a personal workspace) and never falls back to the
|
||||
* caller's membership org. Forks set this so the child always lands in the SOURCE's
|
||||
* org, not whatever org the acting user happens to belong to.
|
||||
*/
|
||||
pinOrganization?: boolean
|
||||
}
|
||||
|
||||
export function isOrganizationWorkspace(
|
||||
@@ -215,9 +222,12 @@ export async function getInvitePlanCategoryForUser(userId: string): Promise<Plan
|
||||
export async function getWorkspaceCreationPolicy({
|
||||
userId,
|
||||
activeOrganizationId,
|
||||
pinOrganization = false,
|
||||
}: GetWorkspaceCreationPolicyParams): Promise<WorkspaceCreationPolicy> {
|
||||
const membership = await getUserOrganization(userId)
|
||||
const organizationId = activeOrganizationId ?? membership?.organizationId ?? null
|
||||
const organizationId = pinOrganization
|
||||
? (activeOrganizationId ?? null)
|
||||
: (activeOrganizationId ?? membership?.organizationId ?? null)
|
||||
const orgRole =
|
||||
organizationId == null
|
||||
? undefined
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
|
||||
@@ -1312,6 +1312,18 @@ cronjobs:
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
|
||||
# Processes the transactional outbox (deployment side-effects retry / dead-letter)
|
||||
# and reaps stale workspace-fork background-work rows. Both are safety nets behind
|
||||
# the immediate post-commit processing, so frequent runs keep retries timely.
|
||||
outboxProcess:
|
||||
enabled: true
|
||||
name: outbox-process
|
||||
schedule: "*/1 * * * *"
|
||||
path: "/api/webhooks/outbox/process"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
|
||||
# Global CronJob settings
|
||||
image:
|
||||
repository: curlimages/curl
|
||||
|
||||
@@ -182,6 +182,9 @@ export const AuditAction = {
|
||||
WORKSPACE_UPDATED: 'workspace.updated',
|
||||
WORKSPACE_DELETED: 'workspace.deleted',
|
||||
WORKSPACE_DUPLICATED: 'workspace.duplicated',
|
||||
WORKSPACE_FORKED: 'workspace.forked',
|
||||
WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted',
|
||||
WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back',
|
||||
} as const
|
||||
|
||||
export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- Replay-safety: this file ends in a CONCURRENTLY index op below an embedded COMMIT,
|
||||
-- so a failure there replays the whole file from the top — every statement here is idempotent.
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."workspace_fork_promote_direction" AS ENUM('push', 'pull');
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."workspace_fork_resource_type" AS ENUM('workflow', 'oauth_credential', 'service_account_credential', 'env_var', 'table', 'knowledge_base', 'knowledge_document', 'file', 'mcp_server', 'custom_tool', 'skill');
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "workspace_fork_promote_run" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"child_workspace_id" text NOT NULL,
|
||||
"source_workspace_id" text NOT NULL,
|
||||
"target_workspace_id" text NOT NULL,
|
||||
"direction" "workspace_fork_promote_direction" NOT NULL,
|
||||
"snapshot" jsonb NOT NULL,
|
||||
"created_by" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "workspace_fork_resource_map" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"child_workspace_id" text NOT NULL,
|
||||
"resource_type" "workspace_fork_resource_type" NOT NULL,
|
||||
"parent_resource_id" text NOT NULL,
|
||||
"child_resource_id" text,
|
||||
"created_by" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "forked_from_workspace_id" text;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_fork_promote_run" ADD CONSTRAINT "workspace_fork_promote_run_child_workspace_id_workspace_id_fk" FOREIGN KEY ("child_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_fork_promote_run" ADD CONSTRAINT "workspace_fork_promote_run_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_fork_resource_map" ADD CONSTRAINT "workspace_fork_resource_map_child_workspace_id_workspace_id_fk" FOREIGN KEY ("child_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_fork_resource_map" ADD CONSTRAINT "workspace_fork_resource_map_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_forked_from_workspace_id_workspace_id_fk" FOREIGN KEY ("forked_from_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE set null ON UPDATE no action NOT VALID;
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "workspace_fork_promote_run_child_ws_target_unique" ON "workspace_fork_promote_run" USING btree ("child_workspace_id","target_workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "workspace_fork_promote_run_target_ws_idx" ON "workspace_fork_promote_run" USING btree ("target_workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "workspace_fork_resource_map_child_ws_idx" ON "workspace_fork_resource_map" USING btree ("child_workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "workspace_fork_resource_map_child_ws_type_idx" ON "workspace_fork_resource_map" USING btree ("child_workspace_id","resource_type");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "workspace_fork_resource_map_child_type_parent_unique" ON "workspace_fork_resource_map" USING btree ("child_workspace_id","resource_type","parent_resource_id");--> statement-breakpoint
|
||||
-- workspace is an existing table: build its new index CONCURRENTLY so the build never
|
||||
-- write-locks the relation (runner convention — plain CREATE INDEX takes ACCESS EXCLUSIVE).
|
||||
COMMIT;--> statement-breakpoint
|
||||
SET lock_timeout = 0;--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "workspace_forked_from_workspace_id_idx" ON "workspace" USING btree ("forked_from_workspace_id");--> statement-breakpoint
|
||||
SET lock_timeout = '5s';
|
||||
@@ -0,0 +1,50 @@
|
||||
CREATE TYPE "public"."background_work_kind" AS ENUM('deployment_side_effects', 'fork_content_copy', 'fork_sync', 'fork_rollback');--> statement-breakpoint
|
||||
CREATE TYPE "public"."background_work_status_value" AS ENUM('pending', 'processing', 'completed', 'completed_with_warnings', 'failed');--> statement-breakpoint
|
||||
CREATE TABLE "background_work_status" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"workspace_id" text NOT NULL,
|
||||
"workflow_id" text,
|
||||
"kind" "background_work_kind" NOT NULL,
|
||||
"status" "background_work_status_value" NOT NULL,
|
||||
"message" text,
|
||||
"error" text,
|
||||
"metadata" jsonb,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workspace_fork_block_map" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"child_workspace_id" text NOT NULL,
|
||||
"parent_workflow_id" text NOT NULL,
|
||||
"parent_block_id" text NOT NULL,
|
||||
"child_workflow_id" text NOT NULL,
|
||||
"child_block_id" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workspace_fork_dependent_value" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"child_workspace_id" text NOT NULL,
|
||||
"target_workflow_id" text NOT NULL,
|
||||
"target_block_id" text NOT NULL,
|
||||
"sub_block_key" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "background_work_status" ADD CONSTRAINT "background_work_status_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "background_work_status" ADD CONSTRAINT "background_work_status_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_fork_block_map" ADD CONSTRAINT "workspace_fork_block_map_child_workspace_id_workspace_id_fk" FOREIGN KEY ("child_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_fork_dependent_value" ADD CONSTRAINT "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk" FOREIGN KEY ("child_workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "background_work_status_workspace_status_idx" ON "background_work_status" USING btree ("workspace_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "background_work_status_workflow_status_idx" ON "background_work_status" USING btree ("workflow_id","status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "workspace_fork_block_map_child_ws_parent_unique" ON "workspace_fork_block_map" USING btree ("child_workspace_id","parent_block_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "workspace_fork_block_map_child_ws_child_unique" ON "workspace_fork_block_map" USING btree ("child_workspace_id","child_block_id");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_fork_block_map_child_ws_parent_wf_idx" ON "workspace_fork_block_map" USING btree ("child_workspace_id","parent_workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_fork_block_map_child_ws_child_wf_idx" ON "workspace_fork_block_map" USING btree ("child_workspace_id","child_workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_fork_dependent_value_child_ws_wf_idx" ON "workspace_fork_dependent_value" USING btree ("child_workspace_id","target_workflow_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "workspace_fork_dependent_value_field_unique" ON "workspace_fork_dependent_value" USING btree ("child_workspace_id","target_workflow_id","target_block_id","sub_block_key");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1744,6 +1744,20 @@
|
||||
"when": 1782260194924,
|
||||
"tag": "0249_drop_permission_group_applies_to_all_workspaces",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 250,
|
||||
"version": "7",
|
||||
"when": 1782365854239,
|
||||
"tag": "0250_workspace_forking",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 251,
|
||||
"version": "7",
|
||||
"when": 1782606413439,
|
||||
"tag": "0251_wakeful_smiling_tiger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1325,6 +1325,10 @@ export const workspace = pgTable(
|
||||
inboxAddress: text('inbox_address'),
|
||||
inboxProviderId: text('inbox_provider_id'),
|
||||
archivedAt: timestamp('archived_at'),
|
||||
forkedFromWorkspaceId: text('forked_from_workspace_id').references(
|
||||
(): AnyPgColumn => workspace.id,
|
||||
{ onDelete: 'set null' }
|
||||
),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
@@ -1332,6 +1336,230 @@ export const workspace = pgTable(
|
||||
ownerIdIdx: index('workspace_owner_id_idx').on(table.ownerId),
|
||||
organizationIdIdx: index('workspace_organization_id_idx').on(table.organizationId),
|
||||
workspaceModeIdx: index('workspace_mode_idx').on(table.workspaceMode),
|
||||
forkedFromWorkspaceIdx: index('workspace_forked_from_workspace_id_idx').on(
|
||||
table.forkedFromWorkspaceId
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const workspaceForkResourceTypeEnum = pgEnum('workspace_fork_resource_type', [
|
||||
'workflow',
|
||||
'oauth_credential',
|
||||
'service_account_credential',
|
||||
'env_var',
|
||||
'table',
|
||||
'knowledge_base',
|
||||
'knowledge_document',
|
||||
'file',
|
||||
'mcp_server',
|
||||
'custom_tool',
|
||||
'skill',
|
||||
])
|
||||
|
||||
export const workspaceForkResourceMap = pgTable(
|
||||
'workspace_fork_resource_map',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
childWorkspaceId: text('child_workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
resourceType: workspaceForkResourceTypeEnum('resource_type').notNull(),
|
||||
parentResourceId: text('parent_resource_id').notNull(),
|
||||
childResourceId: text('child_resource_id'),
|
||||
// SET NULL (not CASCADE): deleting the creating user must not delete the fork's
|
||||
// identity mappings, which the edge depends on for every future promote.
|
||||
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
childWorkspaceIdx: index('workspace_fork_resource_map_child_ws_idx').on(table.childWorkspaceId),
|
||||
childWorkspaceTypeIdx: index('workspace_fork_resource_map_child_ws_type_idx').on(
|
||||
table.childWorkspaceId,
|
||||
table.resourceType
|
||||
),
|
||||
childTypeParentUnique: uniqueIndex('workspace_fork_resource_map_child_type_parent_unique').on(
|
||||
table.childWorkspaceId,
|
||||
table.resourceType,
|
||||
table.parentResourceId
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Stable 1:1 block-identity map between a fork (child) and its parent, per edge. Seeded at
|
||||
* fork creation (parent block -> derived child block) and reconciled on every promote.
|
||||
* Promote looks a source block up here to reuse its counterpart's EXISTING id instead of
|
||||
* re-deriving: without it, pushing a fork's workflow over the parent would re-key the
|
||||
* parent's blocks and change their webhook URLs (the path falls back to the block id).
|
||||
*
|
||||
* Each pair records BOTH workflow ids so a lookup can be scoped to the workflow it belongs
|
||||
* to: a target workflow that was archived and re-created gets a fresh id (the pair no longer
|
||||
* matches), which avoids reusing an archived workflow's block id and colliding on the global
|
||||
* `workflow_blocks` primary key. Block ids are plain text (no FK to `workflow_blocks`, which
|
||||
* is rewritten on every deploy); only the edge (`child_workspace_id`) cascades. A parent
|
||||
* block can map to different children across sibling forks, so uniqueness is per (edge,
|
||||
* parent) and per (edge, child).
|
||||
*/
|
||||
export const workspaceForkBlockMap = pgTable(
|
||||
'workspace_fork_block_map',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
childWorkspaceId: text('child_workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
parentWorkflowId: text('parent_workflow_id').notNull(),
|
||||
parentBlockId: text('parent_block_id').notNull(),
|
||||
childWorkflowId: text('child_workflow_id').notNull(),
|
||||
childBlockId: text('child_block_id').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Pull resolves parent source block -> child target; one child per parent block per edge.
|
||||
childWsParentBlockUnique: uniqueIndex('workspace_fork_block_map_child_ws_parent_unique').on(
|
||||
table.childWorkspaceId,
|
||||
table.parentBlockId
|
||||
),
|
||||
// Push resolves child source block -> parent target; one parent per child block per edge.
|
||||
childWsChildBlockUnique: uniqueIndex('workspace_fork_block_map_child_ws_child_unique').on(
|
||||
table.childWorkspaceId,
|
||||
table.childBlockId
|
||||
),
|
||||
// Reconcile deletes a source workflow's pairs by its (stable) workflow id before
|
||||
// re-inserting the live ones, so index both workflow sides for that sweep.
|
||||
childWsParentWorkflowIdx: index('workspace_fork_block_map_child_ws_parent_wf_idx').on(
|
||||
table.childWorkspaceId,
|
||||
table.parentWorkflowId
|
||||
),
|
||||
childWsChildWorkflowIdx: index('workspace_fork_block_map_child_ws_child_wf_idx').on(
|
||||
table.childWorkspaceId,
|
||||
table.childWorkflowId
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* The user's stored dependent-field re-picks for an edge: a (target workflow, target block,
|
||||
* subblock) -> selected value mapping (a Gmail label, a KB document, a sheet tab). The sync
|
||||
* modal reads and writes this, and every promote applies it verbatim - it is the single
|
||||
* source of truth for dependent values, replacing the old implicit "preserve the target's
|
||||
* value if the credential is unchanged" path. Block ids are plain text (no FK to
|
||||
* `workflow_blocks`, which is rewritten on every deploy); only the edge (`child_workspace_id`)
|
||||
* cascades. The target workflow id encodes direction (push -> parent workflow, pull -> child
|
||||
* workflow), so no separate direction column is needed.
|
||||
*/
|
||||
export const workspaceForkDependentValue = pgTable(
|
||||
'workspace_fork_dependent_value',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
childWorkspaceId: text('child_workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
targetWorkflowId: text('target_workflow_id').notNull(),
|
||||
targetBlockId: text('target_block_id').notNull(),
|
||||
subBlockKey: text('sub_block_key').notNull(),
|
||||
value: text('value').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Reconcile replaces a workflow's stored values by its id, so index that sweep.
|
||||
childWsWorkflowIdx: index('workspace_fork_dependent_value_child_ws_wf_idx').on(
|
||||
table.childWorkspaceId,
|
||||
table.targetWorkflowId
|
||||
),
|
||||
// One stored value per (edge, target workflow, target block, subblock).
|
||||
childWsFieldUnique: uniqueIndex('workspace_fork_dependent_value_field_unique').on(
|
||||
table.childWorkspaceId,
|
||||
table.targetWorkflowId,
|
||||
table.targetBlockId,
|
||||
table.subBlockKey
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const workspaceForkPromoteDirectionEnum = pgEnum('workspace_fork_promote_direction', [
|
||||
'push',
|
||||
'pull',
|
||||
])
|
||||
|
||||
export const workspaceForkPromoteRun = pgTable(
|
||||
'workspace_fork_promote_run',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
childWorkspaceId: text('child_workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
sourceWorkspaceId: text('source_workspace_id').notNull(),
|
||||
targetWorkspaceId: text('target_workspace_id').notNull(),
|
||||
direction: workspaceForkPromoteDirectionEnum('direction').notNull(),
|
||||
snapshot: jsonb('snapshot').notNull(),
|
||||
// SET NULL (not CASCADE): deleting the creating user must not delete a pending
|
||||
// undo point for a target workspace.
|
||||
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// One undo point per (edge, target) so a push (target=parent) and a pull
|
||||
// (target=child) on the same edge keep independent undo points.
|
||||
childWorkspaceTargetUnique: uniqueIndex('workspace_fork_promote_run_child_ws_target_unique').on(
|
||||
table.childWorkspaceId,
|
||||
table.targetWorkspaceId
|
||||
),
|
||||
targetWorkspaceIdx: index('workspace_fork_promote_run_target_ws_idx').on(
|
||||
table.targetWorkspaceId
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const backgroundWorkKindEnum = pgEnum('background_work_kind', [
|
||||
'deployment_side_effects',
|
||||
'fork_content_copy',
|
||||
'fork_sync',
|
||||
'fork_rollback',
|
||||
])
|
||||
|
||||
export const backgroundWorkStatusValueEnum = pgEnum('background_work_status_value', [
|
||||
'pending',
|
||||
'processing',
|
||||
'completed',
|
||||
'completed_with_warnings',
|
||||
'failed',
|
||||
])
|
||||
|
||||
/**
|
||||
* Durable status for asynchronous background work (post-sync/rollback deployment
|
||||
* side-effects and fork content copy), so the canvas can show a "work in progress"
|
||||
* banner that survives a reload. A row scoped to a single workflow sets `workflowId`;
|
||||
* workspace-spanning work (fork content copy) leaves it null.
|
||||
*/
|
||||
export const backgroundWorkStatus = pgTable(
|
||||
'background_work_status',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workspaceId: text('workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
|
||||
kind: backgroundWorkKindEnum('kind').notNull(),
|
||||
status: backgroundWorkStatusValueEnum('status').notNull(),
|
||||
message: text('message'),
|
||||
error: text('error'),
|
||||
metadata: jsonb('metadata'),
|
||||
startedAt: timestamp('started_at').notNull().defaultNow(),
|
||||
completedAt: timestamp('completed_at'),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceStatusIdx: index('background_work_status_workspace_status_idx').on(
|
||||
table.workspaceId,
|
||||
table.status
|
||||
),
|
||||
workflowStatusIdx: index('background_work_status_workflow_status_idx').on(
|
||||
table.workflowId,
|
||||
table.status
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -154,6 +154,9 @@ export const auditMock = {
|
||||
WORKSPACE_UPDATED: 'workspace.updated',
|
||||
WORKSPACE_DELETED: 'workspace.deleted',
|
||||
WORKSPACE_DUPLICATED: 'workspace.duplicated',
|
||||
WORKSPACE_FORKED: 'workspace.forked',
|
||||
WORKSPACE_FORK_PROMOTED: 'workspace.fork_promoted',
|
||||
WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back',
|
||||
},
|
||||
AuditResourceType: {
|
||||
API_KEY: 'api_key',
|
||||
|
||||
@@ -1240,6 +1240,16 @@ export const schemaMock = {
|
||||
error: 'error',
|
||||
locators: 'locators',
|
||||
},
|
||||
workspaceForkDependentValue: {
|
||||
id: 'id',
|
||||
childWorkspaceId: 'childWorkspaceId',
|
||||
targetWorkflowId: 'targetWorkflowId',
|
||||
targetBlockId: 'targetBlockId',
|
||||
subBlockKey: 'subBlockKey',
|
||||
value: 'value',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
/** Custom type export for tsvector */
|
||||
tsvector: 'tsvector',
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 864,
|
||||
zodRoutes: 864,
|
||||
totalRoutes: 872,
|
||||
zodRoutes: 872,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user