Skip to content

Commit 405cc6a

Browse files
feat(forks): compare last synced source deployments (#8586)
* feat(forks): compare last synced source deployments * fix(workflows): simplify comparison headings and badges * fix(forks): track admitted deployment outcomes accurately
1 parent 19abda3 commit 405cc6a

49 files changed

Lines changed: 31187 additions & 234 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14176,6 +14176,97 @@
1417614176
"type": "string",
1417714177
"maxLength": 1024,
1417814178
"description": "Display name of the workflow or workspace."
14179+
},
14180+
"comparison": {
14181+
"description": "Last successfully synced source deployment compared with the source deployment pinned by this preview. Absent for archive actions.",
14182+
"oneOf": [
14183+
{
14184+
"type": "object",
14185+
"properties": {
14186+
"status": {
14187+
"type": "string",
14188+
"const": "available",
14189+
"description": "Both exact source snapshots are available."
14190+
},
14191+
"base": {
14192+
"type": "object",
14193+
"properties": {
14194+
"id": {
14195+
"type": "string",
14196+
"minLength": 1,
14197+
"description": "Exact source deployment snapshot identifier."
14198+
},
14199+
"version": {
14200+
"type": "integer",
14201+
"minimum": 1,
14202+
"maximum": 2147483647,
14203+
"description": "Saved version number in the source workflow."
14204+
}
14205+
},
14206+
"required": ["id", "version"],
14207+
"additionalProperties": false,
14208+
"description": "Source deployment last successfully synced to this destination."
14209+
},
14210+
"target": {
14211+
"type": "object",
14212+
"properties": {
14213+
"id": {
14214+
"type": "string",
14215+
"minLength": 1,
14216+
"description": "Exact source deployment snapshot identifier."
14217+
},
14218+
"version": {
14219+
"type": "integer",
14220+
"minimum": 1,
14221+
"maximum": 2147483647,
14222+
"description": "Saved version number in the source workflow."
14223+
}
14224+
},
14225+
"required": ["id", "version"],
14226+
"additionalProperties": false,
14227+
"description": "Pinned source deployment that this sync would copy."
14228+
}
14229+
},
14230+
"required": ["status", "base", "target"],
14231+
"additionalProperties": false
14232+
},
14233+
{
14234+
"type": "object",
14235+
"properties": {
14236+
"status": {
14237+
"type": "string",
14238+
"const": "unavailable",
14239+
"description": "The source baseline cannot be compared."
14240+
},
14241+
"reason": {
14242+
"type": "string",
14243+
"enum": ["new_workflow", "no_baseline", "missing_baseline"],
14244+
"description": "New destination workflow, no recorded successful sync, or deleted baseline snapshot."
14245+
},
14246+
"target": {
14247+
"type": "object",
14248+
"properties": {
14249+
"id": {
14250+
"type": "string",
14251+
"minLength": 1,
14252+
"description": "Exact source deployment snapshot identifier."
14253+
},
14254+
"version": {
14255+
"type": "integer",
14256+
"minimum": 1,
14257+
"maximum": 2147483647,
14258+
"description": "Saved version number in the source workflow."
14259+
}
14260+
},
14261+
"required": ["id", "version"],
14262+
"additionalProperties": false,
14263+
"description": "Pinned source deployment that this sync would copy."
14264+
}
14265+
},
14266+
"required": ["status", "reason", "target"],
14267+
"additionalProperties": false
14268+
}
14269+
]
1417914270
}
1418014271
},
1418114272
"required": ["action", "name"],

