Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -14176,6 +14176,97 @@
"type": "string",
"maxLength": 1024,
"description": "Display name of the workflow or workspace."
},
"comparison": {
"description": "Last successfully synced source deployment compared with the source deployment pinned by this preview. Absent for archive actions.",
"oneOf": [
{
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "available",
"description": "Both exact source snapshots are available."
},
"base": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"description": "Exact source deployment snapshot identifier."
},
"version": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"description": "Saved version number in the source workflow."
}
},
"required": ["id", "version"],
"additionalProperties": false,
"description": "Source deployment last successfully synced to this destination."
},
"target": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"description": "Exact source deployment snapshot identifier."
},
"version": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"description": "Saved version number in the source workflow."
}
},
"required": ["id", "version"],
"additionalProperties": false,
"description": "Pinned source deployment that this sync would copy."
}
},
"required": ["status", "base", "target"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "unavailable",
"description": "The source baseline cannot be compared."
},
"reason": {
"type": "string",
"enum": ["new_workflow", "no_baseline", "missing_baseline"],
"description": "New destination workflow, no recorded successful sync, or deleted baseline snapshot."
},
"target": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1,
"description": "Exact source deployment snapshot identifier."
},
"version": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647,
"description": "Saved version number in the source workflow."
}
},
"required": ["id", "version"],
"additionalProperties": false,
"description": "Pinned source deployment that this sync would copy."
}
},
"required": ["status", "reason", "target"],
"additionalProperties": false
}
]
}
},
"required": ["action", "name"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ export const GET = defineInternalJsonRoute({
* draft graph. Redacting here would blank OAuth accounts and resource selectors in that viewer
* without closing any disclosure boundary, so this surface opts into the raw graph.
*/
mapInput: ({ params }) => ({
mapInput: ({ params, query }) => ({
workflowId: params.id,
version: params.version,
expectedDeploymentVersionId: query.expectedDeploymentVersionId,
includeCredentialValues: true,
representation: 'comparison' as const,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
'use client'

import { useId, useMemo, useState } from 'react'
import {
ChipDropdown,
type ChipDropdownOption,
ChipModal,
ChipModalBody,
ChipModalHeader,
} from '@sim/emcn'
import { useMemo, useState } from 'react'
import { ChipDropdown, type ChipDropdownOption } from '@sim/emcn'
import { ArrowRight } from '@sim/emcn/icons'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { formatVersionLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/format-version-label'
import { useDraftWorkflowState } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-draft-workflow-state'
import {
WorkflowDiffSkeleton,
WorkflowDiffView,
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff'
import { WorkflowComparisonModal } from '@/app/workspace/[workspaceId]/w/components/workflow-diff'
import { useDeploymentVersionState } from '@/hooks/queries/workflows'

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

return (
<ChipModal
<WorkflowComparisonModal
open={open}
onOpenChange={onOpenChange}
srTitle='Compare versions'
aria-describedby={descriptionId}
size='full'
className='h-[84vh] [&>div]:h-full'
>
<ChipModalHeader onClose={() => onOpenChange(false)}>
header={
<div className='flex items-center gap-2'>
<span>Compare</span>
<ChipDropdown
Expand All @@ -130,26 +115,12 @@ export function CompareVersionsModal({
aria-label={`Compare to ${optionLabel(options, sideToValue(target))}`}
/>
</div>
</ChipModalHeader>
<ChipModalBody fullBleed>
<p id={descriptionId} className='sr-only'>
Blocks, fields and connections that differ between the two selected versions.
</p>
{loadError ? (
<div className='flex h-full items-center justify-center text-[var(--text-error)] text-small'>
{loadError.message || 'Could not load one of the versions.'}
</div>
) : isLoading || !baseState || !targetState ? (
<WorkflowDiffSkeleton />
) : (
/* One view per picked pair, so selection and folds start fresh when either side changes. */
<WorkflowDiffView
key={`${sideToValue(base)}:${sideToValue(target)}`}
baseState={baseState}
targetState={targetState}
/>
)}
</ChipModalBody>
</ChipModal>
}
baseState={baseState}
targetState={targetState}
isLoading={isLoading}
error={loadError}
comparisonKey={`${sideToValue(base)}:${sideToValue(target)}`}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ function WorkflowPreviewBlockInner({ id, data }: NodeProps<WorkflowPreviewBlockN
)}
>
{/* Comparison label above the card */}
{diffStatus && <DiffStatusLabel status={diffStatus} count={changedFieldSet.size} />}
{diffStatus && <DiffStatusLabel status={diffStatus} />}
{/* Selection ring overlay (takes priority over execution rings) */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-2xl ring-[1.5px] ring-[var(--text-secondary)]' />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,13 @@ const DIFF_LABEL_CLASS: Record<BlockDiffStatus, string> = {

interface DiffStatusLabelProps {
status: BlockDiffStatus
/** How many fields changed on a modified block; shown after the label when known */
count?: number
}

/**
* The comparison label floating above a canvas card or container: the same
* chrome wherever it appears so a block and a loop read as one system.
*/
export function DiffStatusLabel({ status, count }: DiffStatusLabelProps) {
export function DiffStatusLabel({ status }: DiffStatusLabelProps) {
return (
<div
className={cn(
Expand All @@ -35,7 +33,6 @@ export function DiffStatusLabel({ status, count }: DiffStatusLabelProps) {
)}
>
{DIFF_LABEL[status]}
{status === 'modified' && count ? ` · ${count}` : null}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { WorkflowDiffSkeleton, WorkflowDiffView } from './workflow-diff-view'
export { WorkflowComparisonModal } from './workflow-comparison-modal'
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client'

import { type ReactNode, useId } from 'react'
import { ChipModal, ChipModalBody, ChipModalHeader } from '@sim/emcn'
import {
WorkflowDiffSkeleton,
WorkflowDiffView,
} from '@/app/workspace/[workspaceId]/w/components/workflow-diff/workflow-diff-view'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

interface WorkflowComparisonModalProps {
open: boolean
onOpenChange: (open: boolean) => void
header: ReactNode
baseState: WorkflowState | null
targetState: WorkflowState | null
isLoading: boolean
error?: Error | null
comparisonKey: string
baseLabel?: string
targetLabel?: string
}

/**
* Renders caller-loaded snapshots once both are ready, with errors taking precedence.
* Changing comparisonKey resets selection in the shared diff view.
*/
export function WorkflowComparisonModal({
open,
onOpenChange,
header,
baseState,
Comment thread
icecrasher321 marked this conversation as resolved.
targetState,
isLoading,
error,
comparisonKey,
baseLabel,
targetLabel,
}: WorkflowComparisonModalProps) {
const descriptionId = useId()
return (
<ChipModal
open={open}
onOpenChange={onOpenChange}
srTitle='Compare versions'
aria-describedby={descriptionId}
size='full'
className='h-[84vh] [&>div]:h-full'
>
<ChipModalHeader onClose={() => onOpenChange(false)}>{header}</ChipModalHeader>
<ChipModalBody fullBleed>
<p id={descriptionId} className='sr-only'>
Blocks, fields and connections that differ between the two selected versions.
</p>
{error ? (
<div className='flex h-full items-center justify-center text-[var(--text-error)] text-small'>
{error.message || 'Could not load one of the versions.'}
</div>
) : isLoading || !baseState || !targetState ? (
<WorkflowDiffSkeleton />
) : (
<WorkflowDiffView
key={comparisonKey}
baseState={baseState}
targetState={targetState}
baseLabel={baseLabel}
targetLabel={targetLabel}
/>
)}
</ChipModalBody>
</ChipModal>
)
}
33 changes: 18 additions & 15 deletions apps/sim/ee/workspace-forking/application/admit-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import type { DbOrTx } from '@/lib/db/types'
import { prepareWorkflowSnapshotDeployment } from '@/lib/workflows/orchestration/deploy'
import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils'
import {
insertWorkspaceOperationReceipt,
type WorkspaceOperationReport,
Expand All @@ -14,6 +12,10 @@ import {
type ForkContentCopyPayload,
hasForkContentToCopy,
} from '@/ee/workspace-forking/lib/copy/content-copy-runner'
import {
type PrepareForkDeploymentsParams,
prepareForkSyncDeployments,
} from '@/ee/workspace-forking/lib/promote/prepare-deployments'
import type { PromoteForkResult } from '@/ee/workspace-forking/lib/promote/promote'

/** Persists the receipt, exact deployment versions, and resumable effects with the sync writes. */
Expand All @@ -29,6 +31,10 @@ export async function admitForkSync(
undeployEventIds: string[]
mcpAttachmentServerIds: string[]
needsConfigurationIds: Set<string>
deploymentSources: Pick<
PrepareForkDeploymentsParams,
'childWorkspaceId' | 'items' | 'sourceVersions'
>
copy?: ForkContentCopyPayload
}
): Promise<WorkspaceOperationReport> {
Expand Down Expand Up @@ -72,19 +78,16 @@ export async function admitForkSync(
report.copyProgress = { status: 'pending', copied: 0, failed: 0 }
report.contentOutboxEventId = await enqueueDurableForkContent(tx, report, params.copy)
}
for (const workflowId of [...params.targetIds].sort()) {
if (params.needsConfigurationIds.has(workflowId)) continue
const workflowState = await loadWorkflowDeploymentSnapshot(workflowId, tx)
if (!workflowState) throw new Error('A synced workflow is missing its admitted graph')
const prepared = await prepareWorkflowSnapshotDeployment({
params: { workflowId, userId: params.userId, requestId: admission.requestId },
actorId: params.userId,
requestId: admission.requestId,
idempotencyKey: `${report.operationId}:${workflowId}`,
workflowState,
tx,
workspaceOperationId: report.operationId,
})
const deployments = await prepareForkSyncDeployments(tx, {
...params.deploymentSources,
targetWorkspaceId: params.targetWorkspaceId,
promoteRunId: result.promoteRunId,
needsConfigurationIds: params.needsConfigurationIds,
userId: params.userId,
requestId: admission.requestId,
workspaceOperationId: report.operationId,
})
for (const [workflowId, prepared] of deployments) {
if (prepared.success) {
report.deploymentOperationIds!.push(prepared.operation.id)
if (prepared.outboxEventId) report.effectEventIds!.push(prepared.outboxEventId)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/ee/workspace-forking/application/create-and-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ForkInput {
previewFingerprint?: string
}
export interface SyncInput extends SyncChoices {
expectedSourceVersions?: PromoteForkParams['expectedSourceVersions']
dependentValues?: PromoteForkParams['dependentValues']
workspaceId: string
otherWorkspaceId: string
Expand Down Expand Up @@ -255,6 +256,7 @@ export const syncWorkspace = defineForkUseCase<
...syncContext(input, context),
...choices,
dependentValues: input.dependentValues,
expectedSourceVersions: input.expectedSourceVersions,
userId: context.userId,
actorName: await loadActorName(context.userId),
otherWorkspaceName: context.other!.name,
Expand Down
Loading
Loading