improvement(forking): make webhook url mapping clear (#6272)

* improvement(forking): make webhook url mapping clear

* fix(forking): honour drops before the unmapped gate, match provider on URL adoption

Review round 1 on #6272.

- Drop was inert for required references: `postCopyUnmappedRequired` gates before
  the cleared-ref gate that honours acknowledgments, so a source-deleted reference
  on a required field still failed with "map all required ... first". Verified drops
  are now resolved once (`verifyForkDropAcknowledgments`) and subtracted from both
  gates. Verification is not optional: an unmapped reference of a non-blocking kind
  (credential, env-var) never re-blocks downstream, so subtracting raw
  acknowledgments would let a crafted payload skip the required gate.
- URL adoption now requires provider equality. A count-only 1:1 pairing could hand a
  GitHub URL to an arriving Slack trigger, keeping the endpoint alive while every
  request failed signature verification - and reporting the URL as preserved.
- `resolveTriggerId` moved from `lib/webhooks/deploy.ts` to `@/triggers/webhook-url`
  so the deploy path and the fork's provider check share one resolution.
- Trigger URL warnings render the full public URL in the heads-up section and name
  the URL in the overwrite confirm, where identical workflow names were ambiguous.
- The Drop control renders once per resource and states how many fields it covers;
  the remapper clears by reference, so a per-row control implied a choice the write
  path cannot honour.
- Export clears `workflow-selector`: nothing on the import path remaps workflow ids
  (`import-export.ts` re-creates each workflow under a fresh id), so a preserved
  reference dangled - bundle or not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vikhyath Mondreti
2026-08-05 01:10:33 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 0fce5358b6
commit 6cc25f6d9b
34 changed files with 2571 additions and 210 deletions
@@ -10,6 +10,7 @@ import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-w
import {
listForkExcludedDeployedWorkflows,
loadSourceDeployedStates,
loadTargetWebhookPathsByBlock,
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
@@ -27,6 +28,10 @@ import {
collectForkClearedRefCandidates,
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
import {
buildForkTriggerPlan,
resolveForkTriggerPaths,
} from '@/ee/workspace-forking/lib/promote/trigger-urls'
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'
@@ -173,6 +178,36 @@ export const GET = withRouteHandler(
})
)
// Trigger URLs this sync decides in the target - the "we had to re-paste the Slack Request
// URL again" case, surfaced as an editable pairing before the overwrite instead of discovered
// after it. The preview reports the plan's DEFAULT resolution; the user's picks ride the
// promote call, where the same plan is rebuilt and validated against them.
const triggerPlan = buildForkTriggerPlan({
items: plan.items,
sourceStates,
resolveBlockId,
targetWebhooks: await loadTargetWebhookPathsByBlock(db, allTargetIds),
})
const { changes: triggerUrlChanges } = resolveForkTriggerPaths(triggerPlan)
// Every trigger that HAS a public URL, plus every one whose URL is up for decision - not just
// the decisions, so the section reads as a standing statement of each URL rather than an alert.
//
// A trigger with neither is deliberately absent: whether a block will serve a URL at all is
// only knowable from its webhook row, and a schedule / chat / manual / poller trigger never
// gets one. Claiming "gets a new URL" for those would be a straight lie, and no declarative
// flag separates them - `polling` is set on 10 of the trigger defs, while `webhook` is set on
// 345 including `slack_oauth`, which routes by `routingKey` with a NULL path.
const triggerMappings = triggerPlan.slots
.filter((slot) => slot.ownPath !== null || slot.adoptablePaths.length > 0)
.map((slot) => ({
sourceBlockId: slot.sourceBlockId,
blockName: slot.blockName,
workflowName: slot.workflowName,
ownPath: slot.ownPath,
adoptablePaths: slot.adoptablePaths,
defaultAdoptPath: slot.defaultAdoptPath,
}))
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
kind: reference.kind,
sourceId: reference.sourceId,
@@ -224,6 +259,8 @@ export const GET = withRouteHandler(
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
copyableUnmapped: plan.copyableUnmapped,
clearedRefs,
triggerUrlChanges,
triggerMappings,
})
}
)
@@ -25,7 +25,14 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(promoteForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body
const {
otherWorkspaceId,
direction,
dependentValues,
copyResources,
dropReferences,
triggerMappings,
} = parsed.data.body
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
@@ -38,6 +45,8 @@ export const POST = withRouteHandler(
actorName: session.user.name ?? undefined,
dependentValues,
copyResources,
dropReferences,
triggerMappings,
requestId,
})
@@ -52,6 +61,8 @@ export const POST = withRouteHandler(
blockers: result.blockers,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
droppedReferences: result.droppedReferences,
triggerUrlChanges: result.triggerUrlChanges,
}
if (result.blocked) {
@@ -91,7 +102,9 @@ export const POST = withRouteHandler(
status:
result.deployFailed > 0 ||
result.needsConfiguration.length > 0 ||
result.clearedOptional.length > 0
result.clearedOptional.length > 0 ||
result.droppedReferences.length > 0 ||
result.triggerUrlChanges.length > 0
? 'completed_with_warnings'
: 'completed',
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
@@ -110,6 +123,8 @@ export const POST = withRouteHandler(
archivedNames: result.archivedNames,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
droppedReferences: result.droppedReferences.length,
triggerUrlChanges: result.triggerUrlChanges.length,
},
}).catch((error) =>
logger.error(`[${requestId}] Failed to record sync activity`, {
@@ -58,14 +58,20 @@ export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): {
return { blockers, informational }
}
/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */
const BLOCKER_KIND_LABEL: Record<string, string> = {
/**
* Human label per remap kind for the resolution copy (singular, lowercase mid-sentence). Shared
* with the Mappings section's source-deleted note so both phrase the same resolution identically.
* `credential` is reachable only from a mapping entry - credentials gate through the required
* check, never through the cleared-ref blockers.
*/
export const FORK_RESOURCE_KIND_LABEL: Record<string, string> = {
table: 'table',
'knowledge-base': 'knowledge base',
file: 'file',
'custom-tool': 'custom tool',
skill: 'skill',
'mcp-server': 'MCP server',
credential: 'credential',
}
/**
@@ -79,7 +85,7 @@ export function forkBlockerResolution(ref: ForkClearedRef): string | null {
case 'unmapped-copyable':
return 'map it to a target or select it for copy'
case 'source-deleted':
return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
return `deleted in the source — map it to an existing ${FORK_RESOURCE_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
case 'workflow-missing':
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
}
@@ -83,38 +83,39 @@ export function forkParentResolution(
}
/**
* Whether every required reference is satisfied - it has a mapping target OR is selected for copy.
* The server accepts a copy as resolving a required ref (promote.ts `willResolve`), so the client
* gate must too. No double-count: a mapped copyable is excluded from the copy candidates, so the two
* branches are mutually exclusive.
* Whether every required reference is satisfied - it has a mapping target, or its key is in
* `satisfiedKeys` (selected for copy, or acknowledged as a dropped source-deleted reference).
* The server accepts both as resolving a required ref, so the client gate must too. No
* double-count: a mapped copyable is excluded from the copy candidates, and a droppable reference
* is source-deleted, so it has no copy candidate either.
*/
export function isForkRequiredComplete(
entries: ForkMappingEntry[],
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
satisfiedKeys: ReadonlySet<string>
): boolean {
return entries.every(
(entry) =>
!entry.required ||
effectiveForkTarget(entry, targets) !== '' ||
copyingKeys.has(forkRefKey(entry))
satisfiedKeys.has(forkRefKey(entry))
)
}
/**
* Whether any reference in a kind is required AND still unmapped AND not selected for copy - drives
* the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}'s satisfied rule.
* Whether any reference in a kind is required AND still unmapped AND not satisfied another way -
* drives the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}.
*/
export function forkRequiredPending(
items: ForkMappingEntry[],
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
satisfiedKeys: ReadonlySet<string>
): boolean {
return items.some(
(entry) =>
entry.required &&
effectiveForkTarget(entry, targets) === '' &&
!copyingKeys.has(forkRefKey(entry))
!satisfiedKeys.has(forkRefKey(entry))
)
}
@@ -4,6 +4,7 @@ import { type Dispatch, Fragment, type SetStateAction, useMemo, useState } from
import {
Badge,
ChevronDown,
Chip,
ChipCombobox,
ChipSwitch,
CollapsibleCard,
@@ -18,6 +19,7 @@ import type {
ForkDependentReconfig,
ForkMappingEntry,
ForkResourceUsage,
ForkTriggerMapping,
} from '@/lib/api/contracts/workspace-fork'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -25,7 +27,10 @@ import {
FileKindRow,
ResourceKindRow,
} from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker'
import { forkBlockerResolution } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
import {
FORK_RESOURCE_KIND_LABEL,
forkBlockerResolution,
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector'
import {
@@ -39,7 +44,9 @@ import type {
ForkSyncController,
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork'
import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers'
import type { SelectorKey } from '@/hooks/selectors/types'
import { buildWebhookTriggerUrl } from '@/triggers/webhook-url'
/**
* Copyable kinds as expandable rows in the "Copy resources" section, ordered + labeled to match
@@ -65,6 +72,12 @@ const COPYABLE_KIND_SECTIONS: ReadonlyArray<{
*/
const NEW_COPY_VALUE = '__new_copy__'
/**
* Sentinel option value for "New URL" - the trigger mints a fresh public URL instead of taking
* over a retiring one. Sent as `adoptPath: null`.
*/
const NEW_TRIGGER_URL_VALUE = '__new_trigger_url__'
/** Fixed target-picker width so every mapping row's control lines up as one column (mirrors General). */
const MAPPING_TARGET_TRIGGER_CLASS = 'w-[240px] flex-shrink-0'
@@ -390,6 +403,13 @@ function MappingEntry({ controller, group, entry }: MappingEntryProps) {
/>
</div>
</div>
{entry.sourceDeleted ? (
<p className='text-[var(--text-muted)] text-small'>
Deleted in the source — its name can't be shown. Map it to an existing{' '}
{FORK_RESOURCE_KIND_LABEL[entry.kind] ?? 'resource'} in the target, or fix the reference
in the source and redeploy.
</p>
) : null}
{entry.candidatesTruncated ? (
<p className='text-[var(--text-muted)] text-small'>
More options than shown — search by name.
@@ -561,6 +581,84 @@ function CopyKindSections({ controller, byKind }: CopyKindSectionsProps) {
)
}
interface TriggerMappingRowProps {
controller: ForkSyncController
mapping: ForkTriggerMapping
}
/**
* One arriving trigger's URL decision: take over a URL that is retiring in the same target
* workflow, or mint a new one.
*
* Keyed and labelled by BLOCK NAME rather than the raw path - it is one block to one webhook URL,
* and the name is what the user recognises. Adopting keeps the external caller (a Slack Request
* URL, a provider subscription) working with no re-registration at all.
*/
function TriggerMappingRow({ controller, mapping }: TriggerMappingRowProps) {
// A trigger that already serves a URL keeps it, so the row states the URL and offers no
// control. Only a trigger the sync would give a NEW URL has something to decide.
const decidable = mapping.ownPath === null && mapping.adoptablePaths.length > 0
const chosen =
mapping.sourceBlockId in controller.triggerAdoptions
? controller.triggerAdoptions[mapping.sourceBlockId]
: (mapping.defaultAdoptPath ?? '')
const resultingPath = mapping.ownPath ?? (chosen === '' ? null : chosen)
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center justify-between gap-4'>
{/* One inner span, so the name and its "in <workflow>" suffix share a normal inline flow:
`Label` is inline-flex, and a flex container DISCARDS whitespace-only children, which
eats the separating space (and leaves `truncate` with no text run to clip). */}
<Label className='min-w-0'>
<span className='min-w-0 truncate'>
{mapping.blockName}{' '}
<span className='text-[var(--text-muted)]'>in {mapping.workflowName}</span>
</span>
</Label>
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
{decidable ? (
<ChipCombobox
className='w-full'
align='start'
options={[
// The full URL lives under the row and follows the selection, so an option only
// has to name the CHOICE. Several retiring URLs is the one case that needs a
// disambiguator, and the path tail is what distinguishes them.
...mapping.adoptablePaths.map((path) => ({
label:
mapping.adoptablePaths.length === 1
? 'Keep existing URL'
: `Keep …${path.slice(-12)}`,
value: path,
})),
{ label: 'Generate new URL', value: NEW_TRIGGER_URL_VALUE },
]}
value={chosen === '' ? NEW_TRIGGER_URL_VALUE : chosen}
onChange={(value) =>
controller.setTriggerAdoption(
mapping.sourceBlockId,
value === NEW_TRIGGER_URL_VALUE ? '' : value
)
}
placeholder='Generate new URL'
/>
) : (
<p className='text-right text-[var(--text-muted)] text-small'>Unchanged</p>
)}
</div>
</div>
<p className='min-w-0 truncate text-[var(--text-muted)] text-small'>
{resultingPath ? (
<span className='font-mono text-caption'>{buildWebhookTriggerUrl(resultingPath)}</span>
) : (
'Gets a new URL on sync — register it with the calling service afterwards.'
)}
</p>
</div>
)
}
interface ForkSyncViewProps {
controller: ForkSyncController
onDirectionChange: (direction: ForkDirection) => void
@@ -574,7 +672,10 @@ interface ForkSyncViewProps {
*/
export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProps) {
const detailsError = controller.errorMessage ?? controller.diffErrorMessage
const headsUp = controller.mcpReauthCount > 0 || controller.inlineSecretCount > 0
const headsUp =
controller.mcpReauthCount > 0 ||
controller.inlineSecretCount > 0 ||
controller.triggerUrlChanges.length > 0
// Excluded workflows render greyed in the change list. Orient each name's tooltip
// to WHERE it is excluded (that's the only place it can be re-included): the sync's
@@ -694,6 +795,20 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
target workspace.
</div>
) : null}
{controller.triggerUrlChanges.map((change) => (
<div
key={`${change.workflowName}:${change.path}`}
className='mt-1 min-w-0 text-[var(--text-muted)] text-small'
>
<span className='text-[var(--text-body)]'>
A webhook URL in {change.workflowName}
</span>{' '}
stops being served — anything calling it will stop working.
<span className='block truncate font-mono text-caption'>
{buildWebhookTriggerUrl(change.path)}
</span>
</div>
))}
</SettingsSection>
) : null}
@@ -722,6 +837,20 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
</SettingsSection>
) : null}
{controller.triggerMappings.length > 0 ? (
<SettingsSection label={`Trigger URLs in ${controller.targetWorkspaceName}`}>
<div className='flex flex-col gap-2'>
{controller.triggerMappings.map((mapping) => (
<TriggerMappingRow
key={mapping.sourceBlockId}
controller={controller}
mapping={mapping}
/>
))}
</div>
</SettingsSection>
) : null}
{controller.hasVisibleCopyables ? (
<SettingsSection label='Copy resources'>
<div className='flex flex-col gap-2'>
@@ -743,18 +872,51 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
) : null}
{controller.blockingRefs.length > 0 ? (
<SettingsSection label='Blocking sync'>
<SettingsSection
label='Blocking sync'
action={
controller.droppableBlockerCount > 1 ? (
<Chip onClick={controller.dropAllDeletedRefs}>Drop all deleted</Chip>
) : undefined
}
>
<div className='flex flex-col gap-1'>
{controller.blockingRefs.map((ref, index) => (
<div
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
className='min-w-0 text-[var(--text-secondary)] text-small'
>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> would lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName} — {forkBlockerResolution(ref)}
</div>
))}
{controller.blockingRefs.map((ref, index) => {
const dropKey = `${ref.kind}:${ref.sourceId}`
const uses = controller.blockingUsesByResource.get(dropKey) ?? 1
return (
<div
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
className='flex min-w-0 items-start justify-between gap-3 text-[var(--text-secondary)] text-small'
>
<span className='min-w-0'>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> would lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName} — {forkBlockerResolution(ref)}
</span>
{/* Only a source-deleted reference can be dropped: an unmapped copyable can still
be copied and a missing workflow can still be deployed, so neither is a dead
end the user should be able to accept away.
One control per RESOURCE, not per row: the resource is gone, so the sync
clears every field naming it (the remapper's clear resolves by reference, not
by field). Rendering a Drop on each row would imply a per-field choice the
write path cannot honour, so later rows for the same id state the scope
instead. */}
{forkSyncBlockerReasonFor(ref) !==
'source-deleted' ? null : controller.firstBlockingRowForResource.get(dropKey) ===
index ? (
<Chip onClick={() => controller.toggleDroppedRef(ref.kind, ref.sourceId, true)}>
{uses > 1 ? `Drop from ${uses} fields` : 'Drop'}
</Chip>
) : (
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
same reference
</span>
)}
</div>
)
})}
</div>
</SettingsSection>
) : null}
@@ -762,16 +924,30 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{controller.dependentClears.length > 0 ? (
<SettingsSection label='Will be cleared'>
<div className='flex flex-col gap-1'>
{controller.dependentClears.map((ref, index) => (
<div
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
className='min-w-0 text-[var(--text-secondary)] text-small'
>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> will lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName}
</div>
))}
{controller.dependentClears.map((ref, index) => {
const droppedKey = `${ref.kind}:${ref.sourceId}`
const dropped = controller.droppedRefs.has(droppedKey)
return (
<div
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
className='flex min-w-0 items-start justify-between gap-3 text-[var(--text-secondary)] text-small'
>
<span className='min-w-0'>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> will lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName}
{dropped ? ' — dropped' : ''}
</span>
{dropped ? (
<Chip
onClick={() => controller.toggleDroppedRef(ref.kind, ref.sourceId, false)}
>
Undo
</Chip>
) : null}
</div>
)
})}
</div>
<p className='text-[var(--text-muted)] text-caption'>
Re-pick these in the target after the sync.
@@ -9,6 +9,8 @@ import type {
ForkDependentReconfig,
ForkMappingEntry,
ForkResourceUsage,
ForkTriggerMapping,
ForkTriggerUrlChange,
ForkWorkflowChange,
} from '@/lib/api/contracts/workspace-fork'
import {
@@ -40,6 +42,7 @@ import {
usePromoteFork,
useUpdateForkMapping,
} from '@/ee/workspace-forking/hooks/workspace-fork'
import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers'
/**
* The mapping kinds that can be a standalone mapping entry. `knowledge-document` is excluded:
@@ -92,6 +95,12 @@ export interface ForkKindSummary {
export interface ForkSyncController {
direction: ForkDirection
otherWorkspaceName: string
/**
* The workspace this sync WRITES, named for user-facing copy: the other workspace on push,
* "this workspace" on pull. Derived once here so every surface that names the target - the
* overwrite confirm, the Trigger URLs heading - says the same thing.
*/
targetWorkspaceName: string
isLoading: boolean
isError: boolean
errorMessage: string | null
@@ -140,6 +149,25 @@ export interface ForkSyncController {
/** The raw copy selection (visible-ness not applied), for per-kind selected-id derivation. */
copySelected: ReadonlySet<string>
toggleCopyKeys: (keys: string[], checked: boolean) => void
/**
* Source-deleted references the user accepted losing in the target, keyed `${kind}:${sourceId}`.
* In-session only - an acknowledgment is a decision about this sync, never a stored mapping.
*/
droppedRefs: ReadonlySet<string>
/** Toggle one acknowledgment; the row leaves "Blocking sync" for "Will be cleared". */
toggleDroppedRef: (kind: string, sourceId: string, dropped: boolean) => void
/** Accept losing every source-deleted blocker at once - the volume is the point. */
dropAllDeletedRefs: () => void
/** Source-deleted blockers still awaiting a decision, for the bulk affordance. */
droppableBlockerCount: number
/**
* How many blocking rows name each resource, keyed `${kind}:${sourceId}`. A drop is inherently
* resource-scoped - the remapper clears by reference, not by field - so the row that offers the
* control states how many fields it covers rather than implying a per-field choice.
*/
blockingUsesByResource: ReadonlyMap<string, number>
/** Index of the row that owns each resource's Drop control, so it renders exactly once. */
firstBlockingRowForResource: ReadonlyMap<string, number>
/** Visible copy candidates split by referenced-ness, grouped per kind for the section rows. */
referencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
unreferencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
@@ -152,6 +180,16 @@ export interface ForkSyncController {
workflowChanges: ForkWorkflowChange[]
/** Names of target workflows this sync archives, for the confirm modal. */
archivedWorkflowNames: string[]
/** Public trigger URLs this sync would stop serving in the target (warn before overwriting). */
triggerUrlChanges: ForkTriggerUrlChange[]
/** Arriving triggers whose URL is a choice: keep a retiring one, or mint a new one. */
triggerMappings: ForkTriggerMapping[]
/**
* The chosen adoption per source trigger block. A key present with a path adopts it; present
* with `''` mints a new URL; absent takes the server's `defaultAdoptPath`.
*/
triggerAdoptions: Readonly<Record<string, string>>
setTriggerAdoption: (sourceBlockId: string, path: string) => void
/** Names of deployed SOURCE workflows marked "Exclude from sync" - never sent. */
excludedSourceWorkflows: string[]
/** Names of mapped TARGET workflows marked "Exclude from sync" - never replaced or archived. */
@@ -239,6 +277,16 @@ export function useForkSync(params: {
// sync so their references resolve to the copy instead of being cleared.
const [copySelected, setCopySelected] = useState<Set<string>>(new Set())
const [copyDefaulted, setCopyDefaulted] = useState(false)
// Source-deleted references the user explicitly accepted losing in the target (keyed by
// `${kind}:${sourceId}`). In-session only, like `copySelected` - an acknowledgment is a decision
// about THIS sync, never a stored mapping. The server re-checks that each source really is gone
// before honouring one.
const [droppedRefs, setDroppedRefs] = useState<Set<string>>(new Set())
// Which retiring public URL each arriving trigger takes over, keyed by SOURCE block id. Session
// state like the two above: the choice is about THIS sync, and once it lands the adopted path is
// stored in the target block's `triggerPath`, so later syncs preserve it with no input at all.
// `''` is the explicit "mint a new URL" choice, distinct from an absent key (take the default).
const [triggerAdoptions, setTriggerAdoptions] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Drop every in-session choice when the direction (or edge) changes - the mapping set,
@@ -248,6 +296,8 @@ export function useForkSync(params: {
setReconfig({})
setCopySelected(new Set())
setCopyDefaulted(false)
setDroppedRefs(new Set())
setTriggerAdoptions({})
}, [direction, otherWorkspaceId])
const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled })
@@ -266,6 +316,10 @@ export function useForkSync(params: {
[diff.data?.copyableUnmapped]
)
const clearedRefs = useMemo(() => diff.data?.clearedRefs ?? [], [diff.data?.clearedRefs])
const triggerMappings = useMemo(
() => diff.data?.triggerMappings ?? [],
[diff.data?.triggerMappings]
)
// Keys the backend offers as copy candidates, so the entry rows show a "Copy instead"
// affordance only for those - clearing a name-match suggestion returns the ref to the copy
@@ -298,6 +352,16 @@ export function useForkSync(params: {
[visibleCopyables, copySelected]
)
/**
* Keys that no longer need a mapping target: selected for copy, or an acknowledged drop. Kept
* separate from `copyingKeys` so a dropped reference is never counted as "copied" in the
* per-kind badge.
*/
const satisfiedKeys = useMemo(() => {
if (droppedRefs.size === 0) return copyingKeys
return new Set([...copyingKeys, ...droppedRefs])
}, [copyingKeys, droppedRefs])
// Group the visible copy candidates by kind so each renders as its own expandable section
// (chevron + tri-state select-all + count), matching the fork picker. Referenced and
// unreferenced candidates group separately: unreferenced ones (used by no synced workflow)
@@ -448,7 +512,7 @@ export function useForkSync(params: {
// A required reference is satisfied when it has a mapping target OR the user selected it for
// copy (the server accepts a copy as resolving a required ref). See `isForkRequiredComplete`.
const requiredComplete = isForkRequiredComplete(entries, targets, copyingKeys)
const requiredComplete = isForkRequiredComplete(entries, targets, satisfiedKeys)
// Every required dependent whose parent is RESOLVED must have a value before sync. Under a
// mapped parent the user re-picks against the target; under a copy-resolved parent the field
@@ -494,8 +558,20 @@ export function useForkSync(params: {
const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false
return mapped || copyingKeys.has(key)
}
return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved))
}, [clearedRefs, entriesByParent, targets, copyingKeys])
const { blockers, informational } = splitForkClearedRefs(
selectVisibleClearedRefs(clearedRefs, isResolved)
)
if (droppedRefs.size === 0) return { blockers, informational }
// An acknowledged drop stops blocking and moves into the informational "Will be cleared"
// list, mirroring the server: it filters the same entries out of its own gate, but only
// after re-checking that each source really is gone.
const dropped = blockers.filter((ref) => droppedRefs.has(`${ref.kind}:${ref.sourceId}`))
if (dropped.length === 0) return { blockers, informational }
return {
blockers: blockers.filter((ref) => !droppedRefs.has(`${ref.kind}:${ref.sourceId}`)),
informational: [...informational, ...dropped],
}
}, [clearedRefs, entriesByParent, targets, copyingKeys, droppedRefs])
// Per-kind status for the Mappings summary: "Fully mapped" or "n/total mapped", flagged when
// a REQUIRED target is still missing (which blocks Sync). Reads the effective
@@ -510,7 +586,7 @@ export function useForkSync(params: {
const copied = group.items.filter((entry) => copyingKeys.has(entryKey(entry))).length
// Mirror the Sync gate: a required ref selected for copy is satisfied, so it is not
// "pending".
const requiredPending = forkRequiredPending(group.items, targets, copyingKeys)
const requiredPending = forkRequiredPending(group.items, targets, satisfiedKeys)
const reconfigPending = reconfigPendingByKind.has(group.kind)
return { kind: group.kind, total, mapped, copied, requiredPending, reconfigPending }
})
@@ -665,9 +741,51 @@ export function useForkSync(params: {
)
}
const toggleDroppedRef = (kind: string, sourceId: string, dropped: boolean) => {
const key = `${kind}:${sourceId}`
setDroppedRefs((prev) => {
const next = new Set(prev)
if (dropped) next.add(key)
else next.delete(key)
return next
})
}
// Only `source-deleted` blockers are droppable: an unmapped-copyable can be copied and a
// missing workflow can be deployed, so neither is a dead end the user should be able to accept.
const droppableBlockerKeys = useMemo(
() =>
blockingRefs
.filter((ref) => forkSyncBlockerReasonFor(ref) === 'source-deleted')
.map((ref) => `${ref.kind}:${ref.sourceId}`),
[blockingRefs]
)
const dropAllDeletedRefs = () => {
setDroppedRefs((prev) => new Set([...prev, ...droppableBlockerKeys]))
}
// Blocking rows indexed by the resource they name, so the Drop control renders once per resource
// and can state how many fields it covers - matching what the sync actually does.
const { blockingUsesByResource, firstBlockingRowForResource } = useMemo(() => {
const uses = new Map<string, number>()
const firstRow = new Map<string, number>()
blockingRefs.forEach((ref, index) => {
const key = `${ref.kind}:${ref.sourceId}`
uses.set(key, (uses.get(key) ?? 0) + 1)
if (!firstRow.has(key)) firstRow.set(key, index)
})
return { blockingUsesByResource: uses, firstBlockingRowForResource: firstRow }
}, [blockingRefs])
const setTriggerAdoption = (sourceBlockId: string, path: string) => {
setTriggerAdoptions((prev) => ({ ...prev, [sourceBlockId]: path }))
}
const discard = () => {
setTargets({})
setReconfig({})
setTriggerAdoptions({})
}
const sync = async () => {
@@ -685,6 +803,27 @@ export function useForkSync(params: {
const selectedCopyables = visibleCopyables.filter((candidate) =>
copySelected.has(forkRefKey(candidate))
)
// Acknowledged drops, captured at confirm time like every other payload. The server honours
// one only after re-checking that the source resource is genuinely gone.
const dropReferences = Array.from(droppedRefs).map((key) => {
const separator = key.indexOf(':')
return {
kind: key.slice(0, separator) as ForkMappingEntry['kind'],
sourceId: key.slice(separator + 1),
}
})
// Only the choices that DIFFER from the server's default need sending - an untouched row is
// already what the server would pick, so an empty list means "the preview, as shown".
const triggerMappingOverrides = triggerMappings
.filter(
(mapping) =>
mapping.sourceBlockId in triggerAdoptions &&
(triggerAdoptions[mapping.sourceBlockId] || null) !== mapping.defaultAdoptPath
)
.map((mapping) => ({
sourceBlockId: mapping.sourceBlockId,
adoptPath: triggerAdoptions[mapping.sourceBlockId] || null,
}))
try {
await updateMapping.mutateAsync({
workspaceId,
@@ -716,6 +855,10 @@ export function useForkSync(params: {
// existing store is left untouched.
...(dependentValues !== null ? { dependentValues } : {}),
...(selectedCopyables.length > 0 ? { copyResources } : {}),
...(dropReferences.length > 0 ? { dropReferences } : {}),
...(triggerMappingOverrides.length > 0
? { triggerMappings: triggerMappingOverrides }
: {}),
},
})
@@ -751,11 +894,26 @@ export function useForkSync(params: {
// Activity entry (needsConfiguration/clearedOptional are recorded there) and a
// needs-config workflow visibly stays undeployed. Deploy FAILURES remain a real,
// actionable outcome, so they keep a warning.
const dropped = result.droppedReferences.length
// Naming the dropped count is the point of making the drop explicit: the fields really are
// blank in the target now, and the server reports only the acknowledgments it honoured.
const droppedSuffix =
dropped > 0 ? ` ${dropped} deleted reference${dropped === 1 ? '' : 's'} dropped.` : ''
// A dead webhook URL fails silently and externally - nothing in the app breaks - so the one
// moment the user can act on it is right after the sync that killed it.
const deadUrls = result.triggerUrlChanges.length
const urlSuffix =
deadUrls > 0
? ` ${deadUrls} webhook URL${deadUrls === 1 ? '' : 's'} stopped being served — re-register ${deadUrls === 1 ? 'it' : 'them'}.`
: ''
const suffix = `${droppedSuffix}${urlSuffix}`
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'}.`
`${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.${suffix}`
)
} else if (suffix !== '') {
toast.warning(`${label}.${suffix}`)
} else {
toast.success(label)
}
@@ -769,6 +927,7 @@ export function useForkSync(params: {
return {
direction,
otherWorkspaceName,
targetWorkspaceName: direction === 'push' ? otherWorkspaceName : 'this workspace',
isLoading: enabled && mapping.isLoading,
isError: mapping.isError,
errorMessage: mapping.isError ? getErrorMessage(mapping.error, 'Failed to load mapping') : null,
@@ -793,6 +952,12 @@ export function useForkSync(params: {
copyingKeys,
copySelected,
toggleCopyKeys,
droppedRefs,
toggleDroppedRef,
dropAllDeletedRefs,
droppableBlockerCount: droppableBlockerKeys.length,
blockingUsesByResource,
firstBlockingRowForResource,
referencedByKind,
unreferencedByKind,
hasVisibleCopyables: visibleCopyables.length > 0,
@@ -800,6 +965,10 @@ export function useForkSync(params: {
dependentClears,
workflowChanges,
archivedWorkflowNames,
triggerUrlChanges: diff.data?.triggerUrlChanges ?? [],
triggerMappings,
triggerAdoptions,
setTriggerAdoption,
excludedSourceWorkflows: diff.data?.excludedSourceWorkflows ?? [],
excludedTargetWorkflows: diff.data?.excludedTargetWorkflows ?? [],
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,
@@ -46,6 +46,7 @@ import {
} from '@/ee/workspace-forking/hooks/workspace-fork'
import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { buildWebhookTriggerUrl } from '@/triggers/webhook-url'
/** Explains a disabled lineage action whose target workspace the viewer cannot open. */
const NO_ACCESS_TOOLTIP = "You don't have access to this workspace"
@@ -152,7 +153,7 @@ function ForkSyncDetailView({
},
]
const targetWorkspaceName = direction === 'push' ? otherWorkspaceName : 'this workspace'
const targetWorkspaceName = controller.targetWorkspaceName
return (
<>
@@ -224,6 +225,36 @@ function ForkSyncDetailView({
) : null}
</div>
) : null}
{/* A dead trigger URL is only discoverable after the fact, when the external caller goes
quiet - so it belongs in the confirm, next to the other irreversible consequences. */}
{controller.triggerUrlChanges.length > 0 ? (
<div className='flex flex-col gap-1 px-2'>
<p className='break-words text-[var(--text-primary)] text-sm'>
{controller.triggerUrlChanges.length === 1 ? 'A webhook URL' : 'Webhook URLs'} in{' '}
<span className='font-medium'>{targetWorkspaceName}</span> will stop being served —
anything calling {controller.triggerUrlChanges.length === 1 ? 'it' : 'them'} breaks
until you re-register:
</p>
{controller.triggerUrlChanges.slice(0, ARCHIVED_PREVIEW_LIMIT).map((change) => (
// Naming the URL, not just its workflow: several URLs in one workflow would render
// as identical lines, and this confirm is the last point before they stop serving.
<div
key={`${change.workflowName}:${change.path}`}
className='min-w-0 text-[var(--text-muted)] text-small'
>
{change.workflowName}
<span className='block truncate font-mono text-caption'>
{buildWebhookTriggerUrl(change.path)}
</span>
</div>
))}
{controller.triggerUrlChanges.length > ARCHIVED_PREVIEW_LIMIT ? (
<div className='text-[var(--text-muted)] text-small'>
and {controller.triggerUrlChanges.length - ARCHIVED_PREVIEW_LIMIT} more
</div>
) : null}
</div>
) : null}
</ChipConfirmModal>
</>
)
@@ -354,3 +354,81 @@ describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () =>
}
)
})
describe('copyWorkflowStateIntoTarget webhook path pinning', () => {
const sourceState = {
blocks: {
'blk-src': {
id: 'blk-src',
type: 'slack',
name: 'Slack',
// The SOURCE's own path, written back into its draft after its deploy. Copying it would
// point the target at the source's URL, so the sanitizer strips it.
subBlocks: { triggerPath: { id: 'triggerPath', type: 'short-input', value: 'src-path' } },
outputs: {},
enabled: true,
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
} as never
const baseParams = {
targetWorkflowId: 'wf-tgt',
targetWorkspaceId: 'ws-target',
userId: 'target-user',
mode: 'replace' as const,
now: new Date('2026-07-01'),
sourceState,
sourceMeta: { name: 'Prod', description: null, folderId: null, sortOrder: 0 },
workflowIdMap: new Map(),
folderIdMap: new Map(),
nameRegistry: buildWorkflowNameRegistry([]),
resolveBlockId: (_targetWorkflowId: string, sourceBlockId: string) => `tgt-${sourceBlockId}`,
}
/** `replace` mode updates the existing target workflow row; stub just that chain. */
const stubTx = () =>
({
update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
}) as unknown as DbOrTx
function writtenSubBlocks() {
const state = mockSaveWorkflowToNormalizedTables.mock.calls.at(-1)?.[1] as {
blocks: Record<string, { subBlocks?: Record<string, { value?: unknown }> }>
}
return state.blocks['tgt-blk-src'].subBlocks ?? {}
}
it("pins the TARGET's live webhook path so a sync never moves a URL already in the wild", async () => {
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
await copyWorkflowStateIntoTarget({
...baseParams,
tx: stubTx(),
triggerPathByBlockId: new Map([['tgt-blk-src', 'parent-live-path']]),
})
expect(writtenSubBlocks().triggerPath?.value).toBe('parent-live-path')
})
/**
* The adoption case: the arriving trigger has a different target block id (re-created in the
* source), and the resolver handed it the URL retiring in the same target workflow.
*/
it('writes an ADOPTED path onto a trigger block that serves no webhook of its own', async () => {
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
await copyWorkflowStateIntoTarget({
...baseParams,
tx: stubTx(),
triggerPathByBlockId: new Map([['tgt-blk-src', 'retiring-slack-path']]),
})
expect(writtenSubBlocks().triggerPath?.value).toBe('retiring-slack-path')
})
it('leaves the path unset when the target block serves no webhook yet (derives as before)', async () => {
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
await copyWorkflowStateIntoTarget({ ...baseParams, tx: stubTx() })
expect(writtenSubBlocks().triggerPath).toBeUndefined()
})
})
@@ -362,6 +362,13 @@ export interface CopyWorkflowStateParams {
* creation, where every id is derived fresh.
*/
resolveBlockId?: ForkBlockIdResolver
/**
* The resolved public webhook path per TARGET block id - the block's own live path, or a
* retiring one it adopts (see `resolveForkTriggerPaths`). Pinned into the target block's
* `triggerPath` so the URL stops being a derivation of the block id this sync assigns.
* Omitted on fork creation, where the child has no webhooks yet.
*/
triggerPathByBlockId?: ReadonlyMap<string, string>
requestId?: string
}
@@ -394,6 +401,7 @@ export async function copyWorkflowStateIntoTarget(
dependentOverrides,
nameRegistry,
resolveBlockId,
triggerPathByBlockId,
requestId = 'unknown',
} = params
@@ -438,6 +446,19 @@ export async function copyWorkflowStateIntoTarget(
const sourceSubBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord
const sanitizedSource = sanitizeSubBlocksForDuplicate(sourceSubBlocks)
let subBlocks: SubBlockRecord = sanitizedSource
// The sanitizer strips `triggerPath` (the SOURCE's URL must never be copied). Pin the
// TARGET's resolved path back in - the one this block already serves, or a retiring one it
// adopts - so the URL stops being a derivation of the block id this sync assigns. Otherwise
// any later change to that id silently re-points a URL external systems already call (a
// Slack Request URL, a provider subscription). With no resolved path the field stays empty
// and derives as before, so a first-time sync is unchanged.
const resolvedTriggerPath = triggerPathByBlockId?.get(newBlockId)
if (resolvedTriggerPath) {
subBlocks = {
...subBlocks,
triggerPath: { id: 'triggerPath', type: 'short-input', value: resolvedTriggerPath },
}
}
// Tracks the block's live `canonicalModes` through this pass, so a `tool-input` reindex
// (a dropped custom-tool/MCP entry shifts later tools' array positions) is visible to every
// later step below that resolves a nested tool's basic/advanced mode - not just the final
@@ -1,11 +1,12 @@
import { db, runOutsideTransactionContext } from '@sim/db'
import { workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, exists, inArray, isNull, sql } from 'drizzle-orm'
import { and, eq, exists, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
import { isInternalTriggerProvider, isPollingWebhookProvider } from '@/triggers/constants'
const logger = createLogger('WorkspaceForkDeployBridge')
@@ -227,3 +228,83 @@ export async function readDeployedState(
}
})
}
/** A live, path-based webhook on a target block: the URL it serves and the workflow it belongs to. */
export interface ForkTargetWebhook {
path: string
workflowId: string
/**
* The provider the path is served under. An inbound request is authenticated and parsed as this
* provider, so a URL is only meaningfully transferable to a trigger of the SAME provider.
*/
provider: string | null
}
/**
* The public webhook path each target trigger block currently serves on, keyed by block id.
*
* A webhook's path defaults to its block id (`triggerPath || block.id`, see
* `lib/webhooks/deploy.ts`), so the target's URL has always been a *derivation* of an id the
* sync itself assigns. Reading the live path lets the copy pin it back into the target block's
* own `triggerPath`, turning the URL into stored data - the sync then cannot move a URL that
* external systems (a Slack Request URL, a provider subscription) are already pointing at.
*
* Only rows serving a PUBLIC URL are returned. Three families are excluded, because preserving
* their path would be meaningless and offering it for adoption actively wrong:
* - shared-app providers (the native Slack trigger) route by `routingKey` with a NULL path;
* - polling providers ({@link isPollingWebhookProvider}) keep a webhook row as state, but Sim
* pulls from the provider - nothing external calls the path;
* - internal providers ({@link isInternalTriggerProvider}) register a path that the public
* trigger route deliberately rejects, so it is not an endpoint either.
*
* Scoped to each workflow's ACTIVE deployment version, exactly as inbound delivery resolves a
* path (`lib/webhooks/processor.ts`). A workflow keeps non-archived webhook rows from previous
* versions too (`lib/webhooks/deploy.ts` reads "ALL webhooks for this workflow (all versions)"
* before narrowing to the current one), so an unscoped read would return several rows per block
* and pick a stale path arbitrarily - pinning a URL nothing is actually serving, which is the
* precise failure this function exists to prevent.
*/
export async function loadTargetWebhookPathsByBlock(
executor: DbOrTx,
targetWorkflowIds: string[]
): Promise<Map<string, ForkTargetWebhook>> {
if (targetWorkflowIds.length === 0) return new Map()
const rows = await executor
.select({
blockId: webhook.blockId,
path: webhook.path,
workflowId: webhook.workflowId,
provider: webhook.provider,
})
.from(webhook)
.innerJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, webhook.workflowId),
eq(workflowDeploymentVersion.isActive, true),
eq(workflowDeploymentVersion.id, webhook.deploymentVersionId)
)
)
.where(
and(
inArray(webhook.workflowId, targetWorkflowIds),
isNull(webhook.archivedAt),
isNotNull(webhook.blockId),
isNotNull(webhook.path)
)
)
const byBlock = new Map<string, ForkTargetWebhook>()
for (const row of rows) {
if (!row.blockId || !row.path) continue
if (isPollingWebhookProvider(row.provider ?? '') || isInternalTriggerProvider(row.provider)) {
continue
}
// One live path-based row per block within a version - `path_deployment_unique` enforces it.
byBlock.set(row.blockId, {
path: row.path,
workflowId: row.workflowId,
provider: row.provider,
})
}
return byBlock
}
@@ -4,24 +4,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references'
const { mockFilterExisting, mockGetCredentialProviders, mockGetEnvKeys } = vi.hoisted(() => ({
const {
mockFilterExisting,
mockGetCredentialProviders,
mockGetEnvKeys,
mockLoadLabels,
mockListCandidates,
mockClassifyCredential,
mockListDeployedWorkflows,
mockReadDeployedState,
mockScanWorkflowReferences,
mockDetectCascade,
} = vi.hoisted(() => ({
mockFilterExisting: vi.fn(),
mockGetCredentialProviders: vi.fn(),
mockGetEnvKeys: vi.fn(),
mockLoadLabels: vi.fn(),
mockListCandidates: vi.fn(),
mockClassifyCredential: vi.fn(),
mockListDeployedWorkflows: vi.fn(),
mockReadDeployedState: vi.fn(),
mockScanWorkflowReferences: vi.fn(),
mockDetectCascade: vi.fn(),
}))
vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({
listForkResourceCandidates: vi.fn(),
classifyCredentialResourceType: vi.fn(),
listForkResourceCandidates: mockListCandidates,
classifyCredentialResourceType: mockClassifyCredential,
getWorkspaceEnvKeys: mockGetEnvKeys,
filterExistingForkTargets: mockFilterExisting,
getCredentialProvidersByIds: mockGetCredentialProviders,
loadForkResourceLabels: mockLoadLabels,
CANDIDATE_LIMIT: 1000,
}))
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
listDeployedWorkflows: mockListDeployedWorkflows,
readDeployedState: mockReadDeployedState,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/cascade', () => ({
detectForkCascadeReferences: mockDetectCascade,
}))
vi.mock('@/ee/workspace-forking/lib/remap/remap-references', () => ({
scanWorkflowReferences: mockScanWorkflowReferences,
}))
vi.mock('@/ee/workspace-forking/lib/remap/reference-scan', () => ({
toScannerBlocks: vi.fn((state: unknown) => state),
}))
import { workflow, workspaceForkResourceMap } from '@sim/db/schema'
import { queueTableRows, resetDbChainMock } from '@sim/testing'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import {
findDuplicateTargetEntry,
getForkMappingView,
suggestTarget,
validateForkMappingTargets,
} from '@/ee/workspace-forking/lib/mapping/mapping-service'
@@ -149,16 +188,32 @@ describe('validateForkMappingTargets', () => {
).resolves.toBeUndefined()
})
it('rejects a credential whose source is not a credential in the source workspace', async () => {
/**
* A source credential that no longer exists is exactly what the mapping editor asks the user
* to resolve (`sourceDeleted`), so the save must accept it - rejecting made it the one kind of
* reference the UI told you to map and the server refused. Access propagation is not driven
* from here: `propagateCredentialAccess` re-validates both sides inside the promote tx.
*/
it('accepts a credential whose source no longer exists 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<string, string | null>() // cred-deleted is gone from the source
: new Map([['cred-tgt', 'google-email']])
)
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'oauth_credential', sourceId: 'cred-foreign', targetId: 'cred-tgt' },
{ resourceType: 'oauth_credential', sourceId: 'cred-deleted', targetId: 'cred-tgt' },
])
).resolves.toBeUndefined()
})
it('still rejects a target that does not exist, even when the source is gone', async () => {
mockFilterExisting.mockResolvedValue({ credential: new Set<string>() })
mockGetCredentialProviders.mockImplementation(async () => new Map<string, string | null>())
await expect(
validateForkMappingTargets('ws-source', 'ws-target', [
{ resourceType: 'oauth_credential', sourceId: 'cred-deleted', targetId: 'cred-foreign' },
])
).rejects.toBeInstanceOf(ForkError)
})
@@ -246,3 +301,109 @@ describe('suggestTarget', () => {
expect(suggestTarget('table', ' Orders ', undefined, [cand('t1', 'orders')])).toBe('t1')
})
})
describe('getForkMappingView', () => {
const edge = { parentWorkspaceId: 'ws-parent', childWorkspaceId: 'ws-child' } as never
const emptyCandidates = {
credential: [],
'env-var': [],
table: [],
'knowledge-base': [],
'mcp-server': [],
'custom-tool': [],
skill: [],
'knowledge-document': [],
file: [],
}
/** Pull: parent is the source, child the target — the direction the raw-id rows showed up in. */
function pullView(overrides: { workflowRows?: unknown[] } = {}) {
// The real `getEdgeMappingRows` runs; this row is the workflow identity pair it returns.
queueTableRows(workspaceForkResourceMap, [
{
id: 'map-1',
childWorkspaceId: 'ws-child',
resourceType: 'workflow',
parentResourceId: 'wf-parent',
childResourceId: 'wf-child',
},
])
queueTableRows(
workflow,
overrides.workflowRows ?? [{ id: 'wf-child', forkSyncExcluded: false }]
)
return getForkMappingView({
edge,
sourceWorkspaceId: 'ws-parent',
targetWorkspaceId: 'ws-child',
})
}
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockGetEnvKeys.mockResolvedValue(new Set<string>())
mockListCandidates.mockResolvedValue(emptyCandidates)
mockListDeployedWorkflows.mockResolvedValue([{ id: 'wf-parent', name: 'Prod' }])
mockReadDeployedState.mockResolvedValue({ blocks: {} })
mockScanWorkflowReferences.mockReturnValue({
references: [
{ kind: 'table', sourceId: 'tbl_live', subBlockKey: 'tableSelector', required: false },
{ kind: 'table', sourceId: 'tbl_gone', subBlockKey: 'tableSelector', required: false },
],
})
mockDetectCascade.mockResolvedValue({ references: [] })
mockFilterExisting.mockResolvedValue({})
mockGetCredentialProviders.mockResolvedValue(new Map())
mockClassifyCredential.mockResolvedValue('oauth_credential')
mockLoadLabels.mockResolvedValue({ table: new Map([['tbl_live', 'Orders']]) })
})
it('labels a live source resource by name and flags a deleted one', async () => {
const { entries } = await pullView()
expect(entries).toEqual([
expect.objectContaining({
sourceId: 'tbl_live',
sourceLabel: 'Orders',
sourceDeleted: false,
}),
expect.objectContaining({
sourceId: 'tbl_gone',
sourceLabel: 'tbl_gone',
sourceDeleted: true,
}),
])
})
/**
* The label lookup must be by exact id, never the display-capped candidate list — otherwise a
* workspace past CANDIDATE_LIMIT renders live resources as raw ids, indistinguishable from
* deleted ones. Pinned by asserting the exact ids are what gets looked up.
*/
it('looks source labels up by exact id, not through the capped candidate list', async () => {
await pullView()
expect(mockLoadLabels).toHaveBeenCalledWith(expect.anything(), 'ws-parent', {
table: new Set(['tbl_live', 'tbl_gone']),
})
expect(mockListCandidates).toHaveBeenCalledTimes(1)
expect(mockListCandidates).toHaveBeenCalledWith(expect.anything(), 'ws-child')
})
it('skips a source workflow whose target is excluded from sync', async () => {
const { entries } = await pullView({
workflowRows: [{ id: 'wf-child', forkSyncExcluded: true }],
})
expect(entries).toEqual([])
expect(mockReadDeployedState).not.toHaveBeenCalled()
})
it('still scans when the excluded flag is on an unrelated target workflow', async () => {
const { entries } = await pullView({
workflowRows: [
{ id: 'wf-child', forkSyncExcluded: false },
{ id: 'wf-other', forkSyncExcluded: true },
],
})
expect(entries).toHaveLength(2)
})
})
@@ -1,4 +1,6 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
import type { ForkMappableResourceType, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
@@ -25,7 +27,9 @@ import {
getCredentialProvidersByIds,
getWorkspaceEnvKeys,
listForkResourceCandidates,
loadForkResourceLabels,
} from '@/ee/workspace-forking/lib/mapping/resources'
import { resolveForkExcludedTargetId } from '@/ee/workspace-forking/lib/promote/promote-plan'
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
import {
type ForkReference,
@@ -66,13 +70,16 @@ export async function getForkMappingView(
const { edge, sourceWorkspaceId, targetWorkspaceId } = params
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
const [mappingRows, targetEnvKeys, sourceEnvKeys, sourceCandidates, targetCandidates] =
const [mappingRows, targetEnvKeys, sourceEnvKeys, targetCandidates, targetWorkflows] =
await Promise.all([
getEdgeMappingRows(db, edge.childWorkspaceId),
getWorkspaceEnvKeys(db, targetWorkspaceId),
getWorkspaceEnvKeys(db, sourceWorkspaceId),
listForkResourceCandidates(db, sourceWorkspaceId),
listForkResourceCandidates(db, targetWorkspaceId),
db
.select({ id: workflow.id, forkSyncExcluded: workflow.forkSyncExcluded })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), isNull(workflow.archivedAt))),
])
const resolver = buildForkResolver(mappingRows, { sourceIsParent, targetEnvKeys, sourceEnvKeys })
@@ -93,11 +100,30 @@ export async function getForkMappingView(
if (key) resourceTypeBySourceId.set(key, row.resourceType)
}
// The workflow identity map + the target's live/excluded sets, so this view scans exactly the
// workflows a sync would write. Without the exclusion filter a source whose target is marked
// "Exclude from sync" still contributed blocking mapping entries the sync could never act on.
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 targetActiveIds = new Set(targetWorkflows.map((w) => w.id))
const excludedTargetIds = new Set(
targetWorkflows.filter((w) => w.forkSyncExcluded).map((w) => w.id)
)
// 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) {
if (
resolveForkExcludedTargetId(wf.id, identityMap, targetActiveIds, excludedTargetIds) !== null
) {
continue
}
const state = await readDeployedState(wf.id, sourceWorkspaceId)
if (!state) continue
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
@@ -116,6 +142,25 @@ export async function getForkMappingView(
}
const references: ForkReference[] = Array.from(referenceByKey.values())
// Source-side labels and credential providers, both looked up by EXACT ID (never the capped
// candidate list). A capped lookup made a live resource past `CANDIDATE_LIMIT` render as a raw
// id, indistinguishable from a deleted one - and, for a credential, silently dropped the
// provider filter so the picker offered every provider's credentials. Resolved here, an id
// missing from `sourceLabels` means exactly one thing: it no longer exists in the source.
const sourceIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const reference of references) {
if (reference.kind === 'env-var' || reference.kind === 'knowledge-document') continue
;(sourceIdsByKind[reference.kind] ??= new Set()).add(reference.sourceId)
}
const [sourceLabels, sourceProviders] = await Promise.all([
loadForkResourceLabels(db, sourceWorkspaceId, sourceIdsByKind),
getCredentialProvidersByIds(
db,
sourceWorkspaceId,
Array.from(sourceIdsByKind.credential ?? [])
),
])
// 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.
@@ -123,6 +168,7 @@ export async function getForkMappingView(
reference: ForkReference
resourceType: ForkMappableResourceType
sourceLabel: string
sourceDeleted: boolean
sourceProviderId: string | undefined
candidates: ForkResourceCandidate[]
storedTargetId: string | null
@@ -147,11 +193,16 @@ export async function getForkMappingView(
: nonCredentialForkKindToResourceType(reference.kind)
}
const sourceCandidate = sourceCandidates[reference.kind].find(
(c) => c.id === reference.sourceId
)
const sourceLabel = sourceCandidate?.label ?? reference.sourceId
const sourceProviderId = sourceCandidate?.providerId
// An env var IS its own name, so it can never be "deleted but referenced" here - a `{{KEY}}`
// absent from the source workspace was already skipped above as a personal secret.
const sourceLabel =
reference.kind === 'env-var'
? reference.sourceId
: (sourceLabels[reference.kind]?.get(reference.sourceId) ?? reference.sourceId)
const sourceDeleted =
reference.kind !== 'env-var' &&
!(sourceLabels[reference.kind]?.has(reference.sourceId) ?? false)
const sourceProviderId = sourceProviders.get(reference.sourceId) ?? undefined
// 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.
@@ -169,6 +220,7 @@ export async function getForkMappingView(
reference,
resourceType,
sourceLabel,
sourceDeleted,
sourceProviderId,
candidates,
storedTargetId,
@@ -212,6 +264,7 @@ export async function getForkMappingView(
resourceType: p.resourceType,
sourceId: p.reference.sourceId,
sourceLabel: p.sourceLabel,
sourceDeleted: p.sourceDeleted,
targetId,
suggested,
// Every entry here is a reference a synced workflow actually carries, and a sync is
@@ -414,16 +467,18 @@ export async function validateForkMappingTargets(
}
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
)
}
// A source credential that no longer exists in the source workspace is EXPECTED here: the
// mapping editor deliberately lists such references (`sourceDeleted`) because mapping the
// dead id to a live target is the documented way to unblock the sync. Rejecting the save
// made that the one kind you could not resolve - the UI told you to map it and the server
// refused. Accepting it is safe: the target is still proven to belong to the target
// workspace above, and credential-ACCESS propagation is not driven from here - promote's
// `propagateCredentialAccess` re-validates BOTH sides inside its transaction and skips any
// pair whose source is not a live credential of the source workspace.
const sourceProviderId = sourceProviders.get(entry.sourceId)
if (sourceProviderId === undefined) continue
// With a live source, the target must share its OAuth provider - a Gmail reference can
// never be pointed at a Google Calendar credential.
const targetProviderId = targetProviders.get(targetId) ?? null
if (sourceProviderId && targetProviderId !== sourceProviderId) {
throw new ForkError(
@@ -240,21 +240,27 @@ export async function listForkResourceCandidates(
}
}
/** One live resource, by exact id. `label` is absent for kinds looked up by id only. */
interface ForkResourceRow {
id: string
label?: string
}
/**
* 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.
* Look up the given ids, grouped by kind, in one workspace and return the rows that still EXIST
* (same archived/deleted filters as `listForkResourceCandidates`). Queries the exact ids - NOT
* the capped candidate list - so a resource sitting past `CANDIDATE_LIMIT` is never mistaken for
* a missing one. 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.
*
* Backs both {@link filterExistingForkTargets} (existence) and {@link loadForkResourceLabels}
* (display names), so the two can never disagree about what "exists" means.
*/
export async function filterExistingForkTargets(
async function loadForkResourceRows(
executor: DbOrTx,
workspaceId: string,
idsByKind: Partial<Record<ForkRemapKind, Set<string>>>
): Promise<Partial<Record<ForkRemapKind, Set<string>>>> {
): Promise<Partial<Record<ForkRemapKind, ForkResourceRow[]>>> {
const ids = (kind: ForkRemapKind): string[] => {
const set = idsByKind[kind]
return set && set.size > 0 ? Array.from(set) : []
@@ -272,9 +278,9 @@ export async function filterExistingForkTargets(
const [creds, tables, kbs, docs, servers, tools, skills, files] = await Promise.all([
credIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
? Promise.resolve([] as ForkResourceRow[])
: executor
.select({ id: credential.id })
.select({ id: credential.id, label: credential.displayName })
.from(credential)
.where(
and(
@@ -284,15 +290,15 @@ export async function filterExistingForkTargets(
)
),
tableIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
? Promise.resolve([] as ForkResourceRow[])
: tableCandidatesQuery(executor, workspaceId, tableIds),
kbIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
? Promise.resolve([] as ForkResourceRow[])
: knowledgeBaseCandidatesQuery(executor, workspaceId, kbIds),
// Documents are validated through a KB join (they are not a standalone candidate kind), so
// this existence check stays inline rather than sharing a per-kind candidate query.
docIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
? Promise.resolve([] as ForkResourceRow[])
: executor
.select({ id: document.id })
.from(document)
@@ -307,29 +313,75 @@ export async function filterExistingForkTargets(
)
),
mcpIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
? Promise.resolve([] as ForkResourceRow[])
: mcpServerCandidatesQuery(executor, workspaceId, mcpIds),
toolIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
? Promise.resolve([] as ForkResourceRow[])
: customToolCandidatesQuery(executor, workspaceId, toolIds),
skillIds.length === 0
? Promise.resolve([] as Array<{ id: string }>)
? Promise.resolve([] as ForkResourceRow[])
: skillCandidatesQuery(executor, workspaceId, skillIds),
fileKeys.length === 0
? Promise.resolve([] as Array<{ id: string }>)
? Promise.resolve([] as ForkResourceRow[])
: fileCandidatesQuery(executor, workspaceId, fileKeys),
])
const result: Partial<Record<ForkRemapKind, ForkResourceRow[]>> = {}
if (credIds.length > 0) result.credential = creds
if (tableIds.length > 0) result.table = tables
if (kbIds.length > 0) result['knowledge-base'] = kbs
if (docIds.length > 0) result['knowledge-document'] = docs
if (mcpIds.length > 0) result['mcp-server'] = servers
if (toolIds.length > 0) result['custom-tool'] = tools
if (skillIds.length > 0) result.skill = skills
// `fileCandidatesQuery` exposes the storage key under `id`, so file rows key by `r.id`.
if (fileKeys.length > 0) result.file = files
return result
}
/**
* Given mapped target ids grouped by kind, return the subset that still EXISTS in the target
* workspace. 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, and by the cleared-ref collector pointed at the SOURCE workspace to flag a
* reference whose resource is gone.
*/
export async function filterExistingForkTargets(
executor: DbOrTx,
workspaceId: string,
idsByKind: Partial<Record<ForkRemapKind, Set<string>>>
): Promise<Partial<Record<ForkRemapKind, Set<string>>>> {
const rows = await loadForkResourceRows(executor, workspaceId, idsByKind)
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 (docIds.length > 0) result['knowledge-document'] = new Set(docs.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))
// `fileCandidatesQuery` exposes the storage key under `id`, so file existence keys by `r.id`.
if (fileKeys.length > 0) result.file = new Set(files.map((r) => r.id))
for (const [kind, kindRows] of Object.entries(rows) as Array<
[ForkRemapKind, ForkResourceRow[]]
>) {
result[kind] = new Set(kindRows.map((row) => row.id))
}
return result
}
/**
* Display names for the given ids, grouped by kind, looked up by exact id in one workspace.
*
* The mapping view labels each scanned reference with this rather than with the capped
* `listForkResourceCandidates` output: a workspace past `CANDIDATE_LIMIT` would otherwise render
* a perfectly live resource as a raw id, indistinguishable from one that was actually deleted.
* With this, an id absent from the returned map means exactly one thing - the resource no longer
* exists in that workspace - which is what `ForkMappingEntry.sourceDeleted` reports.
*/
export async function loadForkResourceLabels(
executor: DbOrTx,
workspaceId: string,
idsByKind: Partial<Record<ForkRemapKind, Set<string>>>
): Promise<Partial<Record<ForkRemapKind, Map<string, string>>>> {
const rows = await loadForkResourceRows(executor, workspaceId, idsByKind)
const result: Partial<Record<ForkRemapKind, Map<string, string>>> = {}
for (const [kind, kindRows] of Object.entries(rows) as Array<
[ForkRemapKind, ForkResourceRow[]]
>) {
result[kind] = new Map(kindRows.map((row) => [row.id, row.label ?? row.id]))
}
return result
}
@@ -957,7 +957,7 @@ describe('collectForkSyncBlockers', () => {
mockLoadCopyableLabels.mockResolvedValue(
new Map([['table:tbl-src', { label: 'Orders', parentId: null, parentLabel: null }]])
)
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
sourceStates: new Map([
[
@@ -987,7 +987,7 @@ describe('collectForkSyncBlockers', () => {
blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }])
)
const { executor, select } = makeExecutor()
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([
@@ -1014,7 +1014,7 @@ describe('collectForkSyncBlockers', () => {
)
mockFilterExisting.mockResolvedValue({ 'mcp-server': new Set(['srv-1']) })
const { executor } = makeExecutor([[{ id: 'srv-1', name: 'Internal Tools' }]])
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([
@@ -1037,6 +1037,58 @@ describe('collectForkSyncBlockers', () => {
])
})
/**
* The drop hatch. `sourceDeleted` is re-derived here from the source workspace inside the
* promote transaction, so the acknowledgment is only ever honoured against a reference that is
* genuinely gone - a crafted payload can never drop a working one.
*/
it('honours a drop acknowledgment for a source-deleted reference', async () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }])
)
mockFilterExisting.mockResolvedValue({ 'knowledge-base': new Set() })
const { blockers, appliedDrops } = await collectForkSyncBlockers(
baseParams({
sourceStates: new Map([
[
'wf-src',
stateWith('knowledge', 'KB Block', {
kb: { type: 'knowledge-base-selector', value: 'kb-gone' },
}),
],
]),
droppedReferences: [{ kind: 'knowledge-base', sourceId: 'kb-gone' }],
})
)
expect(blockers).toEqual([])
expect(appliedDrops).toEqual([{ kind: 'knowledge-base', sourceId: 'kb-gone' }])
})
it('ignores a drop acknowledgment for a reference whose source is still live', async () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }])
)
// The source row still exists, so the reference is an unmapped-copyable, not source-deleted.
mockFilterExisting.mockResolvedValue({ 'knowledge-base': new Set(['kb-live']) })
const { blockers, appliedDrops } = await collectForkSyncBlockers(
baseParams({
sourceStates: new Map([
[
'wf-src',
stateWith('knowledge', 'KB Block', {
kb: { type: 'knowledge-base-selector', value: 'kb-live' },
}),
],
]),
droppedReferences: [{ kind: 'knowledge-base', sourceId: 'kb-live' }],
})
)
expect(blockers).toEqual([
expect.objectContaining({ sourceId: 'kb-live', reason: 'unmapped-copyable' }),
])
expect(appliedDrops).toEqual([])
})
it('blocks a source-deleted reference (source-deleted) - no exemption, resolvable by mapping', async () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }])
@@ -1044,7 +1096,7 @@ describe('collectForkSyncBlockers', () => {
// The liveness check reports the source row gone; the copy loader (live rows only) misses,
// so the label falls back to the id.
mockFilterExisting.mockResolvedValue({ 'knowledge-base': new Set() })
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
sourceStates: new Map([
[
@@ -1066,7 +1118,7 @@ describe('collectForkSyncBlockers', () => {
])
// Mapping the dead id to a live target resolves it (the resolver never checks source
// liveness - a mapping row whose source row is gone still resolves).
const resolved = await collectForkSyncBlockers(
const { blockers: resolved } = await collectForkSyncBlockers(
baseParams({
sourceStates: new Map([
[
@@ -1095,7 +1147,7 @@ describe('collectForkSyncBlockers', () => {
targetActiveIds: new Set(['wf-child-tgt']),
items: [{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt' }],
})
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([
@@ -1132,7 +1184,7 @@ describe('collectForkSyncBlockers', () => {
targetActiveIds: new Set(['wf-child-tgt']),
items: [{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt' }],
})
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([
@@ -1167,8 +1219,8 @@ describe('collectForkSyncBlockers', () => {
],
])
const freshScan = await collectForkSyncBlockers(baseParams({ sourceStates }))
const reusedPlan = await collectForkSyncBlockers(
const { blockers: freshScan } = await collectForkSyncBlockers(baseParams({ sourceStates }))
const { blockers: reusedPlan } = await collectForkSyncBlockers(
baseParams({
sourceStates,
planUnmapped: [{ kind: 'table', sourceId: 'tbl-src' }],
@@ -1179,7 +1231,7 @@ describe('collectForkSyncBlockers', () => {
// unchanged either way.
const overlayResolver: ForkReferenceResolver = (kind, id) =>
kind === 'custom-tool' && id === 'ct-unreferenced' ? 'ct-copy' : null
const withIrrelevantCopy = await collectForkSyncBlockers(
const { blockers: withIrrelevantCopy } = await collectForkSyncBlockers(
baseParams({
sourceStates,
resolver: overlayResolver,
@@ -1206,7 +1258,7 @@ describe('collectForkSyncBlockers', () => {
blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }])
)
const { executor, select } = makeExecutor()
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([
@@ -1230,7 +1282,7 @@ describe('collectForkSyncBlockers', () => {
blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }])
)
const { executor, select } = makeExecutor()
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([
@@ -1259,7 +1311,7 @@ describe('collectForkSyncBlockers', () => {
blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }])
)
const { executor } = makeExecutor([[{ id: 'wf-child', name: 'Child Flow' }]])
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([
@@ -1289,7 +1341,7 @@ describe('collectForkSyncBlockers', () => {
blockWith([{ id: 'workflowIds', title: 'Workflows', type: 'dropdown', multiSelect: true }])
)
const { executor } = makeExecutor([[{ id: 'wf-watched', name: 'Watched Workflow' }]])
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([
@@ -1351,7 +1403,7 @@ describe('collectForkSyncBlockers', () => {
parallels: {},
variables: {},
} as unknown as WorkflowState
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
sourceStates: new Map([['wf-src', state]]),
@@ -1376,7 +1428,7 @@ describe('collectForkSyncBlockers', () => {
])
)
const { executor, select } = makeExecutor()
const blockers = await collectForkSyncBlockers(
const { blockers } = await collectForkSyncBlockers(
baseParams({
executor,
items: [{ ...replaceItem, mode: 'create' as const }],
@@ -300,6 +300,34 @@ export async function annotateForkClearedRefSourceLiveness(
)
}
/**
* Narrow a caller's drop acknowledgments to the ones the server will actually honour: a reference
* whose kind can block at all, and whose resource is genuinely gone from the SOURCE workspace.
*
* Both promote gates consult this, so "which drops count" is decided once. The unmapped gate runs
* FIRST and would otherwise reject a dropped required reference before the cleared-ref gate ever
* got to honour it - making Drop unusable for exactly the required references it exists for. It
* cannot simply subtract the raw acknowledgments there either: an unmapped reference of a
* non-blocking kind (credential, env-var) never re-blocks downstream, so an unverified subtraction
* would let a crafted payload skip the required gate entirely.
*/
export async function verifyForkDropAcknowledgments(
executor: DbOrTx,
sourceWorkspaceId: string,
acknowledged: ReadonlyArray<{ kind: ForkRemapKind; sourceId: string }> | undefined
): Promise<Array<{ kind: ForkRemapKind; sourceId: string }>> {
const droppable = (acknowledged ?? []).filter(
(entry) => !CLEARED_REF_EXCLUDED_KINDS.has(entry.kind)
)
if (droppable.length === 0) return []
const idsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const entry of droppable) {
;(idsByKind[entry.kind] ??= new Set()).add(entry.sourceId)
}
const liveByKind = await filterExistingForkTargets(executor, sourceWorkspaceId, idsByKind)
return droppable.filter((entry) => !(liveByKind[entry.kind]?.has(entry.sourceId) ?? false))
}
/** Upper bound on the blockers a gate failure reports, so the error body stays sane. */
const FORK_SYNC_BLOCKER_LIMIT = 100
@@ -376,24 +404,60 @@ export async function collectForkSyncBlockers(
* rows) when one does. Omit to always collect from scratch.
*/
planUnmapped?: ReadonlyArray<Pick<ForkReference, 'kind' | 'sourceId'>>
/**
* References the user explicitly acknowledged dropping. Applied ONLY where the source
* resource is actually gone, judged by the liveness annotation below - which reads the
* source workspace inside this same transaction - so an acknowledgment for a still-live
* reference is ignored and keeps blocking.
*/
droppedReferences?: ReadonlyArray<{ kind: ForkRemapKind; sourceId: string }>
}
): Promise<ForkSyncBlocker[]> {
const { executor, sourceWorkspaceId, planUnmapped, ...collectParams } = params
if (planUnmapped && !hasForkSyncBlockerCandidates(planUnmapped, collectParams)) return []
): Promise<{
blockers: ForkSyncBlocker[]
/** The acknowledgments that were actually honoured, for post-sync reporting. */
appliedDrops: Array<{ kind: ForkRemapKind; sourceId: string }>
}> {
const { executor, sourceWorkspaceId, planUnmapped, droppedReferences, ...collectParams } = params
const empty = { blockers: [] as ForkSyncBlocker[], appliedDrops: [] }
if (planUnmapped && !hasForkSyncBlockerCandidates(planUnmapped, collectParams)) return empty
const candidates = collectForkClearedRefCandidates({
...collectParams,
sourceLabels: new Map(),
sourceWorkflowNames: new Map(),
})
if (!candidates.some((ref) => ref.cause === 'reference' || ref.cause === 'workflow')) return []
if (!candidates.some((ref) => ref.cause === 'reference' || ref.cause === 'workflow')) return empty
const annotated = await annotateForkClearedRefSourceLiveness(
executor,
sourceWorkspaceId,
candidates
)
const blocking = selectForkSyncBlockingRefs(annotated).slice(0, FORK_SYNC_BLOCKER_LIMIT)
if (blocking.length === 0) return []
const acknowledged = new Set(
(droppedReferences ?? []).map((entry) => `${entry.kind}:${entry.sourceId}`)
)
const appliedDropKeys = new Set<string>()
const afterDrops =
acknowledged.size === 0
? annotated
: annotated.filter((ref) => {
const key = `${ref.kind}:${ref.sourceId}`
// `sourceDeleted` is set only on `reference`-cause entries, so this can never drop a
// dependent- or workflow-cause blocker, nor a reference whose source is still live.
if (ref.cause !== 'reference' || !ref.sourceDeleted || !acknowledged.has(key)) return true
appliedDropKeys.add(key)
return false
})
const appliedDrops = Array.from(appliedDropKeys).map((key) => {
const separator = key.indexOf(':')
return {
kind: key.slice(0, separator) as ForkRemapKind,
sourceId: key.slice(separator + 1),
}
})
const blocking = selectForkSyncBlockingRefs(afterDrops).slice(0, FORK_SYNC_BLOCKER_LIMIT)
if (blocking.length === 0) return { blockers: [], appliedDrops }
// Best-effort display labels (failure path only). Copyable kinds go through the shared label
// loader (live rows only - a deleted source keeps its id label); MCP servers are read without
@@ -434,7 +498,10 @@ export async function collectForkSyncBlockers(
return copyableLabels.get(`${ref.kind}:${ref.sourceId}`)?.label ?? ref.sourceLabel
}
return toForkSyncBlockers(
blocking.map(({ ref, reason }) => ({ ref: { ...ref, sourceLabel: labelFor(ref) }, reason }))
)
return {
blockers: toForkSyncBlockers(
blocking.map(({ ref, reason }) => ({ ref: { ...ref, sourceLabel: labelFor(ref) }, reason }))
),
appliedDrops,
}
}
@@ -140,6 +140,27 @@ export function buildPromoteWorkflowIdMap(params: {
* reported in `excludedTargets` instead of written - the target side of the
* "Exclude from sync" contract. Pure - split from the DB reads so it is unit-testable.
*/
/**
* The target this source would write, when that target is live AND marked "Exclude from sync" -
* the target side of the exclusion contract, which makes the sync skip the source entirely.
* Returns null when the source is not excluded.
*
* Shared by the plan builder and by `getForkMappingView`, so the Mappings section can never list
* references carried only by a workflow the sync provably never touches. Such an entry would be
* unresolvable-looking (its "Used in" list is plan-scoped, so it renders empty) yet still block
* Sync, because every mapping entry is `required`.
*/
export function resolveForkExcludedTargetId(
sourceWorkflowId: string,
identityMap: ReadonlyMap<string, string>,
targetActiveIds: ReadonlySet<string>,
excludedTargetIds: ReadonlySet<string>
): string | null {
const mappedTargetId = identityMap.get(sourceWorkflowId)
if (!mappedTargetId || !targetActiveIds.has(mappedTargetId)) return null
return excludedTargetIds.has(mappedTargetId) ? mappedTargetId : null
}
export function buildForkPromotePlanItems(params: {
deployedSourceWorkflows: DeployedWorkflowSummary[]
sourceStateIds: ReadonlySet<string>
@@ -164,16 +185,22 @@ export function buildForkPromotePlanItems(params: {
for (const source of deployedSourceWorkflows) {
if (!sourceStateIds.has(source.id)) continue
const mappedTargetId = identityMap.get(source.id)
const activeTargetId =
mappedTargetId && targetActiveIds.has(mappedTargetId) ? mappedTargetId : null
if (activeTargetId && excludedTargetIds.has(activeTargetId)) {
const excludedTargetId = resolveForkExcludedTargetId(
source.id,
identityMap,
targetActiveIds,
excludedTargetIds
)
if (excludedTargetId !== null) {
excludedTargets.push({
id: activeTargetId,
name: targetNameById.get(activeTargetId) ?? source.name,
id: excludedTargetId,
name: targetNameById.get(excludedTargetId) ?? source.name,
})
continue
}
const mappedTargetId = identityMap.get(source.id)
const activeTargetId =
mappedTargetId && targetActiveIds.has(mappedTargetId) ? mappedTargetId : null
items.push({
sourceWorkflowId: source.id,
targetWorkflowId: activeTargetId ?? generateId(),
@@ -20,6 +20,8 @@ const {
mockCreateTransform,
mockSumForkCopyBytes,
mockAssertForkStorageHeadroom,
mockLoadTargetWebhookPaths,
mockVerifyDrops,
} = vi.hoisted(() => ({
mockComputePlan: vi.fn(),
mockBuildCopySelection: vi.fn(),
@@ -36,6 +38,8 @@ const {
mockCreateTransform: vi.fn(),
mockSumForkCopyBytes: vi.fn(),
mockAssertForkStorageHeadroom: vi.fn(),
mockLoadTargetWebhookPaths: vi.fn(),
mockVerifyDrops: vi.fn(),
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
@@ -68,6 +72,7 @@ vi.mock('@/ee/workspace-forking/lib/copy/storage-quota', () => ({
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
getActiveDeploymentVersionNumbers: vi.fn(async () => new Map()),
loadSourceDeployedStates: mockLoadSourceDeployedStates,
loadTargetWebhookPathsByBlock: mockLoadTargetWebhookPaths,
}))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
acquireForkEdgeLock: vi.fn(),
@@ -104,6 +109,7 @@ vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({
}))
vi.mock('@/ee/workspace-forking/lib/promote/cleared-refs', () => ({
collectForkSyncBlockers: mockCollectBlockers,
verifyForkDropAcknowledgments: mockVerifyDrops,
}))
vi.mock('@/ee/workspace-forking/lib/promote/copy-unmapped', () => ({
// Faithful mirror of the real overlay so a copy's id maps resolve through the augmented
@@ -152,6 +158,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
}))
import { db } from '@sim/db'
import { getBlock } from '@/blocks/registry'
import { copyWorkflowStateIntoTarget } from '@/ee/workspace-forking/lib/copy/copy-workflows'
import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
@@ -246,7 +253,7 @@ beforeEach(() => {
willResolve: new Set<string>(),
})
mockHasCopySelection.mockReturnValue(false)
mockCollectBlockers.mockResolvedValue([])
mockCollectBlockers.mockResolvedValue({ blockers: [], appliedDrops: [] })
mockLoadBlockMap.mockResolvedValue(new Map())
mockBuildBlockIdResolver.mockReturnValue((_wf: string, blockId: string) => blockId)
mockResolveFolderMapping.mockResolvedValue(new Map())
@@ -255,6 +262,9 @@ beforeEach(() => {
mockCreateTransform.mockReturnValue((subBlocks: unknown) => subBlocks)
mockSumForkCopyBytes.mockResolvedValue(0)
mockAssertForkStorageHeadroom.mockResolvedValue(undefined)
mockLoadTargetWebhookPaths.mockResolvedValue(new Map())
// Default: no acknowledgments, so the unmapped gate behaves exactly as before.
mockVerifyDrops.mockResolvedValue([])
})
describe('promoteFork gates', () => {
@@ -321,8 +331,56 @@ describe('promoteFork gates', () => {
expect(mockUpsertPromoteRun).not.toHaveBeenCalled()
})
/**
* A source-deleted reference on a REQUIRED field sits in `unmappedRequired`, and that gate runs
* before the cleared-ref gate that honours drops. Without subtracting verified drops here, Drop
* was inert for exactly the references it exists to unblock - the sync still failed with
* "map all required ... first".
*/
it('lets a VERIFIED drop clear the unmapped gate for a required reference', async () => {
mockComputePlan.mockResolvedValue(
makePlan({
unmappedRequired: [
{ kind: 'table', sourceId: 'tbl-gone', subBlockKey: 'tableSelector', required: true },
],
})
)
mockVerifyDrops.mockResolvedValue([{ kind: 'table', sourceId: 'tbl-gone' }])
const result = await promoteFork({
...promoteParams(),
dropReferences: [{ kind: 'table', sourceId: 'tbl-gone' }],
})
expect(result.blocked).toBeNull()
// The SAME verified set reaches the cleared-ref gate, so one liveness check governs both.
expect(mockCollectBlockers).toHaveBeenCalledWith(
expect.objectContaining({ droppedReferences: [{ kind: 'table', sourceId: 'tbl-gone' }] })
)
})
/** An acknowledgment the server refuses (source still live) must not weaken the required gate. */
it('keeps blocking when the acknowledgment fails verification', async () => {
mockComputePlan.mockResolvedValue(
makePlan({
unmappedRequired: [
{ kind: 'table', sourceId: 'tbl-live', subBlockKey: 'tableSelector', required: true },
],
})
)
mockVerifyDrops.mockResolvedValue([])
const result = await promoteFork({
...promoteParams(),
dropReferences: [{ kind: 'table', sourceId: 'tbl-live' }],
})
expect(result.blocked).toBe('unmapped')
expect(mockCollectBlockers).not.toHaveBeenCalled()
})
it('blocks with the structured blocker list when references would clear, writing NOTHING', async () => {
mockCollectBlockers.mockResolvedValue([BLOCKER])
mockCollectBlockers.mockResolvedValue({ blockers: [BLOCKER], appliedDrops: [] })
const result = await promoteFork(promoteParams())
@@ -624,3 +682,106 @@ describe('promoteFork dependent values', () => {
)
})
})
describe('promoteFork trigger URLs', () => {
beforeEach(() => {
// A block only holds a public URL when its config declares a `useWebhookUrl` field, so the
// fixture has to look like a webhook trigger to the shared predicate.
vi.mocked(getBlock).mockReturnValue({
category: 'triggers',
subBlocks: [{ id: 'triggerWebhookUrl', useWebhookUrl: true }],
} as never)
})
const triggerState = {
blocks: {
'blk-new': {
id: 'blk-new',
// The REAL slack_webhook trigger id, so the provider check resolves against the actual
// registry - adoption only pairs a URL with a trigger of the SAME provider.
type: 'slack_webhook',
name: 'Slack messages',
triggerMode: true,
subBlocks: {},
outputs: {},
enabled: true,
},
},
edges: [],
loops: {},
parallels: {},
variables: {},
}
function arrangeReCreatedTrigger() {
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Flow',
mode: 'replace' as const,
sourceMeta: { name: 'Flow', description: null, folderId: null, sortOrder: 0 },
}
mockComputePlan.mockResolvedValue(makePlan({ items: [item] }))
mockLoadSourceDeployedStates.mockResolvedValue({
deployedWorkflows: [],
sourceStates: new Map([['wf-src', triggerState]]),
})
// The old trigger block ('blk-old') serves the live URL and is NOT in the source any more:
// the user deleted and re-added the trigger, so the sync writes 'blk-new' instead.
mockLoadTargetWebhookPaths.mockResolvedValue(
new Map([['blk-old', { path: 'live-slack-path', workflowId: 'wf-tgt', provider: 'slack' }]])
)
vi.mocked(copyWorkflowStateIntoTarget).mockResolvedValue({
targetWorkflowId: 'wf-tgt',
mode: 'replace',
name: 'Flow',
blocksCount: 1,
edgesCount: 0,
subflowsCount: 0,
clearedDependents: [],
blockIdMapping: new Map(),
})
}
/**
* The reported bug, at the promote level: pushing a workflow whose Slack trigger was re-created
* used to hand the parent a brand-new webhook URL, forcing a re-paste into Slack every sync.
*/
it('hands the retiring URL to the arriving trigger instead of minting a new one', async () => {
arrangeReCreatedTrigger()
const result = await promoteFork(promoteParams())
expect(result.blocked).toBeNull()
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.triggerPathByBlockId?.get('blk-new')).toBe('live-slack-path')
// Adopted, so nothing needs re-registering externally.
expect(result.triggerUrlChanges).toEqual([])
})
it('reports the URL as lost when the caller explicitly opts into a new one', async () => {
arrangeReCreatedTrigger()
const result = await promoteFork({
...promoteParams(),
triggerMappings: [{ sourceBlockId: 'blk-new', adoptPath: null }],
})
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.triggerPathByBlockId?.size).toBe(0)
expect(result.triggerUrlChanges).toEqual([{ workflowName: 'Flow', path: 'live-slack-path' }])
})
/** The server re-derives the adoptable set, so a stale or crafted path is never honoured. */
it('ignores a mapping naming a path the plan does not offer', async () => {
arrangeReCreatedTrigger()
await promoteFork({
...promoteParams(),
triggerMappings: [{ sourceBlockId: 'blk-new', adoptPath: 'someone-elses-path' }],
})
const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0]
expect(writeParams.triggerPathByBlockId?.size).toBe(0)
})
})
@@ -33,6 +33,7 @@ import {
import {
getActiveDeploymentVersionNumbers,
loadSourceDeployedStates,
loadTargetWebhookPathsByBlock,
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import {
assertForkStorageHeadroom,
@@ -63,7 +64,10 @@ import {
upsertEdgeMappings,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import { getMcpServerMetaByIds } from '@/ee/workspace-forking/lib/mapping/resources'
import { collectForkSyncBlockers } from '@/ee/workspace-forking/lib/promote/cleared-refs'
import {
collectForkSyncBlockers,
verifyForkDropAcknowledgments,
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
import {
augmentForkResolver,
buildPromoteCopySelection,
@@ -78,6 +82,12 @@ import {
type PromoteRunWorkflowSnapshot,
upsertPromoteRun,
} from '@/ee/workspace-forking/lib/promote/promote-run-store'
import {
buildForkTriggerPlan,
type ForkTriggerMappingInput,
type ForkTriggerUrlChange,
resolveForkTriggerPaths,
} from '@/ee/workspace-forking/lib/promote/trigger-urls'
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import {
createForkSubBlockTransform,
@@ -119,6 +129,17 @@ export interface PromoteForkParams {
* plan's copyable candidates, so an arbitrary id is ignored.
*/
copyResources?: PromoteCopyResources
/**
* References the caller explicitly acknowledged dropping, so the sync clears them in the target
* instead of blocking. Honoured only where the source resource is genuinely gone (re-derived
* in-transaction), so a live reference can never be dropped by a crafted payload.
*/
dropReferences?: Array<{ kind: ForkRemapKind; sourceId: string }>
/**
* Which retiring public URL each arriving trigger takes over. Re-validated in-transaction
* against the adoptable set the plan derives, so an entry the plan does not offer is ignored.
*/
triggerMappings?: ForkTriggerMappingInput[]
requestId?: string
}
@@ -159,6 +180,16 @@ export interface PromoteForkResult {
* behavior is never silent.
*/
clearedOptional: Array<{ workflowName: string; blocks: string[] }>
/**
* Source-deleted references the user acknowledged dropping that this sync actually cleared in
* the target. Only entries whose source was verified gone in-transaction appear here.
*/
droppedReferences: Array<{ kind: ForkRemapKind; sourceId: string }>
/**
* Public trigger URLs this sync stopped serving in the target - a URL that retired with no
* arriving trigger adopting it. Whatever calls it externally has to be repointed.
*/
triggerUrlChanges: ForkTriggerUrlChange[]
}
function collectCredentialPairs(plan: ForkPromotePlan): Array<[string, string]> {
@@ -307,6 +338,10 @@ interface PromoteTxApplied {
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[] }>
/** Acknowledged source-deleted references this sync cleared instead of blocking on. */
droppedReferences: Array<{ kind: ForkRemapKind; sourceId: string }>
/** Public trigger URLs this sync stopped serving (nothing adopted them). */
triggerUrlChanges: ForkTriggerUrlChange[]
/** Heavy content for resources copied into the target this sync, filled best-effort post-commit. */
copyContentPlan: ForkContentPlan | null
/** Serialized in-content maps for the post-commit skill-body rewrite (paired with the plan). */
@@ -442,10 +477,23 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
params.copyResources,
plan.copyableUnmapped
)
// Drop acknowledgments the server will honour, verified against the SOURCE inside this same
// locked tx. Resolved BEFORE the unmapped gate because a source-deleted reference on a
// required field is in `unmappedRequired`: gating on it first would reject the sync with
// "map all required ... first" no matter what the user dropped, making Drop inert for the
// required references it exists to unblock.
const verifiedDrops = await verifyForkDropAcknowledgments(
tx,
sourceWorkspaceId,
params.dropReferences
)
const droppedKeys = new Set(verifiedDrops.map((entry) => `${entry.kind}:${entry.sourceId}`))
// plan.unmappedRequired is already references.filter(resolver == null).filter(required), so
// subtracting the refs the copy will resolve is equivalent to re-scanning the predicate.
const postCopyUnmappedRequired = plan.unmappedRequired.filter(
(reference) => !willResolve.has(`${reference.kind}:${reference.sourceId}`)
(reference) =>
!willResolve.has(`${reference.kind}:${reference.sourceId}`) &&
!droppedKeys.has(`${reference.kind}:${reference.sourceId}`)
)
if (postCopyUnmappedRequired.length > 0) {
return {
@@ -477,9 +525,10 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
// plan's unmapped references are threaded through so the gate's happy path reuses the plan's
// scan (computed moments earlier over the same states, inside this same locked tx) instead of
// re-running the full per-block reference scan; the scan re-runs only when something blocks.
let droppedReferences: Array<{ kind: ForkRemapKind; sourceId: string }> = []
const gateResolver: ForkReferenceResolver = (kind, sourceId) =>
willResolve.has(`${kind}:${sourceId}`) ? sourceId : plan.resolver(kind, sourceId)
const blockers = await collectForkSyncBlockers({
const { blockers, appliedDrops } = await collectForkSyncBlockers({
executor: tx,
sourceWorkspaceId,
items: plan.items,
@@ -488,10 +537,12 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
workflowIdMap: plan.workflowIdMap,
resolveBlockId,
planUnmapped: [...plan.unmappedRequired, ...plan.unmappedOptional],
droppedReferences: verifiedDrops,
})
if (blockers.length > 0) {
return { blocked: 'cleared-refs', blockers }
}
droppedReferences = appliedDrops
// Resolve the source->target folder map BEFORE the copy so the folders already exist in the
// target and the copy can rewrite `sim:folder/<id>` references inside copied skill / markdown
@@ -638,6 +689,22 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
// block map + resolver loaded before the would-clear gate above.
const blockPairs: ForkBlockPair[] = []
// Every trigger block's final public path, resolved ONCE for the whole sync: the path a
// target block already serves, or a retiring one it adopts. Rebuilt here rather than trusted
// from the caller, so an adoption is re-validated against the live webhooks inside this same
// locked transaction and a stale preview can never move a URL the plan no longer offers.
const triggerPlan = buildForkTriggerPlan({
items: plan.items,
sourceStates,
resolveBlockId,
targetWebhooks: await loadTargetWebhookPathsByBlock(
tx,
plan.items.map((item) => item.targetWorkflowId)
),
})
const { pathByTargetBlockId: triggerPathByBlockId, changes: triggerUrlChanges } =
resolveForkTriggerPaths(triggerPlan, params.triggerMappings)
const updatedSnapshots: PromoteRunWorkflowSnapshot[] = []
const createdTargetIds: string[] = []
const writtenItems: typeof plan.items = []
@@ -672,6 +739,7 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
dependentOverrides: overridesByWorkflow.get(item.targetWorkflowId),
nameRegistry,
resolveBlockId,
triggerPathByBlockId,
requestId,
})
blockPairs.push(
@@ -901,6 +969,8 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
undeployEventIds,
needsConfiguration,
clearedOptional,
droppedReferences,
triggerUrlChanges,
copyContentPlan,
copyContentRefMaps,
copyContentBlobTasks,
@@ -924,6 +994,8 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
archivedNames: [],
needsConfiguration: [],
clearedOptional: [],
droppedReferences: [],
triggerUrlChanges: [],
}
}
@@ -1097,5 +1169,7 @@ export async function promoteFork(params: PromoteForkParams): Promise<PromoteFor
blocks,
})),
clearedOptional: txResult.clearedOptional,
droppedReferences: txResult.droppedReferences,
triggerUrlChanges: txResult.triggerUrlChanges,
}
}
@@ -0,0 +1,253 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getBlock } from '@/blocks/registry'
import type { ForkTargetWebhook } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import {
buildForkTriggerPlan,
type ForkTriggerMappingInput,
resolveForkTriggerPaths,
} from '@/ee/workspace-forking/lib/promote/trigger-urls'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
/** A webhook trigger: `useWebhookUrl` is what marks a block as serving a public URL. */
const TRIGGER_BLOCK = {
name: 'Slack',
description: '',
category: 'triggers',
subBlocks: [{ id: 'triggerWebhookUrl', useWebhookUrl: true }],
}
/** A trigger with no public URL - a poller, schedule, or chat trigger. */
const URL_LESS_TRIGGER_BLOCK = { ...TRIGGER_BLOCK, subBlocks: [{ id: 'cron' }] }
function stateWith(blocks: Record<string, { type: string; name: string }>): WorkflowState {
return {
blocks: Object.fromEntries(
Object.entries(blocks).map(([id, block]) => [
id,
{ id, type: block.type, name: block.name, subBlocks: {}, outputs: {}, enabled: true },
])
),
edges: [],
loops: {},
parallels: {},
} as unknown as WorkflowState
}
const item = {
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Prod',
mode: 'replace' as const,
sourceMeta: {
name: 'Prod',
description: null,
folderId: null,
sortOrder: 0,
isPublicApi: false,
},
}
/** Identity resolver: the source block keeps its id in the target (the stable-pairing case). */
const identityResolver = (_targetWorkflowId: string, sourceBlockId: string) => sourceBlockId
function webhooks(entries: Array<[string, ForkTargetWebhook]>) {
return new Map(entries)
}
function run(
blocks: Record<string, { type: string; name: string }>,
targetWebhooks: Map<string, ForkTargetWebhook>,
overrides?: ForkTriggerMappingInput[]
) {
const plan = buildForkTriggerPlan({
items: [item],
sourceStates: new Map([['wf-src', stateWith(blocks)]]),
resolveBlockId: identityResolver,
targetWebhooks,
})
return { plan, ...resolveForkTriggerPaths(plan, overrides) }
}
/**
* Blocks use the REAL `slack_webhook` trigger id, so provider resolution runs against the actual
* trigger registry (provider `slack`) rather than a mock that could drift from it.
*/
describe('fork trigger URLs', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getBlock).mockReturnValue(TRIGGER_BLOCK as never)
})
it('pins a trigger that keeps its target identity to its own path, reporting no change', () => {
const { pathByTargetBlockId, changes, plan } = run(
{ blk: { type: 'slack_webhook', name: 'Slack' } },
webhooks([['blk', { path: 'custom-path', workflowId: 'wf-tgt', provider: 'slack' }]])
)
expect(changes).toEqual([])
expect(pathByTargetBlockId.get('blk')).toBe('custom-path')
// Nothing to decide: the block already serves a URL, so it offers no alternatives.
expect(plan.slots[0].adoptablePaths).toEqual([])
})
/**
* The reported bug: a trigger deleted and re-added in the source re-keys the target block, which
* used to mint a new URL. The single arriving trigger now adopts the retiring URL instead.
*/
it('adopts a retiring URL onto the single arriving trigger that replaces it', () => {
const { pathByTargetBlockId, changes, plan } = run(
{ blk2: { type: 'slack_webhook', name: 'Slack v2' } },
webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]])
)
expect(plan.slots[0].defaultAdoptPath).toBe('blk1')
expect(pathByTargetBlockId.get('blk2')).toBe('blk1')
// Adopted means still served — there is nothing for the user to re-register.
expect(changes).toEqual([])
})
it('reports a removal when the trigger is gone from the source entirely', () => {
vi.mocked(getBlock).mockReturnValue({ ...TRIGGER_BLOCK, category: 'blocks' } as never)
const { pathByTargetBlockId, changes } = run(
{ fn: { type: 'function', name: 'Fn' } },
webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]])
)
expect(changes).toEqual([{ workflowName: 'Prod', path: 'blk1' }])
expect(pathByTargetBlockId.size).toBe(0)
})
it('does not guess a pairing when several URLs retire at once', () => {
const { pathByTargetBlockId, changes, plan } = run(
{ blk3: { type: 'slack_webhook', name: 'Slack' } },
webhooks([
['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }],
['blk2', { path: 'blk2', workflowId: 'wf-tgt', provider: 'slack' }],
])
)
expect(plan.slots[0].defaultAdoptPath).toBeNull()
// Both are offered, so the user can resolve the ambiguity; neither is taken by default.
expect(plan.slots[0].adoptablePaths).toEqual(['blk1', 'blk2'])
expect(pathByTargetBlockId.size).toBe(0)
expect(changes.map((change) => change.path)).toEqual(['blk1', 'blk2'])
})
it('honours an explicit pick when the pairing is ambiguous', () => {
const { pathByTargetBlockId, changes } = run(
{ blk3: { type: 'slack_webhook', name: 'Slack' } },
webhooks([
['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }],
['blk2', { path: 'blk2', workflowId: 'wf-tgt', provider: 'slack' }],
]),
[{ sourceBlockId: 'blk3', adoptPath: 'blk2' }]
)
expect(pathByTargetBlockId.get('blk3')).toBe('blk2')
expect(changes.map((change) => change.path)).toEqual(['blk1'])
})
it('lets an explicit null override the default and mint a new URL', () => {
const { pathByTargetBlockId, changes } = run(
{ blk2: { type: 'slack_webhook', name: 'Slack v2' } },
webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]]),
[{ sourceBlockId: 'blk2', adoptPath: null }]
)
expect(pathByTargetBlockId.size).toBe(0)
expect(changes).toEqual([{ workflowName: 'Prod', path: 'blk1' }])
})
/** A crafted payload must not be able to move a URL the plan never offered. */
it('ignores an override naming a path this slot does not offer', () => {
const { pathByTargetBlockId } = run(
{ blk2: { type: 'slack_webhook', name: 'Slack v2' } },
webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]]),
[{ sourceBlockId: 'blk2', adoptPath: 'a-path-from-another-workspace' }]
)
expect(pathByTargetBlockId.size).toBe(0)
})
it('never lets two triggers adopt the same path', () => {
const { pathByTargetBlockId } = run(
{
blk2: { type: 'slack_webhook', name: 'Slack A' },
blk3: { type: 'slack_webhook', name: 'Slack B' },
},
webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]]),
[
{ sourceBlockId: 'blk2', adoptPath: 'blk1' },
{ sourceBlockId: 'blk3', adoptPath: 'blk1' },
]
)
expect(Array.from(pathByTargetBlockId.values())).toEqual(['blk1'])
expect(pathByTargetBlockId.get('blk2')).toBe('blk1')
})
/**
* A path is authenticated and parsed as its provider. Handing a GitHub URL to an arriving Slack
* trigger would keep the endpoint alive while every request failed signature verification — and
* the sync would have reported the URL as preserved, so nobody would go looking.
*/
it('never offers a retiring URL from a DIFFERENT provider', () => {
const { plan, pathByTargetBlockId, changes } = run(
{ blk2: { type: 'slack_webhook', name: 'Slack v2' } },
webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'github' }]])
)
expect(plan.slots[0].adoptablePaths).toEqual([])
expect(plan.slots[0].defaultAdoptPath).toBeNull()
expect(pathByTargetBlockId.size).toBe(0)
// Still reported as lost, so the GitHub subscription's owner is told it stopped serving.
expect(changes).toEqual([{ workflowName: 'Prod', path: 'blk1' }])
})
it('pairs only within the matching provider when several URLs retire', () => {
const { plan, pathByTargetBlockId } = run(
{ blk3: { type: 'slack_webhook', name: 'Slack' } },
webhooks([
['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'github' }],
['blk2', { path: 'blk2', workflowId: 'wf-tgt', provider: 'slack' }],
])
)
// Only the same-provider URL is a candidate, which makes the pairing unambiguous again.
expect(plan.slots[0].adoptablePaths).toEqual(['blk2'])
expect(pathByTargetBlockId.get('blk3')).toBe('blk2')
})
/**
* Adoption is scoped to one target workflow because `webhook_path_claim` ownership is
* per-workflow: taking a path from another workflow would be an ownership transfer the claim
* layer refuses, so it must never be offered.
*/
it('never offers a path owned by a different workflow', () => {
const { plan, pathByTargetBlockId, changes } = run(
{ blk: { type: 'slack_webhook', name: 'Slack' } },
webhooks([['other', { path: 'other', workflowId: 'wf-elsewhere', provider: 'slack' }]])
)
expect(plan.slots[0].adoptablePaths).toEqual([])
expect(pathByTargetBlockId.size).toBe(0)
expect(changes).toEqual([])
})
it('skips a non-trigger block arriving on a target block with no webhook', () => {
vi.mocked(getBlock).mockReturnValue({ ...TRIGGER_BLOCK, category: 'blocks' } as never)
const { plan } = run(
{ fn: { type: 'function', name: 'Fn' } },
webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]])
)
expect(plan.slots).toEqual([])
})
/**
* A poller or schedule trigger has no public URL, so handing it a retiring one would point an
* external caller at a path its provider never serves.
*/
it('never offers a retiring URL to a trigger that serves no public URL', () => {
vi.mocked(getBlock).mockReturnValue(URL_LESS_TRIGGER_BLOCK as never)
const { plan, pathByTargetBlockId, changes } = run(
{ poller: { type: 'gmail', name: 'Gmail poller' } },
webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]])
)
expect(plan.slots).toEqual([])
expect(pathByTargetBlockId.size).toBe(0)
// The URL still retires and is still reported - it just has no eligible adopter.
expect(changes).toEqual([{ workflowName: 'Prod', path: 'blk1' }])
})
})
@@ -0,0 +1,195 @@
import type { ForkTargetWebhook } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import type { ForkPromotePlanItem } from '@/ee/workspace-forking/lib/promote/promote-plan'
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { blockAdvertisesWebhookUrl, resolveBlockTriggerProvider } from '@/triggers/webhook-url'
/**
* A public trigger URL a sync stops serving in the target.
*
* Only URLs that genuinely go away are reported: one an arriving trigger adopts keeps serving the
* same path, so it is not a change. Whatever calls this path externally - a Slack Request URL, a
* provider subscription - stops being called and has to be repointed by hand.
*/
export interface ForkTriggerUrlChange {
workflowName: string
path: string
}
/**
* One arriving trigger block whose public URL this sync decides.
*
* A target webhook's path is `triggerPath || block.id`, and a sync assigns target block ids from
* the SOURCE's block identity - so a trigger re-created in the source re-keys its target block and
* moves the URL. `ownPath` is the stable case (the block already serves a URL, which is pinned
* back verbatim); `adoptablePaths` is the decision, listing URLs retiring in the SAME target
* workflow that this block can take over instead of minting a new one.
*/
export interface ForkTriggerSlot {
sourceBlockId: string
targetBlockId: string
blockName: string
workflowName: string
/** The path this block already serves. Pinned as-is; there is no decision to make. */
ownPath: string | null
/** Retiring paths in the same target workflow this block could take over instead. */
adoptablePaths: string[]
/** The unambiguous pairing (exactly one retiring URL, exactly one arriving trigger). */
defaultAdoptPath: string | null
}
/** Every trigger decision a sync makes, plus the URLs it would retire. */
export interface ForkTriggerPlan {
slots: ForkTriggerSlot[]
/** Live target webhooks on blocks this sync will not write - their URLs stop being served. */
retiring: Array<{ path: string; workflowName: string }>
}
/** A caller's explicit choice of which retiring URL an arriving trigger takes over. */
export interface ForkTriggerMappingInput {
sourceBlockId: string
/** A path from that slot's `adoptablePaths`, or null to mint a new URL. */
adoptPath: string | null
}
/**
* Work out, per target workflow, which trigger URLs retire and which arriving triggers could
* take them over.
*
* Adoption is deliberately scoped to a SINGLE target workflow. `webhook_path_claim` ownership is
* per-workflow (`claimWebhookPath` conflicts only against a *different* workflow), so moving a
* path between blocks of the same workflow re-uses a claim that workflow already holds and can
* never conflict. Offering a path from another workflow would be a genuine ownership transfer,
* which the claim layer refuses by design - so it is never a candidate.
*
* Pure over the pre-read source states, so the preview and the write agree by construction.
*/
export function buildForkTriggerPlan(params: {
items: ForkPromotePlanItem[]
sourceStates: Map<string, WorkflowState>
resolveBlockId: ForkBlockIdResolver
targetWebhooks: ReadonlyMap<string, ForkTargetWebhook>
}): ForkTriggerPlan {
const { items, sourceStates, resolveBlockId, targetWebhooks } = params
const liveByWorkflow = new Map<
string,
Array<{ blockId: string; path: string; provider: string | null }>
>()
for (const [blockId, row] of targetWebhooks) {
const entry = { blockId, path: row.path, provider: row.provider }
const list = liveByWorkflow.get(row.workflowId)
if (list) list.push(entry)
else liveByWorkflow.set(row.workflowId, [entry])
}
const slots: ForkTriggerSlot[] = []
const retiring: ForkTriggerPlan['retiring'] = []
for (const item of items) {
const sourceState = sourceStates.get(item.sourceWorkflowId)
if (!sourceState) continue
const sourceByTargetBlockId = new Map<string, { sourceBlockId: string; block: BlockState }>()
for (const [sourceBlockId, block] of Object.entries(sourceState.blocks)) {
sourceByTargetBlockId.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), {
sourceBlockId,
block,
})
}
// A live webhook on a block this sync will not write: its URL stops being served.
const live = liveByWorkflow.get(item.targetWorkflowId) ?? []
const retired = live.filter((row) => !sourceByTargetBlockId.has(row.blockId))
for (const row of retired) {
retiring.push({ path: row.path, workflowName: item.sourceMeta.name })
}
const arriving: ForkTriggerSlot[] = []
for (const [targetBlockId, { sourceBlockId, block }] of sourceByTargetBlockId) {
// Only a block that advertises a public URL can hold one. Handing a retiring URL to a
// poller or a shared-app trigger would point an external caller at a path its provider
// never serves - so those are not candidates, and never appear as rows.
if (!blockAdvertisesWebhookUrl(block)) continue
const ownPath = targetWebhooks.get(targetBlockId)?.path ?? null
// Only a retiring URL of the SAME provider is adoptable. A path is authenticated and parsed
// as its provider, so handing a GitHub URL to a Slack trigger would keep the endpoint alive
// while every request failed signature verification - and the sync would have reported the
// URL as preserved, so nobody would go looking.
const provider = resolveBlockTriggerProvider(block)
arriving.push({
sourceBlockId,
targetBlockId,
blockName: block.name,
workflowName: item.sourceMeta.name,
ownPath,
// A block already serving a URL keeps it; only a block without one is a candidate to
// adopt, so offering it a second URL would just be a way to break the first.
adoptablePaths:
ownPath === null && provider !== null
? retired.filter((row) => row.provider === provider).map((row) => row.path)
: [],
defaultAdoptPath: null,
})
}
// Default only the unambiguous pairing, and only within one provider: with several retiring or
// several arriving, guessing which new trigger replaces which old URL would silently point an
// external caller at the wrong workflow branch - the user picks instead.
const adopters = arriving.filter((slot) => slot.adoptablePaths.length > 0)
if (adopters.length === 1 && adopters[0].adoptablePaths.length === 1) {
adopters[0].defaultAdoptPath = adopters[0].adoptablePaths[0]
}
slots.push(...arriving)
}
return { slots, retiring }
}
/**
* Resolve every trigger block's final path, applying the caller's explicit choices over the
* plan's defaults, and report the URLs that still retire.
*
* An override is honoured only for a path the slot actually offered (same target workflow, still
* retiring), and each path can be adopted once - so a crafted payload can neither move a URL
* across workflows nor point two triggers at one path (which the unique webhook path index would
* reject at deploy time anyway, failing the whole sync).
*/
export function resolveForkTriggerPaths(
plan: ForkTriggerPlan,
overrides: readonly ForkTriggerMappingInput[] = []
): {
/** Target block id -> the path to pin into its `triggerPath`. */
pathByTargetBlockId: Map<string, string>
changes: ForkTriggerUrlChange[]
} {
const overrideBySourceBlockId = new Map(
overrides.map((entry) => [entry.sourceBlockId, entry.adoptPath])
)
const pathByTargetBlockId = new Map<string, string>()
const adopted = new Set<string>()
for (const slot of plan.slots) {
if (slot.ownPath !== null) {
pathByTargetBlockId.set(slot.targetBlockId, slot.ownPath)
continue
}
const requested = overrideBySourceBlockId.has(slot.sourceBlockId)
? overrideBySourceBlockId.get(slot.sourceBlockId)!
: slot.defaultAdoptPath
if (requested === null || requested === undefined) continue
if (!slot.adoptablePaths.includes(requested)) continue
if (adopted.has(requested)) continue
adopted.add(requested)
pathByTargetBlockId.set(slot.targetBlockId, requested)
}
const changes: ForkTriggerUrlChange[] = []
for (const row of plan.retiring) {
// An adopted path keeps serving the same URL, so it is not a change to warn about.
if (adopted.has(row.path)) continue
changes.push({ workflowName: row.workflowName, path: row.path })
}
return { pathByTargetBlockId, changes }
}
@@ -1300,6 +1300,64 @@ describe('canonical mode policy (fork/promote)', () => {
expect(scan.references).toEqual([])
})
/**
* Every shipped canonical pair's advanced member is a plain `short-input`, which carries no
* resource definition — so the "advanced is user-owned, verbatim" policy has never been
* exercised against an advanced member that IS a resource selector. Pin it here so the
* policy holds by enforcement rather than by the accident of the current block configs.
*/
const selectorPairBlock = () =>
blockWith([
{
id: 'tableSelector',
title: 'Table',
type: 'table-selector',
canonicalParamId: 'tableId',
mode: 'basic',
},
{
id: 'advancedTableSelector',
title: 'Table (advanced)',
type: 'table-selector',
canonicalParamId: 'tableId',
mode: 'advanced',
},
])
it('advanced mode: a selector-typed manual member is neither remapped nor detected', () => {
vi.mocked(getBlock).mockReturnValue(selectorPairBlock())
const resolveTable = (kind: string, id: string) =>
kind === 'table' && id === 'tbl-manual' ? 'tbl-copy' : null
const transform = createForkBootstrapTransform(resolveTable as never)
const result = transform(
{
tableSelector: entry('tableSelector', 'table-selector', 'tbl-basic'),
advancedTableSelector: entry('advancedTableSelector', 'table-selector', 'tbl-manual'),
},
'table',
{ tableId: 'advanced' }
)
expect(result.advancedTableSelector.value).toBe('tbl-manual')
expect(result.tableSelector.value).toBe('')
const scan = scanWorkflowReferences(
[
{
id: 'b1',
name: 'Table',
type: 'table',
subBlocks: {
tableSelector: entry('tableSelector', 'table-selector', 'tbl-basic'),
advancedTableSelector: entry('advancedTableSelector', 'table-selector', 'tbl-manual'),
},
canonicalModes: { tableId: 'advanced' },
},
],
() => null
)
expect(scan.references).toEqual([])
})
it('does not detect a condition-hidden subblock (its value never executes)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
@@ -859,14 +859,20 @@ export function remapForkSubBlocks(
// under a MANUAL (advanced-active) parent passes through verbatim; a condition-hidden
// subblock is rewritten but never detected.
const dormant = gates.isDormantMember(subBlockKey)
const verbatimManualDependent = !dormant && gates.isManualParentDependent(subBlockKey)
const detectionSkipped =
dormant || verbatimManualDependent || gates.isConditionHidden(subBlockKey)
// Verbatim (user-owned: never remapped, never a mapping requirement) covers the ACTIVE
// advanced member itself as well as every dependent scoped to it. `clearDependentsOnRemap`
// already spares an active manual member from a parent remap; naming it here applies the
// same policy on the detect/rewrite side, which until now held only because every shipped
// pair's advanced member is a plain `short-input` carrying no resource definition.
const verbatimManual =
!dormant &&
(gates.isActiveManualMember(subBlockKey) || gates.isManualParentDependent(subBlockKey))
const detectionSkipped = dormant || verbatimManual || gates.isConditionHidden(subBlockKey)
if (dormant && isNonEmptyValue(value)) {
value = ''
}
if (definition && forkKind && subBlockType && !verbatimManualDependent) {
if (definition && forkKind && subBlockType && !verbatimManual) {
const parsed = parseWorkflowSearchSubBlockResources(value, {
type: subBlockType as SubBlockType,
})
@@ -8,6 +8,7 @@ import {
forkMappableResourceTypeSchema,
getForkDiffContract,
getWorkspaceBackgroundWorkQuerySchema,
promoteForkBodySchema,
updateForkExcludedWorkflowsBodySchema,
updateForkMappingBodySchema,
} from '@/lib/api/contracts/workspace-fork'
@@ -187,6 +188,54 @@ describe('getForkDiffContract response excluded-workflow lists', () => {
const parsed = getForkDiffContract.response.schema.parse(baseDiffResponse)
expect(parsed.excludedSourceWorkflows).toEqual([])
expect(parsed.excludedTargetWorkflows).toEqual([])
expect(parsed.triggerUrlChanges).toEqual([])
expect(parsed.triggerMappings).toEqual([])
})
it('carries every trigger, whether or not its URL is up for decision', () => {
const parsed = getForkDiffContract.response.schema.parse({
...baseDiffResponse,
triggerMappings: [
// Already serving a URL: informational, no choice offered.
{
sourceBlockId: 'blk-stable',
blockName: 'Prod intake',
workflowName: 'ITSM intake',
ownPath: 'prod-live-path',
adoptablePaths: [],
defaultAdoptPath: null,
},
// Arriving without one, with a retiring URL it can take over.
{
sourceBlockId: 'blk-new',
blockName: 'Slack messages',
workflowName: 'ITSM intake',
ownPath: null,
adoptablePaths: ['live-slack-path'],
defaultAdoptPath: 'live-slack-path',
},
],
triggerUrlChanges: [{ workflowName: 'ITSM intake', path: 'dead-path' }],
})
expect(parsed.triggerMappings[0].ownPath).toBe('prod-live-path')
expect(parsed.triggerMappings[0].adoptablePaths).toEqual([])
expect(parsed.triggerMappings[1].defaultAdoptPath).toBe('live-slack-path')
expect(parsed.triggerUrlChanges[0].path).toBe('dead-path')
})
it('accepts a trigger mapping choice on the promote body, including "new URL"', () => {
const parsed = promoteForkBodySchema.parse({
otherWorkspaceId: 'ws-other',
direction: 'push',
triggerMappings: [
{ sourceBlockId: 'blk-a', adoptPath: 'keep-this-path' },
{ sourceBlockId: 'blk-b', adoptPath: null },
],
})
expect(parsed.triggerMappings).toEqual([
{ sourceBlockId: 'blk-a', adoptPath: 'keep-this-path' },
{ sourceBlockId: 'blk-b', adoptPath: null },
])
})
it('carries the lists when present', () => {
@@ -212,6 +212,14 @@ export const forkMappingEntrySchema = z.object({
/** True when `targetId` is an unconfirmed auto-suggestion (no persisted mapping yet). */
suggested: z.boolean(),
required: z.boolean(),
/**
* True when the referenced resource no longer exists in the SOURCE workspace, so `sourceLabel`
* falls back to the raw id. Checked by exact id (never the capped candidate list), so this is
* unambiguous: a live resource always resolves its name, however many the workspace has. Such a
* reference cannot be offered for copy - there is nothing to copy - so the resolutions are
* mapping it to a live target, fixing the block in the source, or dropping it.
*/
sourceDeleted: z.boolean(),
candidates: z.array(forkMappingCandidateSchema),
/**
* True when the target workspace has more candidates of this kind than the picker
@@ -486,6 +494,53 @@ export const getForkDiffQuerySchema = z.object({
otherWorkspaceId: workspaceIdSchema,
direction: forkDirectionSchema,
})
/**
* A public trigger URL a sync would stop serving in the target. Surfaced before the overwrite is
* confirmed, because the external system calling it - a Slack Request URL, a provider webhook
* subscription - has to be repointed by hand afterwards.
*/
export const forkTriggerUrlChangeSchema = z.object({
workflowName: z.string(),
/** The path that stops being served. A URL an arriving trigger adopts is not reported here. */
path: z.string(),
})
export type ForkTriggerUrlChange = z.output<typeof forkTriggerUrlChangeSchema>
/**
* One trigger block in this sync that has a public webhook URL, or whose URL is up for decision.
*
* Both cases get an entry, not just the decisions, so the Trigger URLs section reads as a
* standing statement of each URL rather than an alert that appears only when something is wrong.
* A trigger already serving one reports it as `ownPath` and keeps it - `adoptablePaths` is empty
* and the row is informational. A trigger arriving without one lists the URLs retiring in the
* SAME target workflow; picking one hands that live URL to the new block, so the external caller
* - a Slack Request URL, a provider webhook subscription - keeps working untouched.
*
* Triggers with neither are absent, because whether a block serves a URL at all is only knowable
* from its webhook row: a schedule, chat, manual or poller trigger never gets one, and no
* declarative flag on the trigger definition separates them cleanly.
*/
export const forkTriggerMappingSchema = z.object({
/** The SOURCE block id - stable across the sync, and what a chosen mapping is keyed by. */
sourceBlockId: z.string(),
blockName: z.string(),
workflowName: z.string(),
/**
* The URL path this trigger already serves in the target, which the sync preserves verbatim.
* Null when the target block has no webhook yet, i.e. the sync decides its URL.
*/
ownPath: z.string().nullable(),
/**
* Retiring URLs in the same target workflow this block may take over instead of minting a new
* one. Always empty when `ownPath` is set: a trigger that already serves a URL keeps it, and
* offering it a second one would only be a way to abandon the first.
*/
adoptablePaths: z.array(z.string()),
/** The pre-selected pairing: unambiguous only when one URL retires and one trigger arrives. */
defaultAdoptPath: z.string().nullable(),
})
export type ForkTriggerMapping = z.output<typeof forkTriggerMappingSchema>
export const getForkDiffContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/fork/diff',
@@ -548,6 +603,13 @@ export const getForkDiffContract = defineRouteContract({
* always clear (informational).
*/
clearedRefs: z.array(forkClearedRefSchema),
/**
* Public trigger URLs this sync would stop serving in the target. Defaulted so a new client
* tolerates an old server's response during rollout.
*/
triggerUrlChanges: z.array(forkTriggerUrlChangeSchema).default([]),
/** Arriving trigger blocks whose URL this sync decides, with their adoptable alternatives. */
triggerMappings: z.array(forkTriggerMappingSchema).default([]),
}),
},
})
@@ -598,6 +660,27 @@ export const promoteForkBodySchema = z.object({
dependentValues: z.array(forkDependentValueEntrySchema).max(2000).optional(),
/** Referenced-but-unmapped resources to copy into the target before the sync gate (U17). */
copyResources: promoteCopyResourcesSchema.optional(),
/**
* References the user explicitly acknowledged dropping, so the sync may clear them in the
* target instead of blocking. Honoured ONLY for a reference whose resource no longer exists in
* the source workspace - the server re-derives that liveness inside the promote transaction and
* ignores an acknowledgment for anything still live, so a working reference can never be
* dropped and the zero-cleared-refs invariant relaxes only where the source is already broken.
*/
dropReferences: z
.array(z.object({ kind: forkRemapKindSchema, sourceId: z.string().min(1) }))
.max(2000)
.optional(),
/**
* Which retiring public URL each arriving trigger takes over, overriding the unambiguous
* default. `adoptPath: null` means "mint a new URL for this trigger". The server re-derives the
* adoptable set inside the promote transaction and ignores a path that slot did not offer, so a
* URL can never be moved between workflows (which the per-workflow path claim forbids anyway).
*/
triggerMappings: z
.array(z.object({ sourceBlockId: z.string().min(1), adoptPath: z.string().min(1).nullable() }))
.max(500)
.optional(),
})
export const promoteForkContract = defineRouteContract({
method: 'POST',
@@ -624,6 +707,20 @@ export const promoteForkContract = defineRouteContract({
needsConfiguration: z.array(forkNeedsConfigurationSchema),
/** Workflows whose optional dependent fields a swap cleared (surfaced, not gated). */
clearedOptional: z.array(forkNeedsConfigurationSchema),
/**
* Acknowledged source-deleted references this sync cleared in the target instead of
* blocking on. Only entries whose source was verified gone in-transaction appear here, so
* an acknowledgment the server refused is visibly absent.
*/
droppedReferences: z
.array(z.object({ kind: forkRemapKindSchema, sourceId: z.string() }))
.default([]),
/**
* Public trigger URLs this sync stopped serving, because no arriving trigger adopted them.
* Reported after the fact so the post-sync toast can name what needs re-registering.
* Defaulted alongside the rest, so an old server's response still parses.
*/
triggerUrlChanges: z.array(forkTriggerUrlChangeSchema).default([]),
}),
},
})
@@ -683,6 +780,10 @@ export const backgroundWorkMetadataSchema = z
needsConfiguration: z.array(forkNeedsConfigurationSchema).optional(),
/** Workflows whose optional dependent fields a sync cleared (FYI, non-blocking). */
clearedOptional: z.array(forkNeedsConfigurationSchema).optional(),
/** How many source-deleted references the operator explicitly dropped in this sync. */
droppedReferences: z.number().int().optional(),
/** How many public trigger URLs this sync stopped serving. */
triggerUrlChanges: z.number().int().optional(),
})
.nullable()
export const backgroundWorkItemSchema = z.object({
+6 -1
View File
@@ -10,7 +10,12 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
// deploy.ts pulls in the trigger/block/provider registries at module load; none are exercised by
// buildProviderConfig (a pure function), so stub them to keep this unit test fast and isolated.
vi.mock('@/blocks', () => ({ getBlock: vi.fn() }))
const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() }))
// `deploy.ts` reads the registry through `@/blocks`, while the trigger-id resolution it now
// shares (`@/triggers/webhook-url`) reads `@/blocks/registry`. Point both specifiers at ONE spy
// so a test configuring the block config governs the whole path, not half of it.
vi.mock('@/blocks', () => ({ getBlock: mockGetBlock }))
vi.mock('@/blocks/registry', () => ({ getBlock: mockGetBlock }))
vi.mock('@/triggers', () => ({ getTrigger: vi.fn(), isTriggerValid: vi.fn(() => true) }))
vi.mock('@/lib/webhooks/providers', () => ({ getProviderHandler: vi.fn() }))
vi.mock('@/lib/webhooks/provider-subscriptions', () => ({
+3 -40
View File
@@ -32,12 +32,12 @@ import {
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared'
import { resolveBlockTriggerId } from '@/triggers/webhook-url'
const logger = createLogger('DeployWebhookSync')
@@ -75,7 +75,7 @@ export async function validateTriggerWebhookConfigForDeploy(
const triggerBlocks = Object.values(blocks || {}).filter((b) => b && b.enabled !== false)
for (const block of triggerBlocks) {
const triggerId = resolveTriggerId(block)
const triggerId = resolveBlockTriggerId(block)
if (!triggerId || !isTriggerValid(triggerId)) continue
const triggerDef = getTrigger(triggerId)
@@ -172,43 +172,6 @@ function isFieldRequired(
return evalCond(condition, subBlockValues)
}
function resolveTriggerId(block: BlockState): string | undefined {
const blockConfig = getBlock(block.type)
if (blockConfig?.category === 'triggers' && isTriggerValid(block.type)) {
return block.type
}
if (!block.triggerMode) {
return undefined
}
const selectedTriggerId = getSubBlockValue(block, 'selectedTriggerId')
if (typeof selectedTriggerId === 'string' && isTriggerValid(selectedTriggerId)) {
return selectedTriggerId
}
const storedTriggerId = getSubBlockValue(block, 'triggerId')
if (typeof storedTriggerId === 'string' && isTriggerValid(storedTriggerId)) {
return storedTriggerId
}
if (blockConfig?.triggers?.enabled) {
const configuredTriggerId =
typeof selectedTriggerId === 'string' ? selectedTriggerId : undefined
if (configuredTriggerId && isTriggerValid(configuredTriggerId)) {
return configuredTriggerId
}
const available = blockConfig.triggers?.available?.[0]
if (available && isTriggerValid(available)) {
return available
}
}
return undefined
}
function getConfigValue(block: BlockState, subBlock: SubBlockConfig): unknown {
const fieldValue = getSubBlockValue(block, subBlock.id)
@@ -372,7 +335,7 @@ export async function resolveWebhookConfigForBlock(input: {
userId: string
requestId: string
}): Promise<ResolveWebhookConfigResult | null> {
const triggerId = resolveTriggerId(input.block)
const triggerId = resolveBlockTriggerId(input.block)
if (!triggerId || !isTriggerValid(triggerId)) return null
const triggerDef = getTrigger(triggerId)
@@ -0,0 +1,96 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EXPORT_PRESERVED_RESOURCE_TYPES,
sanitizeForExport,
} from '@/lib/workflows/credentials/credential-extractor'
import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry'
import { getBlock } from '@/blocks/registry'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
function stateWithSubBlock(type: string, value: unknown): Partial<WorkflowState> {
return {
blocks: {
b1: {
id: 'b1',
type: 'test-block',
name: 'Test',
position: { x: 0, y: 0 },
subBlocks: { field: { id: 'field', type, value } },
outputs: {},
enabled: true,
},
},
} as unknown as Partial<WorkflowState>
}
function sanitizedValue(type: string, value: unknown): unknown {
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type }],
outputs: {},
} as never)
const sanitized = sanitizeForExport(stateWithSubBlock(type, value))
return sanitized.blocks?.b1?.subBlocks?.field?.value
}
describe('export sanitizer resource coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
/**
* The drift guard. Adding a selector to the resource registry without deciding how export
* should treat it fails here rather than silently shipping a workspace-scoped id to another
* workspace — which is exactly how raw `tbl_…` table ids used to escape.
*/
it.each(
WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES.filter(
(type) => !EXPORT_PRESERVED_RESOURCE_TYPES.has(type)
)
)('clears %s on export', (type) => {
expect(sanitizedValue(type, 'res-id-123')).toBeNull()
})
it('clears a table-selector id, the omission that leaked ids across workspaces', () => {
expect(sanitizedValue('table-selector', 'tbl_239e870374c14d4a89923175a7b10648')).toBeNull()
})
/**
* Nothing on the import path remaps workflow references — `import-export.ts` extracts each
* workflow independently under a fresh id — so a preserved reference names a workflow that does
* not exist in the target, bundle or not.
*/
it('clears workflow-selector, since import never remaps the id it names', () => {
expect(sanitizedValue('workflow-selector', 'wf-123')).toBeNull()
})
it('still clears oauth-input, via the credential rule rather than the workspace rule', () => {
expect(sanitizedValue('oauth-input', 'cred-123')).toBeNull()
})
it('leaves an ordinary field untouched', () => {
expect(sanitizedValue('short-input', 'plain text')).toBe('plain text')
})
it('clears tableId by key on a block with no registry config', () => {
vi.mocked(getBlock).mockReturnValue(undefined as never)
const sanitized = sanitizeForExport({
blocks: {
b1: {
id: 'b1',
type: 'unknown-block',
name: 'Test',
position: { x: 0, y: 0 },
subBlocks: { tableId: { id: 'tableId', type: 'short-input', value: 'tbl_abc' } },
outputs: {},
enabled: true,
},
},
} as unknown as Partial<WorkflowState>)
expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull()
})
})
@@ -1,3 +1,4 @@
import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry'
import {
buildCanonicalIndex,
buildSubBlockValues,
@@ -28,27 +29,49 @@ export interface CredentialRequirement {
required: boolean
}
// Workspace-specific subblock types that should be cleared
const WORKSPACE_SPECIFIC_TYPES = new Set([
'knowledge-base-selector',
'knowledge-tag-filters',
'document-selector',
'document-tag-entry',
'file-selector', // Workspace files
'file-upload', // Uploaded files in workspace
'project-selector', // Workspace-specific projects
'channel-selector', // Workspace-specific channels
'folder-selector', // User-specific folders
'mcp-server-selector', // User-specific MCP servers
/**
* Resource-selector types NOT cleared by the workspace rule below. Everything else the resource
* registry knows about IS cleared, so the two lists can never drift apart again — the previous
* hand-written copy had silently omitted `table-selector`, `mcp-tool-selector`, `user-selector`
* and `sheet-selector`, which is how raw `tbl_…` ids reached other workspaces through an export.
*
* Every id in an export is workspace-scoped, and nothing on the import path remaps them:
* `import-export.ts` extracts each workflow independently and assigns it a fresh id, so a
* preserved reference points at a workflow that does not exist in the target — including inside a
* multi-workflow bundle, where the sibling it named was itself re-created under a new id. Clearing
* is therefore the only correct treatment for every id-bearing selector.
*/
export const EXPORT_PRESERVED_RESOURCE_TYPES: ReadonlySet<string> = new Set([
// Cleared by the dedicated `oauth-input` branch in `sanitizeWorkflowForSharing`, so excluding it
// here only avoids clearing it twice - it never survives an export.
'oauth-input',
])
// Field IDs that are workspace-specific
/**
* Sub-block types holding a reference scoped to this workspace, or to a credential that is itself
* cleared on export. Derived from the canonical resource registry plus the name/slot-based
* knowledge fields, which carry no resource id and therefore no registry entry.
*/
const WORKSPACE_SPECIFIC_TYPES: ReadonlySet<string> = new Set<string>([
...WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES.filter(
(type) => !EXPORT_PRESERVED_RESOURCE_TYPES.has(type)
),
'knowledge-tag-filters',
'document-tag-entry',
])
/**
* Field IDs that are workspace-specific, for the fallback pass over blocks with no registry
* config (and over legacy `block.data`). Keyed by sub-block / canonical param id, which the
* type-keyed registry above cannot supply, so this list stays explicit.
*/
const WORKSPACE_SPECIFIC_FIELDS = new Set([
'knowledgeBaseId',
'tagFilters',
'documentTags',
'documentId',
'fileId',
'tableId',
'projectId',
'channelId',
'folderId',
@@ -166,6 +166,11 @@ describe('duplicateWorkflow ordering', () => {
subBlocks: {
triggerPath: { id: 'triggerPath', type: 'short-input', value: 'old-webhook-path' },
webhookId: { id: 'webhookId', type: 'short-input', value: 'old-webhook-id' },
triggerConfig: {
id: 'triggerConfig',
type: 'trigger-config',
value: { tableSelector: 'tbl_stale' },
},
webhookUrlDisplay: {
id: 'webhookUrlDisplay',
type: 'short-input',
@@ -217,6 +222,10 @@ describe('duplicateWorkflow ordering', () => {
expect(copiedSubBlocks.triggerPath).toBeUndefined()
expect(copiedSubBlocks.webhookId).toBeUndefined()
expect(copiedSubBlocks.webhookUrlDisplay).toBeUndefined()
// The aggregate must not ride along: `populateTriggerFieldsFromConfig` re-seeds any empty
// trigger field from it on load, so carrying it would resurrect the source's resource ids in
// the copy right after the remapper cleared or remapped them.
expect(copiedSubBlocks.triggerConfig).toBeUndefined()
expect(copiedSubBlocks.variables.value[0].variableId).not.toBe('old-var-id')
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
expect(insertedBlocks?.[0].locked).toBe(false)
@@ -2,14 +2,11 @@ import { isRecordLike, sortObjectKeysDeep } from '@sim/utils/object'
import type { Edge } from 'reactflow'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor'
import {
buildSubBlockValues,
evaluateSubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
import { blockAdvertisesWebhookUrl } from '@/triggers/webhook-url'
/**
* Sanitized workflow state for copilot (removes all UI-specific data)
@@ -345,21 +342,7 @@ function sanitizeSubBlocks(
* read time, never stored, and rejected on write by `edit_workflow` validation.
*/
function resolveTriggerWebhookUrl(blockId: string, block: BlockState): string | null {
const blockConfig = getBlock(block.type)
if (!blockConfig) return null
const actsAsTrigger = blockConfig.category === 'triggers' || block.triggerMode === true
if (!actsAsTrigger) return null
// A webhook-URL display subblock (`useWebhookUrl`) marks a webhook-based trigger.
// Multi-trigger blocks namespace one per trigger id, each gated by a condition on
// selectedTriggerId — only count a field active for the current values, so a block
// configured with a polling trigger doesn't advertise a webhook URL.
const values = buildSubBlockValues(block.subBlocks || {})
const hasActiveWebhookUrlField = blockConfig.subBlocks.some(
(sb) => sb.useWebhookUrl === true && evaluateSubBlockCondition(sb.condition, values)
)
if (!hasActiveWebhookUrlField) return null
if (!blockAdvertisesWebhookUrl(block)) return null
const triggerPath = block.subBlocks?.triggerPath?.value
const path = typeof triggerPath === 'string' && triggerPath.length > 0 ? triggerPath : blockId
@@ -0,0 +1,72 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
getWorkflowSearchSubBlockResourceDefinition,
parseWorkflowSearchSubBlockResources,
workflowSearchResourceValueContains,
} from '@/lib/workflows/search-replace/resources/registry'
const TABLE_SUB_BLOCK = { type: 'table-selector' } as const
function replaceTableValue(value: unknown, rawValue: string, replacement: string) {
const definition = getWorkflowSearchSubBlockResourceDefinition(TABLE_SUB_BLOCK)
if (!definition) throw new Error('table-selector is not a registered resource selector')
return definition.codec.replace(value, rawValue, replacement)
}
/**
* `parse` and `contains` split on commas and trim, so a value carrying stray whitespace is
* detected as a reference. `replace` must agree, or the reference becomes permanently stuck:
* it is reported as needing a mapping, yet neither remapping nor clearing can ever touch it.
*/
describe('scalar resource codec whitespace handling', () => {
const padded = ' tbl_239e870374c14d4a89923175a7b10648 '
const rawValue = 'tbl_239e870374c14d4a89923175a7b10648'
it('detects a padded single value as a reference', () => {
const parsed = parseWorkflowSearchSubBlockResources(padded, TABLE_SUB_BLOCK)
expect(parsed.map((reference) => reference.rawValue)).toEqual([rawValue])
expect(
workflowSearchResourceValueContains(
{ subBlockType: 'table-selector', rawValue } as Parameters<
typeof workflowSearchResourceValueContains
>[0],
padded
)
).toBe(true)
})
it('remaps a padded single value to its target', () => {
expect(replaceTableValue(padded, rawValue, 'tbl_target')).toEqual({
success: true,
nextValue: 'tbl_target',
})
})
it('clears a padded single value when the reference is unresolved', () => {
expect(replaceTableValue(padded, rawValue, '')).toEqual({ success: true, nextValue: '' })
})
it('leaves a non-matching single value untouched', () => {
expect(replaceTableValue(' tbl_other ', rawValue, 'tbl_target')).toEqual({
success: true,
nextValue: ' tbl_other ',
})
})
it('still remaps an unpadded single value', () => {
expect(replaceTableValue(rawValue, rawValue, 'tbl_target')).toEqual({
success: true,
nextValue: 'tbl_target',
})
})
it('still remaps one entry of a padded multi-value list', () => {
expect(replaceTableValue(` ${rawValue} , tbl_other `, rawValue, 'tbl_target')).toEqual({
success: true,
nextValue: 'tbl_target,tbl_other',
})
})
})
@@ -135,7 +135,10 @@ function replaceCommaResourceValue(
}
return { success: true, nextValue }
}
const nextValue = shouldReplace(value) ? replacement : value
// Compare the TRIMMED token, matching what `parse` and `contains` produced. Comparing the
// raw string made a padded single value (`" tbl_abc"`) unmatchable here even though it was
// detected as a reference, so it could never be remapped nor cleared and stuck forever.
const nextValue = shouldReplace(parts[0]) ? replacement : value
if (targetOccurrenceIndex !== undefined && !replaced) {
return { success: false, reason: 'Target resource changed since search' }
}
@@ -353,6 +356,16 @@ const WORKFLOW_SEARCH_SUBBLOCK_RESOURCES: Partial<
'project-selector': { kind: 'selector-resource', codec: scalarResourceCodec },
}
/**
* Every sub-block type that carries a resource reference. This registry is the single source of
* truth for "does this field hold an id scoped to a workspace or a credential", so consumers that
* need that answer derive it from here rather than keeping a parallel hand-written list — see
* `sanitizeWorkflowForSharing`, whose hand-maintained copy had silently omitted `table-selector`.
*/
export const WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES = Object.keys(
WORKFLOW_SEARCH_SUBBLOCK_RESOURCES
) as SubBlockType[]
export function getWorkflowSearchResourceKindDefinition(
kind: WorkflowSearchMatchKind
): WorkflowSearchResourceKindDefinition | null {
+161
View File
@@ -0,0 +1,161 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getBlock } from '@/blocks/registry'
import type { BlockState } from '@/stores/workflows/workflow/types'
import {
INTERNAL_TRIGGER_PROVIDERS,
isInternalTriggerProvider,
isPollingWebhookProvider,
POLLING_PROVIDERS,
} from '@/triggers/constants'
import { TRIGGER_REGISTRY } from '@/triggers/registry'
import { blockAdvertisesWebhookUrl } from '@/triggers/webhook-url'
function block(overrides: Partial<BlockState> = {}): BlockState {
return {
id: 'blk',
type: 'slack',
name: 'Slack',
subBlocks: {},
outputs: {},
enabled: true,
...overrides,
} as unknown as BlockState
}
describe('blockAdvertisesWebhookUrl', () => {
beforeEach(() => vi.clearAllMocks())
it('is true for a trigger block with an unconditional webhook-URL field', () => {
vi.mocked(getBlock).mockReturnValue({
category: 'triggers',
subBlocks: [{ id: 'triggerWebhookUrl', useWebhookUrl: true }],
} as never)
expect(blockAdvertisesWebhookUrl(block())).toBe(true)
})
it('is false for a trigger block that declares no webhook-URL field (poller, schedule, chat)', () => {
vi.mocked(getBlock).mockReturnValue({
category: 'triggers',
subBlocks: [{ id: 'cron' }],
} as never)
expect(blockAdvertisesWebhookUrl(block())).toBe(false)
})
it('is false for a non-trigger block, even one whose config declares a URL field', () => {
vi.mocked(getBlock).mockReturnValue({
category: 'blocks',
subBlocks: [{ id: 'triggerWebhookUrl', useWebhookUrl: true }],
} as never)
expect(blockAdvertisesWebhookUrl(block())).toBe(false)
})
it('is true for a tool block flipped into trigger mode', () => {
vi.mocked(getBlock).mockReturnValue({
category: 'blocks',
subBlocks: [{ id: 'triggerWebhookUrl', useWebhookUrl: true }],
} as never)
expect(blockAdvertisesWebhookUrl(block({ triggerMode: true } as never))).toBe(true)
})
/**
* The case a trigger-definition flag cannot express: one block hosts several triggers, and only
* some of them serve a URL. Reading the ACTIVE condition is what keeps a block currently set to
* the polling trigger from claiming a URL its webhook sibling would have.
*/
it('honours the selectedTriggerId condition on a multi-trigger block', () => {
vi.mocked(getBlock).mockReturnValue({
category: 'triggers',
subBlocks: [
{
id: 'webhookUrl',
useWebhookUrl: true,
condition: { field: 'selectedTriggerId', value: 'service_webhook' },
},
],
} as never)
const pollingBlock = block({
subBlocks: { selectedTriggerId: { id: 'selectedTriggerId', value: 'service_poller' } },
} as never)
const webhookBlock = block({
subBlocks: { selectedTriggerId: { id: 'selectedTriggerId', value: 'service_webhook' } },
} as never)
expect(blockAdvertisesWebhookUrl(pollingBlock)).toBe(false)
expect(blockAdvertisesWebhookUrl(webhookBlock)).toBe(true)
})
it('is false when the block type is not in the registry', () => {
vi.mocked(getBlock).mockReturnValue(undefined as never)
expect(blockAdvertisesWebhookUrl(block())).toBe(false)
})
})
/**
* The two provider registries and the per-subblock `useWebhookUrl` marker must agree on which
* triggers serve a public URL. `POLLING_PROVIDERS` is already pinned against `polling: true` in
* `constants.test.ts`; this closes the remaining gap - a trigger whose events Sim pulls, or whose
* path the public route rejects, must never also advertise a URL to paste into a provider console.
*/
describe('provider registries agree with the webhook-URL marker', () => {
it('no polling or internal trigger declares a webhook-URL sub-block', () => {
const offenders = Object.values(TRIGGER_REGISTRY)
.filter(
(trigger) =>
isPollingWebhookProvider(trigger.provider) || isInternalTriggerProvider(trigger.provider)
)
.filter((trigger) => trigger.subBlocks.some((subBlock) => subBlock.useWebhookUrl === true))
.map((trigger) => `${trigger.id} (provider: ${trigger.provider})`)
expect(
offenders,
'A polling/internal trigger advertising a webhook URL would offer a path nothing external can call'
).toEqual([])
})
it('keeps both registries non-empty, so neither guard can pass vacuously', () => {
expect(POLLING_PROVIDERS.size).toBeGreaterThan(0)
expect(INTERNAL_TRIGGER_PROVIDERS.size).toBeGreaterThan(0)
})
})
/**
* Slack ships BOTH delivery families, so it is the sharpest test of the marker - and the trigger
* the fork sync's URL preservation exists for. `slack_webhook` is path-based and its URL is what
* a user pastes into a Slack app's Request URL; `slack_oauth` arrives on a shared endpoint routed
* by `routingKey`, so `lib/webhooks/deploy.ts` nulls its path and there is no URL to preserve.
*
* The block configs spread these exact arrays (`blocks/blocks/slack.ts` `...getTrigger(...)
* .subBlocks`), so asserting on the trigger definitions is asserting on what the predicate reads.
*/
describe('Slack: both delivery families classify correctly', () => {
function slackBlock(triggerId: 'slack_webhook' | 'slack_oauth'): BlockState {
vi.mocked(getBlock).mockReturnValue({
category: 'triggers',
subBlocks: TRIGGER_REGISTRY[triggerId].subBlocks,
} as never)
return block({ type: triggerId === 'slack_webhook' ? 'slack' : 'slack_v2' })
}
it('slack_webhook advertises a URL, so the fork sync can preserve it', () => {
expect(blockAdvertisesWebhookUrl(slackBlock('slack_webhook'))).toBe(true)
})
it('slack_oauth does NOT, so it is never offered a URL it cannot serve', () => {
expect(blockAdvertisesWebhookUrl(slackBlock('slack_oauth'))).toBe(false)
})
/**
* The URL field must stay UNCONDITIONAL on the single-trigger Slack block. A `selectedTriggerId`
* condition would evaluate false there (no dropdown ⇒ no value), silently dropping Slack from
* the Trigger URLs section - the one trigger this feature was built for.
*/
it('slack_webhook gates its URL field on nothing', () => {
const urlField = TRIGGER_REGISTRY.slack_webhook.subBlocks.find(
(subBlock) => subBlock.useWebhookUrl === true
)
expect(urlField).toBeDefined()
expect(urlField?.condition).toBeUndefined()
})
})
+110
View File
@@ -0,0 +1,110 @@
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
buildSubBlockValues,
evaluateSubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTrigger, isTriggerValid } from '@/triggers'
/** The public URL an external system POSTs to for a given webhook path. */
export function buildWebhookTriggerUrl(path: string): string {
return `${getBaseUrl()}/api/webhooks/trigger/${path}`
}
function subBlockValue(block: BlockState, subBlockId: string): unknown {
return block.subBlocks?.[subBlockId]?.value
}
/**
* The trigger a block deploys as, or undefined when it is not acting as one.
*
* A dedicated trigger block IS its trigger; a tool block flipped into trigger mode names one via
* `selectedTriggerId` / `triggerId`, falling back to the first trigger its config declares
* available. Single-sourced here because the webhook deploy path and anything reasoning about a
* block's delivery must agree on the answer - two resolutions would let a block deploy as one
* trigger while another layer classified it as a different one.
*/
export function resolveBlockTriggerId(block: BlockState): string | undefined {
const blockConfig = getBlock(block.type)
if (blockConfig?.category === 'triggers' && isTriggerValid(block.type)) {
return block.type
}
if (!block.triggerMode) {
return undefined
}
const selectedTriggerId = subBlockValue(block, 'selectedTriggerId')
if (typeof selectedTriggerId === 'string' && isTriggerValid(selectedTriggerId)) {
return selectedTriggerId
}
const storedTriggerId = subBlockValue(block, 'triggerId')
if (typeof storedTriggerId === 'string' && isTriggerValid(storedTriggerId)) {
return storedTriggerId
}
if (blockConfig?.triggers?.enabled) {
const configuredTriggerId =
typeof selectedTriggerId === 'string' ? selectedTriggerId : undefined
if (configuredTriggerId && isTriggerValid(configuredTriggerId)) {
return configuredTriggerId
}
const available = blockConfig.triggers?.available?.[0]
if (available && isTriggerValid(available)) {
return available
}
}
return undefined
}
/**
* The webhook provider a block's events arrive under, or null when it is not a trigger.
*
* This is the identity an inbound request is verified against: a path served by a `slack` webhook
* authenticates Slack's signature and parses Slack's event shape. Two triggers of DIFFERENT
* providers can therefore never share a URL meaningfully, however similar they look.
*/
export function resolveBlockTriggerProvider(block: BlockState): string | null {
const triggerId = resolveBlockTriggerId(block)
if (!triggerId || !isTriggerValid(triggerId)) return null
return getTrigger(triggerId).provider ?? null
}
/**
* Whether this block advertises a public webhook URL - one an external system POSTs to at
* `/api/webhooks/trigger/<path>`.
*
* The marker is the `useWebhookUrl` sub-block: the field that renders the copyable URL in the
* block's own config. If the UI shows a URL for a block, that is a URL someone could have pasted
* into Slack or a provider console; if it does not, there is nothing external pointing at it.
* That makes this the right question for anything reasoning about "would changing this break a
* caller" - which is why the copilot's read view and the fork sync both ask it here rather than
* re-deriving it.
*
* Deliberately NOT derived from the trigger definition. Neither declarative flag separates the
* families cleanly: `polling` is set on 8 of the trigger defs while several pollers omit it, and
* `webhook` is set on ~345 including `slack_oauth`, which routes by `routingKey` on a shared
* endpoint and has no per-workflow URL at all.
*
* Condition-aware on purpose: a multi-trigger block namespaces one URL field per trigger id, each
* gated on `selectedTriggerId`, so a block currently configured with a POLLING trigger correctly
* reports false even though its config declares a URL field for a sibling trigger.
*/
export function blockAdvertisesWebhookUrl(block: BlockState): boolean {
const blockConfig = getBlock(block.type)
if (!blockConfig) return false
const actsAsTrigger = blockConfig.category === 'triggers' || block.triggerMode === true
if (!actsAsTrigger) return false
const values = buildSubBlockValues(block.subBlocks || {})
return blockConfig.subBlocks.some(
(subBlock) =>
subBlock.useWebhookUrl === true && evaluateSubBlockCondition(subBlock.condition, values)
)
}