Skip to content

Commit 3b4c689

Browse files
committed
improve sync preview
1 parent 1391ca1 commit 3b4c689

8 files changed

Lines changed: 565 additions & 1281 deletions

File tree

apps/sim/app/api/workspaces/[id]/fork/diff/route.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import { parseRequest } from '@/lib/api/server'
77
import { getSession } from '@/lib/auth'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows'
10-
import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
10+
import {
11+
listForkExcludedDeployedWorkflows,
12+
loadSourceDeployedStates,
13+
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
1114
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
1215
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
1316
import {
@@ -73,17 +76,24 @@ export const GET = withRouteHandler(
7376
.filter((item) => item.mode === 'replace')
7477
.map((item) => item.targetWorkflowId)
7578
const allTargetIds = plan.items.map((item) => item.targetWorkflowId)
76-
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
77-
await Promise.all([
78-
loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
79-
loadTargetDraftSubBlocks(db, replaceTargetIds),
80-
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
81-
listForkResourceCandidates(db, auth.sourceWorkspaceId),
82-
db
83-
.select({ id: workflow.id, name: workflow.name })
84-
.from(workflow)
85-
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
86-
])
79+
const [
80+
storedValues,
81+
targetDraftByWorkflow,
82+
sourceCandidates,
83+
sourceWorkflowRows,
84+
excludedSourceWorkflows,
85+
] = await Promise.all([
86+
loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
87+
loadTargetDraftSubBlocks(db, replaceTargetIds),
88+
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
89+
listForkResourceCandidates(db, auth.sourceWorkspaceId),
90+
db
91+
.select({ id: workflow.id, name: workflow.name })
92+
.from(workflow)
93+
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
94+
// Deployed-but-excluded source workflows, so the preview can show what a sync skips.
95+
listForkExcludedDeployedWorkflows(db, auth.sourceWorkspaceId),
96+
])
8797
const storedByKey = new Map(
8898
storedValues.map((entry) => [
8999
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
@@ -204,6 +214,8 @@ export const GET = withRouteHandler(
204214
willCreate: plan.willCreate,
205215
willArchive: plan.willArchive,
206216
workflows,
217+
excludedSourceWorkflows: excludedSourceWorkflows.map((w) => w.name),
218+
excludedTargetWorkflows: plan.excludedTargets.map((t) => t.name),
207219
unmappedRequired: plan.unmappedRequired.map(toRef),
208220
unmappedOptional: plan.unmappedOptional.map(toRef),
209221
mcpReauthServerIds: plan.mcpReauthServerIds,

apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
cn,
1111
FieldDivider,
1212
Label,
13+
Tooltip,
1314
} from '@sim/emcn'
1415
import { ArrowRight } from 'lucide-react'
1516
import type {
@@ -575,6 +576,23 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
575576
const detailsError = controller.errorMessage ?? controller.diffErrorMessage
576577
const headsUp = controller.mcpReauthCount > 0 || controller.inlineSecretCount > 0
577578

579+
// Excluded workflows render greyed in the change list. Orient each name's tooltip
580+
// to WHERE it is excluded (that's the only place it can be re-included): the sync's
581+
// source is this workspace on push and the other workspace on pull.
582+
const excludedRows = [
583+
...(controller.direction === 'push'
584+
? controller.excludedSourceWorkflows
585+
: controller.excludedTargetWorkflows
586+
).map((name) => ({ name, tooltip: 'Excluded from sync' })),
587+
...(controller.direction === 'push'
588+
? controller.excludedTargetWorkflows
589+
: controller.excludedSourceWorkflows
590+
).map((name) => ({
591+
name,
592+
tooltip: `Excluded from sync in "${controller.otherWorkspaceName}"`,
593+
})),
594+
]
595+
578596
return (
579597
<div className='flex flex-col gap-7'>
580598
<SettingsSection label='Sync direction'>
@@ -607,33 +625,51 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
607625

608626
{/* Always shown once the diff loads so the user sees the section even with nothing
609627
deployed - an empty change list means the source has no deployed workflows (every
610-
deployed workflow appears here, changed or not), so the muted state nudges a deploy. */}
628+
deployed workflow appears here, changed or not), so the muted state nudges a deploy.
629+
Sync-excluded workflows list greyed at the end, with a tooltip naming where the
630+
exclusion lives - the sync will not touch them. */}
611631
{controller.hasDiff ? (
612632
<SettingsSection label='Deployed workflows'>
613-
{controller.workflowChanges.length > 0 ? (
614-
<div className='flex flex-col gap-1'>
615-
{controller.workflowChanges.map((change, index) => {
616-
const renamed = change.currentName !== change.otherName
617-
return (
618-
<div
619-
key={`${change.action}:${change.currentName}:${index}`}
620-
className='flex min-w-0 items-center gap-1.5'
621-
>
622-
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
623-
{change.currentName}
624-
</span>
625-
{renamed ? (
626-
<>
627-
<ArrowRight className='size-3 shrink-0 text-[var(--text-icon)]' />
628-
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm'>
629-
{change.otherName}
633+
{controller.workflowChanges.length + excludedRows.length > 0 ? (
634+
<Tooltip.Provider delayDuration={150}>
635+
<div className='flex flex-col gap-1'>
636+
{controller.workflowChanges.map((change, index) => {
637+
const renamed = change.currentName !== change.otherName
638+
return (
639+
<div
640+
key={`${change.action}:${change.currentName}:${index}`}
641+
className='flex min-w-0 items-center gap-1.5'
642+
>
643+
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
644+
{change.currentName}
645+
</span>
646+
{renamed ? (
647+
<>
648+
<ArrowRight className='size-3 shrink-0 text-[var(--text-icon)]' />
649+
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm'>
650+
{change.otherName}
651+
</span>
652+
</>
653+
) : null}
654+
</div>
655+
)
656+
})}
657+
{excludedRows.map(({ name, tooltip }, index) => (
658+
<div key={`excluded:${name}:${index}`} className='flex min-w-0 items-center'>
659+
<Tooltip.Root>
660+
<Tooltip.Trigger asChild>
661+
<span className='min-w-0 max-w-full truncate text-[var(--text-muted)] text-sm'>
662+
{name}
630663
</span>
631-
</>
632-
) : null}
664+
</Tooltip.Trigger>
665+
<Tooltip.Content side='top' className='text-small'>
666+
{tooltip}
667+
</Tooltip.Content>
668+
</Tooltip.Root>
633669
</div>
634-
)
635-
})}
636-
</div>
670+
))}
671+
</div>
672+
</Tooltip.Provider>
637673
) : (
638674
<div className='text-[var(--text-muted)] text-small'>
639675
{controller.direction === 'push'

apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ export interface ForkSyncController {
152152
workflowChanges: ForkWorkflowChange[]
153153
/** Names of target workflows this sync archives, for the confirm modal. */
154154
archivedWorkflowNames: string[]
155+
/** Names of deployed SOURCE workflows marked "Exclude from sync" - never sent. */
156+
excludedSourceWorkflows: string[]
157+
/** Names of mapped TARGET workflows marked "Exclude from sync" - never replaced or archived. */
158+
excludedTargetWorkflows: string[]
155159
mcpReauthCount: number
156160
inlineSecretCount: number
157161
dirty: boolean
@@ -796,6 +800,8 @@ export function useForkSync(params: {
796800
dependentClears,
797801
workflowChanges,
798802
archivedWorkflowNames,
803+
excludedSourceWorkflows: diff.data?.excludedSourceWorkflows ?? [],
804+
excludedTargetWorkflows: diff.data?.excludedTargetWorkflows ?? [],
799805
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,
800806
inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0,
801807
dirty,

apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,40 @@ export async function listDeployedWorkflows(
7777
)
7878
}
7979

80+
/**
81+
* The complement of {@link listDeployedWorkflows}'s exclusion predicate: deployed,
82+
* non-archived workflows the workspace admin marked "Exclude from sync". Only the
83+
* diff preview reads this, to show which workflows a sync deliberately skips;
84+
* the promote itself never touches them.
85+
*/
86+
export async function listForkExcludedDeployedWorkflows(
87+
executor: DbOrTx,
88+
workspaceId: string
89+
): Promise<Array<{ id: string; name: string }>> {
90+
return executor
91+
.select({ id: workflow.id, name: workflow.name })
92+
.from(workflow)
93+
.where(
94+
and(
95+
eq(workflow.workspaceId, workspaceId),
96+
eq(workflow.isDeployed, true),
97+
eq(workflow.forkSyncExcluded, true),
98+
isNull(workflow.archivedAt),
99+
exists(
100+
db
101+
.select({ one: sql`1` })
102+
.from(workflowDeploymentVersion)
103+
.where(
104+
and(
105+
eq(workflowDeploymentVersion.workflowId, workflow.id),
106+
eq(workflowDeploymentVersion.isActive, true)
107+
)
108+
)
109+
)
110+
)
111+
)
112+
}
113+
80114
/** The active deployment version number for a workflow, or null when it has none. */
81115
export async function getActiveDeploymentVersionNumber(
82116
executor: DbOrTx,

apps/sim/lib/api/contracts/workspace-fork.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
forkLineageChildSchema,
77
forkLineageNodeSchema,
88
forkMappableResourceTypeSchema,
9+
getForkDiffContract,
910
getWorkspaceBackgroundWorkQuerySchema,
1011
updateForkExcludedWorkflowsBodySchema,
1112
updateForkMappingBodySchema,
@@ -163,3 +164,38 @@ describe('updateForkExcludedWorkflowsBodySchema', () => {
163164
)
164165
})
165166
})
167+
168+
describe('getForkDiffContract response excluded-workflow lists', () => {
169+
const baseDiffResponse = {
170+
sourceWorkspaceId: 'ws-src',
171+
targetWorkspaceId: 'ws-tgt',
172+
willUpdate: 0,
173+
willCreate: 0,
174+
willArchive: 0,
175+
workflows: [],
176+
unmappedRequired: [],
177+
unmappedOptional: [],
178+
mcpReauthServerIds: [],
179+
inlineSecretSources: [],
180+
dependentReconfigs: [],
181+
resourceUsages: [],
182+
copyableUnmapped: [],
183+
clearedRefs: [],
184+
}
185+
186+
it('defaults absent lists to empty (old-server tolerance)', () => {
187+
const parsed = getForkDiffContract.response.schema.parse(baseDiffResponse)
188+
expect(parsed.excludedSourceWorkflows).toEqual([])
189+
expect(parsed.excludedTargetWorkflows).toEqual([])
190+
})
191+
192+
it('carries the lists when present', () => {
193+
const parsed = getForkDiffContract.response.schema.parse({
194+
...baseDiffResponse,
195+
excludedSourceWorkflows: ['Scratch agent'],
196+
excludedTargetWorkflows: ['Prod hotfix'],
197+
})
198+
expect(parsed.excludedSourceWorkflows).toEqual(['Scratch agent'])
199+
expect(parsed.excludedTargetWorkflows).toEqual(['Prod hotfix'])
200+
})
201+
})

apps/sim/lib/api/contracts/workspace-fork.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,16 @@ export const getForkDiffContract = defineRouteContract({
501501
willArchive: z.number().int(),
502502
/** Per-workflow change list for the sync preview. */
503503
workflows: z.array(forkWorkflowChangeSchema),
504+
/**
505+
* Names of deployed SOURCE workflows marked "Exclude from sync" - never sent.
506+
* Defaulted so a new client tolerates an old server's response during rollout.
507+
*/
508+
excludedSourceWorkflows: z.array(z.string()).default([]),
509+
/**
510+
* Names of mapped TARGET workflows marked "Exclude from sync" - the sync
511+
* neither replaces nor archives them. Defaulted for rollout tolerance.
512+
*/
513+
excludedTargetWorkflows: z.array(z.string()).default([]),
504514
unmappedRequired: z.array(forkUnmappedReferenceSchema),
505515
unmappedOptional: z.array(forkUnmappedReferenceSchema),
506516
/** Source MCP server ids that use OAuth and need re-authorization in the target. */

0 commit comments

Comments
 (0)