‎apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ export const GET = defineInternalJsonRoute({
4545
* draft graph. Redacting here would blank OAuth accounts and resource selectors in that viewer
4646
* without closing any disclosure boundary, so this surface opts into the raw graph.
4747
*/
48-
mapInput: ({ params }) => ({
48+
mapInput: ({ params, query }) => ({
4949
workflowId: params.id,
5050
version: params.version,
51+
expectedDeploymentVersionId: query.expectedDeploymentVersionId,
5152
includeCredentialValues: true,
5253
representation: 'comparison' as const,
5354
}),

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/compare-versions-modal.tsx‎

Lines changed: 12 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,12 @@
11
'use client'
22

3-
import { useId, useMemo, useState } from 'react'
4-
import {
5-
ChipDropdown,
6-
type ChipDropdownOption,
7-
ChipModal,
8-
ChipModalBody,
9-
ChipModalHeader,
10-
} from '@sim/emcn'
3+
import { useMemo, useState } from 'react'
4+
import { ChipDropdown, type ChipDropdownOption } from '@sim/emcn'
115
import { ArrowRight } from '@sim/emcn/icons'
126
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
137
import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label'
148
import { useDraftWorkflowState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state'
15-
import {
16-
WorkflowDiffSkeleton,
17-
WorkflowDiffView,
18-
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff'
9+
import { WorkflowComparisonModal } from '@/app/workspace/[workspaceId]/w/components/workflow-diff'
1910
import { useDeploymentVersionState } from '@/hooks/queries/workflows'
2011

2112
/** One side of a comparison: the editor draft or a numbered deployment version. */
@@ -61,7 +52,6 @@ export function CompareVersionsModal({
6152
initialBase,
6253
initialTarget,
6354
}: CompareVersionsModalProps) {
64-
const descriptionId = useId()
6555
const [base, setBase] = useState<CompareSide>(initialBase)
6656
const [target, setTarget] = useState<CompareSide>(initialTarget)
6757
/* Subscribed only while the modal is open, and only if a side is the draft. */
@@ -103,15 +93,10 @@ export function CompareVersionsModal({
10393
const loadError = baseQuery.error ?? targetQuery.error
10494

10595
return (
106-
<ChipModal
96+
<WorkflowComparisonModal
10797
open={open}
10898
onOpenChange={onOpenChange}
109-
srTitle='Compare versions'
110-
aria-describedby={descriptionId}
111-
size='full'
112-
className='h-[84vh] [&>div]:h-full'
113-
>
114-
<ChipModalHeader onClose={() => onOpenChange(false)}>
99+
header={
115100
<div className='flex items-center gap-2'>
116101
<span>Compare</span>
117102
<ChipDropdown
@@ -130,26 +115,12 @@ export function CompareVersionsModal({
130115
aria-label={`Compare to ${optionLabel(options, sideToValue(target))}`}
131116
/>
132117
</div>
133-
</ChipModalHeader>
134-
<ChipModalBody fullBleed>
135-
<p id={descriptionId} className='sr-only'>
136-
Blocks, fields and connections that differ between the two selected versions.
137-
</p>
138-
{loadError ? (
139-
<div className='flex h-full items-center justify-center text-[var(--text-error)] text-small'>
140-
{loadError.message || 'Could not load one of the versions.'}
141-
</div>
142-
) : isLoading || !baseState || !targetState ? (
143-
<WorkflowDiffSkeleton />
144-
) : (
145-
/* One view per picked pair, so selection and folds start fresh when either side changes. */
146-
<WorkflowDiffView
147-
key={`${sideToValue(base)}:${sideToValue(target)}`}
148-
baseState={baseState}
149-
targetState={targetState}
150-
/>
151-
)}
152-
</ChipModalBody>
153-
</ChipModal>
118+
}
119+
baseState={baseState}
120+
targetState={targetState}
121+
isLoading={isLoading}
122+
error={loadError}
123+
comparisonKey={`${sideToValue(base)}:${sideToValue(target)}`}
124+
/>
154125
)
155126
}

‎apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ function WorkflowPreviewBlockInner({ id, data }: NodeProps<WorkflowPreviewBlockN
458458
)}
459459
>
460460
{/* Comparison label above the card */}
461-
{diffStatus && <DiffStatusLabel status={diffStatus} count={changedFieldSet.size} />}
461+
{diffStatus && <DiffStatusLabel status={diffStatus} />}
462462
{/* Selection ring overlay (takes priority over execution rings) */}
463463
{isPreviewSelected && (
464464
<div className='pointer-events-none absolute inset-0 z-40 rounded-2xl ring-[1.5px] ring-[var(--text-secondary)]' />

‎apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/diff-label/diff-label.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,13 @@ const DIFF_LABEL_CLASS: Record<BlockDiffStatus, string> = {
1818

1919
interface DiffStatusLabelProps {
2020
status: BlockDiffStatus
21-
/** How many fields changed on a modified block; shown after the label when known */
22-
count?: number
2321
}
2422

2523
/**
2624
* The comparison label floating above a canvas card or container: the same
2725
* chrome wherever it appears so a block and a loop read as one system.
2826
*/
29-
export function DiffStatusLabel({ status, count }: DiffStatusLabelProps) {
27+
export function DiffStatusLabel({ status }: DiffStatusLabelProps) {
3028
return (
3129
<div
3230
className={cn(
@@ -35,7 +33,6 @@ export function DiffStatusLabel({ status, count }: DiffStatusLabelProps) {
3533
)}
3634
>
3735
{DIFF_LABEL[status]}
38-
{status === 'modified' && count ? ` · ${count}` : null}
3936
</div>
4037
)
4138
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { WorkflowDiffSkeleton, WorkflowDiffView } from './workflow-diff-view'
1+
export { WorkflowComparisonModal } from './workflow-comparison-modal'
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use client'
2+
3+
import { type ReactNode, useId } from 'react'
4+
import { ChipModal, ChipModalBody, ChipModalHeader } from '@sim/emcn'
5+
import {
6+
WorkflowDiffSkeleton,
7+
WorkflowDiffView,
8+
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view'
9+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
10+
11+
interface WorkflowComparisonModalProps {
12+
open: boolean
13+
onOpenChange: (open: boolean) => void
14+
header: ReactNode
15+
baseState: WorkflowState | null
16+
targetState: WorkflowState | null
17+
isLoading: boolean
18+
error?: Error | null
19+
comparisonKey: string
20+
baseLabel?: string
21+
targetLabel?: string
22+
}
23+
24+
/**
25+
* Renders caller-loaded snapshots once both are ready, with errors taking precedence.
26+
* Changing comparisonKey resets selection in the shared diff view.
27+
*/
28+
export function WorkflowComparisonModal({
29+
open,
30+
onOpenChange,
31+
header,
32+
baseState,
33+
targetState,
34+
isLoading,
35+
error,
36+
comparisonKey,
37+
baseLabel,
38+
targetLabel,
39+
}: WorkflowComparisonModalProps) {
40+
const descriptionId = useId()
41+
return (
42+
<ChipModal
43+
open={open}
44+
onOpenChange={onOpenChange}
45+
srTitle='Compare versions'
46+
aria-describedby={descriptionId}
47+
size='full'
48+
className='h-[84vh] [&>div]:h-full'
49+
>
50+
<ChipModalHeader onClose={() => onOpenChange(false)}>{header}</ChipModalHeader>
51+
<ChipModalBody fullBleed>
52+
<p id={descriptionId} className='sr-only'>
53+
Blocks, fields and connections that differ between the two selected versions.
54+
</p>
55+
{error ? (
56+
<div className='flex h-full items-center justify-center text-[var(--text-error)] text-small'>
57+
{error.message || 'Could not load one of the versions.'}
58+
</div>
59+
) : isLoading || !baseState || !targetState ? (
60+
<WorkflowDiffSkeleton />
61+
) : (
62+
<WorkflowDiffView
63+
key={comparisonKey}
64+
baseState={baseState}
65+
targetState={targetState}
66+
baseLabel={baseLabel}
67+
targetLabel={targetLabel}
68+
/>
69+
)}
70+
</ChipModalBody>
71+
</ChipModal>
72+
)
73+
}

‎apps/sim/ee/workspace-forking/application/admit-sync.ts‎

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { generateId } from '@sim/utils/id'
22
import { truncate } from '@sim/utils/string'
33
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
44
import type { DbOrTx } from '@/lib/db/types'
5-
import { prepareWorkflowSnapshotDeployment } from '@/lib/workflows/orchestration/deploy'
6-
import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils'
75
import {
86
insertWorkspaceOperationReceipt,
97
type WorkspaceOperationReport,
@@ -14,6 +12,10 @@ import {
1412
type ForkContentCopyPayload,
1513
hasForkContentToCopy,
1614
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
15+
import {
16+
type PrepareForkDeploymentsParams,
17+
prepareForkSyncDeployments,
18+
} from '@/ee/workspace-forking/lib/promote/prepare-deployments'
1719
import type { PromoteForkResult } from '@/ee/workspace-forking/lib/promote/promote'
1820

1921
/** Persists the receipt, exact deployment versions, and resumable effects with the sync writes. */
@@ -29,6 +31,10 @@ export async function admitForkSync(
2931
undeployEventIds: string[]
3032
mcpAttachmentServerIds: string[]
3133
needsConfigurationIds: Set<string>
34+
deploymentSources: Pick<
35+
PrepareForkDeploymentsParams,
36+
'childWorkspaceId' | 'items' | 'sourceVersions'
37+
>
3238
copy?: ForkContentCopyPayload
3339
}
3440
): Promise<WorkspaceOperationReport> {
@@ -72,19 +78,16 @@ export async function admitForkSync(
7278
report.copyProgress = { status: 'pending', copied: 0, failed: 0 }
7379
report.contentOutboxEventId = await enqueueDurableForkContent(tx, report, params.copy)
7480
}
75-
for (const workflowId of [...params.targetIds].sort()) {
76-
if (params.needsConfigurationIds.has(workflowId)) continue
77-
const workflowState = await loadWorkflowDeploymentSnapshot(workflowId, tx)
78-
if (!workflowState) throw new Error('A synced workflow is missing its admitted graph')
79-
const prepared = await prepareWorkflowSnapshotDeployment({
80-
params: { workflowId, userId: params.userId, requestId: admission.requestId },
81-
actorId: params.userId,
82-
requestId: admission.requestId,
83-
idempotencyKey: `${report.operationId}:${workflowId}`,
84-
workflowState,
85-
tx,
86-
workspaceOperationId: report.operationId,
87-
})
81+
const deployments = await prepareForkSyncDeployments(tx, {
82+
...params.deploymentSources,
83+
targetWorkspaceId: params.targetWorkspaceId,
84+
promoteRunId: result.promoteRunId,
85+
needsConfigurationIds: params.needsConfigurationIds,
86+
userId: params.userId,
87+
requestId: admission.requestId,
88+
workspaceOperationId: report.operationId,
89+
})
90+
for (const [workflowId, prepared] of deployments) {
8891
if (prepared.success) {
8992
report.deploymentOperationIds!.push(prepared.operation.id)
9093
if (prepared.outboxEventId) report.effectEventIds!.push(prepared.outboxEventId)

‎apps/sim/ee/workspace-forking/application/create-and-sync.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface ForkInput {
2929
previewFingerprint?: string
3030
}
3131
export interface SyncInput extends SyncChoices {
32+
expectedSourceVersions?: PromoteForkParams['expectedSourceVersions']
3233
dependentValues?: PromoteForkParams['dependentValues']
3334
workspaceId: string
3435
otherWorkspaceId: string
@@ -255,6 +256,7 @@ export const syncWorkspace = defineForkUseCase<
255256
...syncContext(input, context),
256257
...choices,
257258
dependentValues: input.dependentValues,
259+
expectedSourceVersions: input.expectedSourceVersions,
258260
userId: context.userId,
259261
actorName: await loadActorName(context.userId),
260262
otherWorkspaceName: context.other!.name,

0 commit comments

Comments
 (0)