diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index 4ac098c3a2d..251c556ec79 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -1,3 +1,4 @@ +import { db } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' @@ -7,6 +8,7 @@ import { processOutboxEvents } from '@/lib/core/outbox/service' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' +import { reapStaleBackgroundWork } from '@/lib/workspaces/fork/background-work/store' const logger = createLogger('OutboxProcessorAPI') @@ -33,12 +35,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { minRemainingMs: 95_000, }) - logger.info('Outbox processing completed', { requestId, ...result }) + // Reap fork background-work rows stuck `processing` past their TTL (worker crash / + // restart has no in-task hook). Independent of the outbox; a failure here must not + // fail the outbox run, so it's guarded separately. + let reapedBackgroundWork = 0 + try { + reapedBackgroundWork = await reapStaleBackgroundWork(db) + } catch (error) { + logger.error('Background-work reap failed', { requestId, error: toError(error).message }) + } + + logger.info('Outbox processing completed', { requestId, ...result, reapedBackgroundWork }) return NextResponse.json({ success: true, requestId, result, + reapedBackgroundWork, }) } catch (error) { logger.error('Outbox processing failed', { requestId, error: toError(error).message }) diff --git a/apps/sim/app/api/workspaces/[id]/background-work/route.ts b/apps/sim/app/api/workspaces/[id]/background-work/route.ts new file mode 100644 index 00000000000..92e15148c0f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/background-work/route.ts @@ -0,0 +1,45 @@ +import { db } from '@sim/db' +import { type NextRequest, NextResponse } from 'next/server' +import { getWorkspaceBackgroundWorkContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export const GET = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getWorkspaceBackgroundWorkContract, req, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + + const access = await checkWorkspaceAccess(id, session.user.id) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + if (!access.hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const rows = await listSurfacedBackgroundWork(db, id) + return NextResponse.json({ + items: rows.map((row) => ({ + id: row.id, + workspaceId: row.workspaceId, + workflowId: row.workflowId, + kind: row.kind, + status: row.status, + message: row.message, + error: row.error, + metadata: row.metadata ?? null, + startedAt: row.startedAt.toISOString(), + completedAt: row.completedAt ? row.completedAt.toISOString() : null, + })), + }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts new file mode 100644 index 00000000000..db3d60d1381 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -0,0 +1,86 @@ +import { db } from '@sim/db' +import { type NextRequest, NextResponse } from 'next/server' +import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge' +import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz' +import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan' + +export const GET = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getForkDiffContract, req, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + const { otherWorkspaceId, direction } = parsed.data.query + + const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) + + const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates( + auth.sourceWorkspaceId + ) + const plan = await computeForkPromotePlan({ + executor: db, + edge: auth.edge, + sourceWorkspaceId: auth.sourceWorkspaceId, + targetWorkspaceId: auth.targetWorkspaceId, + direction, + deployedSourceWorkflows: deployedWorkflows, + sourceStates, + }) + + const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({ + kind: reference.kind, + sourceId: reference.sourceId, + required: reference.required, + blockName: reference.blockName, + }) + + // Orient the mapping around the workspace the modal is open in (`id`): show the + // caller's workflow name first, the sync partner's second, so renames are legible. + const currentIsSource = auth.sourceWorkspaceId === id + const workflows = [ + ...plan.items.map((item) => { + if (item.mode === 'create') { + // The target inherits the source's name, so both sides read the same. + return { + action: 'create' as const, + currentName: item.sourceMeta.name, + otherName: item.sourceMeta.name, + } + } + const targetName = item.targetName ?? item.sourceMeta.name + return { + action: 'update' as const, + currentName: currentIsSource ? item.sourceMeta.name : targetName, + otherName: currentIsSource ? targetName : item.sourceMeta.name, + } + }), + ...plan.archivedTargets.map((target) => ({ + action: 'archive' as const, + currentName: target.name, + otherName: target.name, + })), + ] + + return NextResponse.json({ + sourceWorkspaceId: auth.sourceWorkspaceId, + targetWorkspaceId: auth.targetWorkspaceId, + willUpdate: plan.willUpdate, + willCreate: plan.willCreate, + willArchive: plan.willArchive, + workflows, + unmappedRequired: plan.unmappedRequired.map(toRef), + unmappedOptional: plan.unmappedOptional.map(toRef), + mcpReauthServerIds: plan.mcpReauthServerIds, + inlineSecretSources: plan.inlineSecretSources, + drift: plan.drift, + }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts new file mode 100644 index 00000000000..3abd1282754 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts @@ -0,0 +1,58 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz' +import { getForkLineage } from '@/lib/workspaces/fork/lineage/lineage' +import { getUndoableRunForTarget } from '@/lib/workspaces/fork/promote/promote-run-store' + +export const GET = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getForkLineageContract, req, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + + await assertWorkspaceAdminAccess(workspaceId, session.user.id) + + const [{ parent, children }, run] = await Promise.all([ + getForkLineage(workspaceId), + getUndoableRunForTarget(db, workspaceId), + ]) + + let undoableRun: { + otherWorkspaceId: string + otherName: string + direction: 'push' | 'pull' + } | null = null + if (run) { + const [other] = await db + .select({ name: workspace.name }) + .from(workspace) + .where(eq(workspace.id, run.sourceWorkspaceId)) + .limit(1) + undoableRun = { + otherWorkspaceId: run.sourceWorkspaceId, + otherName: other?.name ?? 'workspace', + direction: run.direction, + } + } + + return NextResponse.json({ + workspaceId, + parent, + children, + hasUndoableRun: Boolean(run), + undoableRun, + }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts new file mode 100644 index 00000000000..dcd5db65145 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts @@ -0,0 +1,69 @@ +import { db } from '@sim/db' +import { type NextRequest, NextResponse } from 'next/server' +import { + getForkMappingContract, + updateForkMappingContract, +} from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz' +import { + applyForkMappingEntries, + getForkMappingView, + validateForkMappingTargets, +} from '@/lib/workspaces/fork/mapping/mapping-service' + +export const GET = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getForkMappingContract, req, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + const { otherWorkspaceId, direction } = parsed.data.query + + const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) + + const { entries } = await getForkMappingView({ + edge: auth.edge, + sourceWorkspaceId: auth.sourceWorkspaceId, + targetWorkspaceId: auth.targetWorkspaceId, + }) + + return NextResponse.json({ + childWorkspaceId: auth.edge.childWorkspaceId, + parentWorkspaceId: auth.edge.parentWorkspaceId, + sourceWorkspaceId: auth.sourceWorkspaceId, + targetWorkspaceId: auth.targetWorkspaceId, + entries, + }) + } +) + +export const PUT = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateForkMappingContract, req, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + const { otherWorkspaceId, direction, entries } = parsed.data.body + + const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) + + await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries) + + const updated = await db.transaction((tx) => + applyForkMappingEntries(tx, auth.edge, session.user.id, direction, entries) + ) + + return NextResponse.json({ success: true as const, updated }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts new file mode 100644 index 00000000000..e5749a3001b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts @@ -0,0 +1,106 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { promoteForkContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { recordBackgroundWork } from '@/lib/workspaces/fork/background-work/store' +import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz' +import { promoteFork } from '@/lib/workspaces/fork/promote/promote' + +const logger = createLogger('WorkspaceForkPromoteAPI') + +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(promoteForkContract, req, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + const { otherWorkspaceId, direction, force } = parsed.data.body + + const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) + + const result = await promoteFork({ + edge: auth.edge, + sourceWorkspaceId: auth.sourceWorkspaceId, + targetWorkspaceId: auth.targetWorkspaceId, + direction, + force, + userId: session.user.id, + requestId, + }) + + const body = { + promoteRunId: result.promoteRunId, + updated: result.updated, + created: result.created, + archived: result.archived, + redeployed: result.redeployed, + deployFailed: result.deployFailed, + unmappedRequired: result.unmappedRequired, + drift: result.drift, + } + + if (result.blocked) { + logger.info(`[${requestId}] Promote blocked (${result.blocked})`, { + sourceWorkspaceId: auth.sourceWorkspaceId, + targetWorkspaceId: auth.targetWorkspaceId, + }) + return NextResponse.json(body) + } + + recordAudit({ + workspaceId: auth.targetWorkspaceId, + actorId: session.user.id, + action: AuditAction.WORKSPACE_FORK_PROMOTED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: auth.targetWorkspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: auth.target.name, + description: `Promoted workflows from "${auth.source.name}" to "${auth.target.name}"`, + metadata: { + direction, + sourceWorkspaceId: auth.sourceWorkspaceId, + updated: result.updated, + created: result.created, + archived: result.archived, + redeployed: result.redeployed, + }, + request: req, + }) + + const otherName = + otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name + await recordBackgroundWork(db, { + workspaceId: id, + kind: 'fork_sync', + status: result.deployFailed > 0 ? 'completed_with_warnings' : 'completed', + message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`, + metadata: { + otherWorkspaceName: otherName, + direction, + updated: result.updated, + created: result.created, + archived: result.archived, + redeployed: result.redeployed, + deployFailed: result.deployFailed, + }, + }).catch((error) => + logger.error(`[${requestId}] Failed to record sync activity`, { + error: getErrorMessage(error), + }) + ) + + return NextResponse.json(body) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts new file mode 100644 index 00000000000..ef489fc6a18 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts @@ -0,0 +1,26 @@ +import { db } from '@sim/db' +import { type NextRequest, NextResponse } from 'next/server' +import { getForkResourcesContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz' +import { listForkCopyableResources } from '@/lib/workspaces/fork/mapping/resources' + +export const GET = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getForkResourcesContract, req, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + + await assertWorkspaceAdminAccess(id, session.user.id) + + const resources = await listForkCopyableResources(db, id) + return NextResponse.json(resources) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts new file mode 100644 index 00000000000..11bef7e3ba3 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts @@ -0,0 +1,83 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { rollbackForkContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { recordBackgroundWork } from '@/lib/workspaces/fork/background-work/store' +import { assertCanRollback } from '@/lib/workspaces/fork/lineage/authz' +import { rollbackFork } from '@/lib/workspaces/fork/promote/rollback' + +const logger = createLogger('WorkspaceForkRollbackAPI') + +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(rollbackForkContract, req, context) + if (!parsed.success) return parsed.response + const { id } = parsed.data.params + const { otherWorkspaceId } = parsed.data.body + + const target = await assertCanRollback(id, session.user.id) + + const result = await rollbackFork({ + targetWorkspaceId: id, + otherWorkspaceId, + userId: session.user.id, + requestId, + }) + + recordAudit({ + workspaceId: id, + actorId: session.user.id, + action: AuditAction.WORKSPACE_FORK_ROLLED_BACK, + resourceType: AuditResourceType.WORKSPACE, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: target.name, + description: `Rolled back the last promote into "${target.name}"`, + metadata: { otherWorkspaceId, ...result }, + request: req, + }) + + // Durable audit entry scoped to this workspace so the undo shows in its Manage Forks + // → Activity log. Non-critical: a failure must not fail the (committed) rollback. + const [other] = await db + .select({ name: workspace.name }) + .from(workspace) + .where(eq(workspace.id, otherWorkspaceId)) + .limit(1) + const otherName = other?.name ?? 'the source workspace' + await recordBackgroundWork(db, { + workspaceId: id, + kind: 'fork_rollback', + status: result.skipped > 0 ? 'completed_with_warnings' : 'completed', + message: `Undid the last sync from "${otherName}"`, + metadata: { + otherWorkspaceName: otherName, + restored: result.restored, + removed: result.archived, + unarchived: result.unarchived, + skipped: result.skipped, + }, + }).catch((error) => + logger.error(`[${requestId}] Failed to record rollback activity`, { + error: getErrorMessage(error), + }) + ) + + return NextResponse.json(result) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.ts b/apps/sim/app/api/workspaces/[id]/fork/route.ts new file mode 100644 index 00000000000..cb37a72eb33 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/route.ts @@ -0,0 +1,66 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { forkWorkspaceContract } from '@/lib/api/contracts/workspace-fork' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createFork } from '@/lib/workspaces/fork/create-fork' +import { assertCanFork } from '@/lib/workspaces/fork/lineage/authz' + +const logger = createLogger('WorkspaceForkAPI') + +export const POST = withRouteHandler( + async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { + const { id: sourceWorkspaceId } = await context.params + const requestId = generateRequestId() + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { source, policy } = await assertCanFork(sourceWorkspaceId, session.user.id) + + const parsed = await parseRequest(forkWorkspaceContract, req, context) + if (!parsed.success) return parsed.response + + const copy = parsed.data.body.copy + const result = await createFork({ + source, + policy, + userId: session.user.id, + name: parsed.data.body.name, + selection: { + files: copy?.files ?? [], + tables: copy?.tables ?? [], + knowledgeBases: copy?.knowledgeBases ?? [], + customTools: copy?.customTools ?? [], + skills: copy?.skills ?? [], + mcpServers: copy?.mcpServers ?? [], + }, + requestId, + }) + + recordAudit({ + workspaceId: result.workspace.id, + actorId: session.user.id, + action: AuditAction.WORKSPACE_FORKED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: result.workspace.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: result.workspace.name, + description: `Forked workspace from "${source.name}"`, + metadata: { + parentWorkspaceId: source.id, + workflowsCopied: result.workflowsCopied, + }, + request: req, + }) + + logger.info(`[${requestId}] Forked workspace ${sourceWorkspaceId} -> ${result.workspace.id}`) + return NextResponse.json(result, { status: 201 }) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 094e1d5a43d..9b20bee8cf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -19,6 +19,8 @@ import { Mail, Pencil, Plus, + Rocket, + Shuffle, SquareArrowUpRight, Trash, Unlock, @@ -68,6 +70,10 @@ interface ContextMenuProps { onUploadLogo?: () => void showUploadLogo?: boolean disableUploadLogo?: boolean + onFork?: () => void + onSync?: () => void + showFork?: boolean + showSync?: boolean } /** @@ -118,6 +124,10 @@ export function ContextMenu({ onUploadLogo, showUploadLogo = false, disableUploadLogo = false, + onFork, + onSync, + showFork = false, + showSync = false, }: ContextMenuProps) { const hasNavigationSection = showOpenInNewTab && onOpenInNewTab const hasStatusSection = @@ -131,6 +141,7 @@ export function ContextMenu({ (showLock && onToggleLock) || (showUploadLogo && onUploadLogo) const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport) + const hasForkSection = (showFork && onFork) || (showSync && onSync) return ( !open && onClose()} modal={false}> @@ -294,6 +305,35 @@ export function ContextMenu({ )} {(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) && + hasForkSection && } + {showFork && onFork && ( + { + onFork() + onClose() + }} + > + + Manage Forks + + )} + {showSync && onSync && ( + { + onSync() + onClose() + }} + > + + Sync workspace + + )} + + {(hasNavigationSection || + hasStatusSection || + hasEditSection || + hasCopySection || + hasForkSection) && (showLeave || showDelete) && } {showLeave && onLeave && ( `${n} ${noun}${n === 1 ? '' : 's'}` + +/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */ +function countList(pairs: Array<[number | undefined, string]>): string { + return pairs + .filter(([n]) => (n ?? 0) > 0) + .map(([n, verb]) => `${n} ${verb}`) + .join(' · ') +} + +/** The audit-row title, derived per kind from the job's metadata. */ +function jobTitle(job: BackgroundWorkItem): string { + const m = job.metadata + switch (job.kind) { + case 'fork_content_copy': + return m?.childWorkspaceName + ? `Forked into "${m.childWorkspaceName}"` + : (job.message ?? 'Fork') + case 'fork_sync': + if (!m?.otherWorkspaceName) return job.message ?? 'Sync' + return m.direction === 'pull' + ? `Pulled from "${m.otherWorkspaceName}"` + : `Pushed to "${m.otherWorkspaceName}"` + case 'fork_rollback': + return m?.otherWorkspaceName + ? `Undid sync from "${m.otherWorkspaceName}"` + : (job.message ?? 'Rollback') + default: + return job.message ?? 'Activity' + } +} + +/** The expand-row detail lines for a job, built per kind from its metadata. */ +function jobDetailLines(job: BackgroundWorkItem): string[] { + const m = job.metadata + if (!m) return [] + + if (job.kind === 'fork_sync') { + const lines: string[] = [] + const counts = countList([ + [m.updated, 'updated'], + [m.created, 'created'], + [m.archived, 'archived'], + [m.redeployed, 'redeployed'], + ]) + if (counts) lines.push(counts) + if (m.deployFailed && m.deployFailed > 0) lines.push(`${m.deployFailed} failed to deploy`) + return lines + } + + if (job.kind === 'fork_rollback') { + const counts = countList([ + [m.restored, 'restored'], + [m.unarchived, 'unarchived'], + [m.removed, 'removed'], + [m.skipped, 'skipped'], + ]) + return counts ? [counts] : [] + } + + // fork_content_copy: countable resource breakdown + the copy outcome. + const lines: string[] = [] + const kinds: Array<[number | undefined, string]> = [ + [m.workflowsCopied, 'workflow'], + [m.tables, 'table'], + [m.knowledgeBases, 'knowledge base'], + [m.files, 'file'], + ] + const selected = kinds.filter(([n]) => (n ?? 0) > 0).map(([n, noun]) => plural(n as number, noun)) + if (selected.length > 0) lines.push(selected.join(' · ')) + if (m.copied != null) { + lines.push( + m.failed && m.failed > 0 + ? `${plural(m.copied, 'item')} copied, ${m.failed} failed` + : `${plural(m.copied, 'item')} copied` + ) + } + return lines +} + +/** Status indicator: the platform loader while active, a colored dot once terminal. */ +function JobStatusIndicator({ status }: { status: BackgroundWorkItem['status'] }) { + if (status === 'pending' || status === 'processing') { + return + } + const color = + status === 'failed' + ? 'bg-[var(--text-error)]' + : status === 'completed_with_warnings' + ? 'bg-[var(--badge-amber-text)]' + : 'bg-[var(--indicator-active)]' + const label = + status === 'failed' + ? 'Failed' + : status === 'completed_with_warnings' + ? 'Completed with warnings' + : 'Done' + return +} + +/** One audit-log row: status + "Forked into ...", expanding to what was copied. */ +function ForkJobRow({ job }: { job: BackgroundWorkItem }) { + const [expanded, setExpanded] = useState(false) + const detailLines = jobDetailLines(job) + const hasDetail = detailLines.length > 0 || Boolean(job.error) + const title = jobTitle(job) + + return ( + +
hasDetail && setExpanded((value) => !value)} + onKeyDown={(event) => { + if (!hasDetail || event.target !== event.currentTarget) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setExpanded((value) => !value) + } + }} + > +
+ + {title} +
+ + {formatDateTime(new Date(job.startedAt))} + + {hasDetail ? ( + + ) : ( + + )} +
+ {expanded && hasDetail ? ( +
+ {detailLines.map((line) => ( + + {line} + + ))} + {job.error ? ( + {job.error} + ) : null} +
+ ) : null} +
+ ) +} + +/** Audit-log table of fork jobs, mirroring the deployment-versions table chrome. */ +function ForkJobsTable({ jobs }: { jobs: BackgroundWorkItem[] }) { + return ( +
+
+ Activity + When + +
+
+ {jobs.map((job) => ( + + ))} +
+
+ ) +} + +interface ForkActivityPanelProps { + /** The triggering operation is currently running (mutation in flight). */ + pending?: boolean + pendingLabel?: string + /** Poll the durable fork-job audit trail for this workspace. */ + backgroundWorkspaceId?: string +} + +/** + * The "Activity" tab for Manage Forks: a durable audit log of every fork, sync, and + * rollback as its own row (a deployment-versions-style table - status, "Forked into ...", + * timestamp, expand for what changed), with a loader while the current action runs. + */ +export function ForkActivityPanel({ + pending = false, + pendingLabel = 'Working…', + backgroundWorkspaceId, +}: ForkActivityPanelProps) { + const { data: jobs = [] } = useWorkspaceBackgroundWork(backgroundWorkspaceId) + + if (!pending && jobs.length === 0) { + return ( +
+ Nothing here yet. Forks, syncs, and rollbacks will appear here. +
+ ) + } + + return ( +
+ {pending ? ( +
+ + {pendingLabel} +
+ ) : null} + + {jobs.length > 0 ? : null} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal.tsx new file mode 100644 index 00000000000..20fe67c404c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal.tsx @@ -0,0 +1,444 @@ +'use client' + +import { useEffect, useId, useMemo, useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { Search } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { + Checkbox, + ChevronDown, + Chip, + ChipConfirmModal, + ChipCopyInput, + ChipInput, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalFooter, + type ChipModalFooterSlotAction, + ChipModalHeader, + ChipModalTabs, + Tooltip, + toast, +} from '@/components/emcn' +import type { + ForkCopyableResource, + GetForkResourcesResponse, +} from '@/lib/api/contracts/workspace-fork' +import { cn } from '@/lib/core/utils/cn' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { ForkActivityPanel } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel' +import { + type ForkDirection, + useForkResources, + useForkWorkspace, + useRollbackFork, +} from '@/hooks/queries/workspace-fork' + +interface ForkWorkspaceModalProps { + open: boolean + onOpenChange: (open: boolean) => void + sourceWorkspaceId: string + sourceWorkspaceName: string + /** The last sync into this workspace that can be undone (drives the rollback action). */ + undoableRun: { otherWorkspaceId: string; otherName: string; direction: ForkDirection } | null +} + +/** Join "N label" segments with " · ", dropping zero counts so toasts never read "0 foo". */ +function summarizeCounts(parts: Array<[number, string]>): string { + return parts + .filter(([count]) => count > 0) + .map(([count, label]) => `${count} ${label}`) + .join(' · ') +} + +type ResourceKey = Exclude +type ResourceSelection = Record> + +const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [ + { key: 'files', label: 'Files' }, + { key: 'tables', label: 'Tables' }, + { key: 'knowledgeBases', label: 'Knowledge bases' }, + { key: 'customTools', label: 'Custom tools' }, + { key: 'skills', label: 'Skills' }, + { key: 'mcpServers', label: 'MCP servers' }, +] + +/** Show the inline search once a kind has more entries than fit comfortably. */ +const SEARCH_THRESHOLD = 8 + +const emptySelection = (): ResourceSelection => ({ + files: new Set(), + tables: new Set(), + knowledgeBases: new Set(), + customTools: new Set(), + skills: new Set(), + mcpServers: new Set(), +}) + +/** + * One expandable resource kind in the fork picker: a tri-state "select all" header + * (count of selected / total) plus, when expanded, a searchable scrollable list of + * individual resources so the user can copy a specific subset. + */ +function ResourceKindRow({ + label, + items, + selected, + onToggleAll, + onToggleItem, + disabled, +}: { + label: string + items: ForkCopyableResource[] + selected: Set + onToggleAll: (selectAll: boolean) => void + onToggleItem: (id: string, checked: boolean) => void + disabled: boolean +}) { + const [expanded, setExpanded] = useState(false) + const [query, setQuery] = useState('') + const fieldId = useId() + + const total = items.length + const selectedCount = selected.size + const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate' + + const filtered = useMemo(() => { + const trimmed = query.trim().toLowerCase() + if (!trimmed) return items + return items.filter((item) => item.label.toLowerCase().includes(trimmed)) + }, [items, query]) + + return ( +
+
+ onToggleAll(headerState !== true)} + disabled={disabled} + /> + +
+ + {expanded ? ( +
+ {total > SEARCH_THRESHOLD ? ( + setQuery(event.target.value)} + placeholder={`Search ${label.toLowerCase()}`} + disabled={disabled} + /> + ) : null} +
+ {filtered.map((item) => { + const isChecked = selected.has(item.id) + const itemId = `${fieldId}-${item.id}` + return ( + + ) + })} + {filtered.length === 0 ? ( +

No matches

+ ) : null} +
+
+ ) : null} +
+ ) +} + +/** + * Names and creates a fork of the current workspace, lets the user pick which + * resources to copy (whole kinds or a specific subset), then navigates into the new + * fork. Unselected resources leave the corresponding workflow subblocks empty. + */ +export function ForkWorkspaceModal({ + open, + onOpenChange, + sourceWorkspaceId, + sourceWorkspaceName, + undoableRun, +}: ForkWorkspaceModalProps) { + const router = useRouter() + const forkWorkspace = useForkWorkspace() + const rollback = useRollbackFork() + const resources = useForkResources(sourceWorkspaceId, open) + const [name, setName] = useState('') + const [selected, setSelected] = useState(emptySelection) + const [error, setError] = useState(null) + + const [activeTab, setActiveTab] = useState<'config' | 'activity'>('config') + const [forkedWorkspace, setForkedWorkspace] = useState<{ id: string; name: string } | null>(null) + const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false) + + useEffect(() => { + if (open) { + setName(`${sourceWorkspaceName} (fork)`) + setSelected(emptySelection()) + setError(null) + setActiveTab('config') + setForkedWorkspace(null) + setConfirmRollbackOpen(false) + } + }, [open, sourceWorkspaceName]) + + const isForking = forkWorkspace.isPending + + const availableKinds = useMemo( + () => RESOURCE_KINDS.filter((kind) => (resources.data?.[kind.key].length ?? 0) > 0), + [resources.data] + ) + + // A fork always produces a usable workspace: deployed workflows are copied, and + // when the source has none, create-fork seeds a blank starter workflow (plus any + // selected resources). So forking is never blocked - we just set expectations when + // there are no deployed workflows to carry over. + const noDeployedWorkflows = + Boolean(resources.data) && (resources.data?.deployedWorkflowCount ?? 0) === 0 + + const handleSubmit = () => { + const trimmed = name.trim() + if (!trimmed || isForking) return + setError(null) + const copy = resources.data + ? Object.fromEntries(RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])])) + : undefined + forkWorkspace.mutate( + { workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } }, + { + onSuccess: (result) => { + toast.success(`Forked into "${result.workspace.name}"`) + setForkedWorkspace({ id: result.workspace.id, name: result.workspace.name }) + setActiveTab('activity') + }, + onError: (err) => setError(err.message || 'Failed to fork workspace'), + } + ) + } + + const openFork = () => { + if (!forkedWorkspace) return + onOpenChange(false) + router.push(`/workspace/${forkedWorkspace.id}/w`) + } + + // Rollback undoes the last sync INTO this workspace, restoring each affected workflow + // to its prior deployed version. Lives in the Activity tab's footer. + const runRollback = async () => { + if (!undoableRun) return + try { + const result = await rollback.mutateAsync({ + workspaceId: sourceWorkspaceId, + body: { otherWorkspaceId: undoableRun.otherWorkspaceId }, + }) + const summary = summarizeCounts([ + [result.restored, 'restored'], + [result.archived, 'removed'], + [result.unarchived, 'unarchived'], + [result.skipped, 'skipped'], + ]) + toast.success(summary ? `Undone · ${summary}` : 'Undone') + setConfirmRollbackOpen(false) + setActiveTab('activity') + } catch (err) { + toast.error(getErrorMessage(err, 'Undo failed')) + } + } + + const rollbackDisabled = rollback.isPending || !undoableRun + const rollbackTooltip = undoableRun + ? `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.` + : 'No sync to roll back yet.' + const rollbackChip = ( + + + + setConfirmRollbackOpen(true)} + disabled={rollbackDisabled} + className={rollbackDisabled ? 'pointer-events-none' : undefined} + > + Rollback + + + + {rollbackTooltip} + + ) + const rollbackAction: ChipModalFooterSlotAction[] = + activeTab === 'activity' && undoableRun ? [{ custom: rollbackChip }] : [] + + return ( + <> + + onOpenChange(false)}>Manage Forks + + setActiveTab(value as 'config' | 'activity')} + className='mx-2' + /> + {activeTab === 'activity' ? ( + + ) : ( + <> +
+ + + + + + * + + } + > + setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + handleSubmit() + } + }} + placeholder='Workspace name' + maxLength={100} + autoComplete='off' + disabled={isForking} + aria-label='Workspace name' + /> + + + {availableKinds.length > 0 ? ( + +
+ {availableKinds.map((kind) => ( + + setSelected((prev) => ({ + ...prev, + [kind.key]: selectAll + ? new Set((resources.data?.[kind.key] ?? []).map((item) => item.id)) + : new Set(), + })) + } + onToggleItem={(id, checked) => + setSelected((prev) => { + const next = new Set(prev[kind.key]) + if (checked) next.add(id) + else next.delete(id) + return { ...prev, [kind.key]: next } + }) + } + disabled={isForking} + /> + ))} +

+ Unselected resources leave their workflow fields empty in the fork. +

+
+
+ ) : null} + + {noDeployedWorkflows ? ( +

+ No deployed workflows to copy — your fork will start with a blank workflow. +

+ ) : null} +
+ {error ?? undefined} + + )} +
+ onOpenChange(false)} + cancelDisabled={isForking} + secondaryActions={rollbackAction.length > 0 ? rollbackAction : undefined} + primaryAction={ + activeTab === 'activity' + ? forkedWorkspace + ? { label: 'Open fork', onClick: openFork } + : { label: 'Done', onClick: () => onOpenChange(false) } + : { + label: isForking ? 'Forking...' : 'Fork', + onClick: handleSubmit, + disabled: !name.trim() || isForking, + } + } + /> +
+ + void runRollback(), + pending: rollback.isPending, + pendingLabel: 'Rolling back...', + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx new file mode 100644 index 00000000000..919195e98c0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx @@ -0,0 +1,429 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { ArrowRight } from 'lucide-react' +import { + Badge, + ChipCombobox, + ChipConfirmModal, + ChipDropdown, + ChipModal, + ChipModalBody, + ChipModalFooter, + type ChipModalFooterSlotAction, + ChipModalHeader, + toast, +} from '@/components/emcn' +import type { + ForkLineageNodeApi, + ForkMappingEntry, + ForkWorkflowChange, +} from '@/lib/api/contracts/workspace-fork' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + type ForkDirection, + useForkDiff, + useForkMapping, + usePromoteFork, + useUpdateForkMapping, +} from '@/hooks/queries/workspace-fork' + +interface PromoteWorkspaceModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + parent: ForkLineageNodeApi | null +} + +const entryKey = (entry: ForkMappingEntry) => `${entry.kind}:${entry.sourceId}` + +/** Join "N label" segments with " · ", dropping any zero counts so toasts never read "0 foo". */ +function summarizeCounts(parts: Array<[number, string]>): string { + return parts + .filter(([count]) => count > 0) + .map(([count, label]) => `${count} ${label}`) + .join(' · ') +} + +/** Section label + display order per mapping kind (one mapping step per kind). */ +const MAPPING_SECTION: Record = { + credential: { label: 'Credentials', order: 0 }, + 'env-var': { label: 'Secrets', order: 1 }, + table: { label: 'Tables', order: 2 }, + 'knowledge-base': { label: 'Knowledge bases', order: 3 }, + 'knowledge-document': { label: 'Knowledge documents', order: 4 }, + file: { label: 'Files', order: 5 }, + 'mcp-server': { label: 'MCP servers', order: 6 }, + 'custom-tool': { label: 'Custom tools', order: 7 }, + skill: { label: 'Skills', order: 8 }, +} + +interface EdgeOption { + value: string + label: string + otherWorkspaceId: string + direction: ForkDirection +} + +/** + * Fork sync surface. Along the parent edge it force pushes/pulls: the overview + * picks a direction and lists each resource kind's mapping status, then Sync. + * "Edit mappings" steps through every kind (Back/Next, each source a + * settings-style section + full-width target) to set or review targets before + * landing back on Sync - with a force-confirm on drift. The durable record of + * every sync is the Activity log in Manage Forks, so this modal just closes on + * success. + */ +export function PromoteWorkspaceModal({ + open, + onOpenChange, + workspaceId, + parent, +}: PromoteWorkspaceModalProps) { + // Sync is only ever performed along the parent edge (from a fork toward its + // parent). Child edges are intentionally not exposed here - a parent manages its + // forks (read-only list) rather than pushing/pulling into them. + const edgeOptions = useMemo(() => { + if (!parent) return [] + return [ + { + value: `push:${parent.id}`, + label: `Push to ${parent.name}`, + otherWorkspaceId: parent.id, + direction: 'push', + }, + { + value: `pull:${parent.id}`, + label: `Pull from ${parent.name}`, + otherWorkspaceId: parent.id, + direction: 'pull', + }, + ] + }, [parent]) + + const [selectedKey, setSelectedKey] = useState('') + // User's IN-SESSION mapping overrides only - NOT the source of truth. The + // displayed/persisted target falls back to each entry's stored `targetId` + // (see `targetFor`), so a reopened edge shows its remembered mappings even + // though React Query's structural sharing keeps `entries` referentially stable + // (a target-seeding effect gated on `entries` would never re-run there). + const [targets, setTargets] = useState>({}) + // Wizard step: 0 is the overview; 1..N edit one resource kind each, entered via + // "Edit mappings". Backing out of step 1 returns to the overview. + const [step, setStep] = useState(0) + const [confirmDriftOpen, setConfirmDriftOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (open) { + setSelectedKey(edgeOptions[0]?.value ?? '') + } + }, [open, edgeOptions]) + + // Restart at the overview and drop in-session overrides whenever it (re)opens or + // the direction changes - the mapping set, and therefore the steps, depend on the + // direction. + useEffect(() => { + setStep(0) + setTargets({}) + }, [open, selectedKey]) + + const selected = edgeOptions.find((option) => option.value === selectedKey) + const otherWorkspaceId = selected?.otherWorkspaceId + const direction = selected?.direction ?? 'push' + + const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled: open }) + const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled: open }) + const updateMapping = useUpdateForkMapping() + const promote = usePromoteFork() + + const entries = useMemo(() => mapping.data?.entries ?? [], [mapping.data]) + + // Effective target for an entry: the user's in-session override if present, + // else the persisted mapping from the server. Read directly from `entries` so + // a reopened edge reflects stored mappings without a seeding effect. + const targetFor = (entry: ForkMappingEntry) => targets[entryKey(entry)] ?? entry.targetId ?? '' + + const requiredComplete = entries.every((entry) => !entry.required || targetFor(entry) !== '') + + // Group mappings by resource type - one step per kind, required types first. + const groupedEntries = useMemo(() => { + const groups = new Map() + for (const entry of entries) { + const list = groups.get(entry.kind) + if (list) list.push(entry) + else groups.set(entry.kind, [entry]) + } + return Array.from(groups, ([kind, items]) => ({ + kind, + label: MAPPING_SECTION[kind].label, + items: items.slice().sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)), + })).sort((a, b) => MAPPING_SECTION[a.kind].order - MAPPING_SECTION[b.kind].order) + }, [entries]) + + // Per-kind status for the overview listing: "Fully mapped" or "n/total mapped", + // flagged when a REQUIRED target is still missing (which blocks Sync). Reads the + // effective (override-or-persisted) target so it reflects both remembered mappings + // and in-session edits. + const kindSummaries = groupedEntries.map((group) => { + const total = group.items.length + const mapped = group.items.filter((entry) => targetFor(entry) !== '').length + const requiredPending = group.items.some((entry) => entry.required && targetFor(entry) === '') + return { kind: group.kind, label: group.label, total, mapped, requiredPending } + }) + + // Step 0 is the overview (direction, deployed-workflow preview, mapping status); + // each subsequent step edits one resource kind, entered via "Edit mappings". + // `safeStep` guards against a group count that shrank on refetch. + const stepCount = 1 + groupedEntries.length + const safeStep = Math.min(step, Math.max(0, stepCount - 1)) + const isLastStep = safeStep >= stepCount - 1 + const currentGroup = safeStep >= 1 ? (groupedEntries[safeStep - 1] ?? null) : null + const syncDisabled = submitting || !otherWorkspaceId || !requiredComplete || mapping.isLoading + const headsUp = + (diff.data?.mcpReauthServerIds.length ?? 0) > 0 || + (diff.data?.inlineSecretSources.length ?? 0) > 0 + + const runPromote = async (force: boolean) => { + if (!otherWorkspaceId) return + setSubmitting(true) + try { + await updateMapping.mutateAsync({ + workspaceId, + body: { + otherWorkspaceId, + direction, + entries: entries.map((entry) => ({ + resourceType: entry.resourceType, + sourceId: entry.sourceId, + targetId: targetFor(entry) || null, + })), + }, + }) + + const result = await promote.mutateAsync({ + workspaceId, + body: { otherWorkspaceId, direction, force }, + }) + + if (!result.promoteRunId) { + if (result.unmappedRequired.length > 0) { + toast.error('Map all required credentials and secrets first') + return + } + if (result.drift) { + setConfirmDriftOpen(true) + return + } + toast.error('Sync did not complete') + return + } + + const summary = + summarizeCounts([ + [result.updated, 'updated'], + [result.created, 'created'], + [result.archived, 'archived'], + [result.redeployed, 'redeployed'], + ]) || 'Sync complete' + if (result.deployFailed > 0) { + const n = result.deployFailed + toast.warning( + `${summary}. ${n} workflow${n === 1 ? '' : 's'} synced but failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.` + ) + } else { + toast.success(summary) + } + onOpenChange(false) + } catch (error) { + toast.error(getErrorMessage(error, 'Sync failed')) + } finally { + setSubmitting(false) + } + } + + const workflowChanges = useMemo(() => { + const order: Record = { update: 0, create: 1, archive: 2 } + return [...(diff.data?.workflows ?? [])].sort( + (a, b) => order[a.action] - order[b.action] || a.currentName.localeCompare(b.currentName) + ) + }, [diff.data?.workflows]) + + // Right-cluster action sitting immediately left of the primary. The overview pairs + // "Edit mappings" with Sync (entering the step walk); every editing step pairs Back + // with Next (or with Sync on the last step). Back out of step 1 lands on the + // overview, restoring the "Edit mappings · Sync" pair. + const syncPrimaryAdjacent: ChipModalFooterSlotAction | undefined = + safeStep === 0 + ? groupedEntries.length > 0 + ? { label: 'Edit mappings', onClick: () => setStep(1), disabled: submitting } + : undefined + : { label: 'Back', onClick: () => setStep(safeStep - 1), disabled: submitting } + + return ( + <> + + onOpenChange(false)}> + {currentGroup ? `Sync workspace: ${currentGroup.label}` : 'Sync workspace'} + + + {safeStep === 0 ? ( +
+ + + + + {workflowChanges.length > 0 ? ( + +
+ {workflowChanges.map((change, index) => { + const renamed = change.currentName !== change.otherName + return ( +
+ + {change.currentName} + + {renamed ? ( + <> + + + {change.otherName} + + + ) : null} +
+ ) + })} +
+
+ ) : null} + + {headsUp ? ( + + {(diff.data?.mcpReauthServerIds.length ?? 0) > 0 ? ( +
+ {diff.data?.mcpReauthServerIds.length} MCP server(s) use OAuth and must be + re-authorized in the target workspace. +
+ ) : null} + {(diff.data?.inlineSecretSources.length ?? 0) > 0 ? ( +
+ {diff.data?.inlineSecretSources.length} inline secret(s) can't be auto-mapped + — set them in the target workspace. +
+ ) : null} +
+ ) : null} + + {kindSummaries.length > 0 ? ( + +
+ {kindSummaries.map(({ kind, label, total, mapped, requiredPending }) => { + const complete = mapped === total + return ( +
+ {label} + + {complete ? 'Fully mapped' : `${mapped}/${total} mapped`} + +
+ ) + })} +
+
+ ) : null} +
+ ) : currentGroup ? ( +
+ {currentGroup.items.map((entry) => ( + + * + + ) : undefined + } + > + ({ + label: candidate.label, + value: candidate.id, + }))} + value={targetFor(entry) || undefined} + onChange={(value) => + setTargets((prev) => ({ ...prev, [entryKey(entry)]: value })) + } + placeholder='Select target' + /> + {entry.candidatesTruncated ? ( +
+ This workspace has more options than shown here. If you don't see the right + one, narrow it down by name. +
+ ) : null} +
+ ))} +
+ ) : null} +
+ onOpenChange(false)} + hideCancel + primaryAdjacentAction={syncPrimaryAdjacent} + primaryAction={ + safeStep >= 1 && !isLastStep + ? { label: 'Next', onClick: () => setStep(safeStep + 1), disabled: submitting } + : { + label: submitting ? 'Working...' : 'Sync', + onClick: () => void runPromote(false), + disabled: syncDisabled, + disabledTooltip: requiredComplete ? undefined : 'Map all required secrets first', + } + } + /> +
+ + { + setConfirmDriftOpen(false) + void runPromote(true) + }, + pending: submitting, + pendingLabel: 'Syncing...', + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available.ts new file mode 100644 index 00000000000..8231e7b6735 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available.ts @@ -0,0 +1,28 @@ +import { getSubscriptionAccessState } from '@/lib/billing/client' +import { getEnv, isTruthy } from '@/lib/core/config/env' +import { useWorkspaceOwnerBilling } from '@/hooks/queries/workspace' + +const isBillingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) +const isForkingEnabledClient = isTruthy(getEnv('NEXT_PUBLIC_FORKING_ENABLED')) + +/** + * Client mirror of the server fork EE gate (`assertForkingEnabled`): on Sim Cloud + * the active workspace's billed account (its owner's rolled-up plan) must be + * Enterprise; on self-hosted it's the `NEXT_PUBLIC_FORKING_ENABLED` override. Used + * to hide the fork UI (and skip the lineage query) for workspaces that cannot fork. + * + * Gating on the WORKSPACE's plan (not the viewer's) is what matches the server, + * which checks the workspace org's plan: a viewer who belongs to a different + * Enterprise org no longer sees fork UI on a non-Enterprise workspace, and a + * member of an Enterprise workspace isn't denied it just because their own + * highest plan is lower. The server gate remains the security boundary. + * + * Self-hosted relies on `NEXT_PUBLIC_FORKING_ENABLED` / `NEXT_PUBLIC_BILLING_ENABLED` + * mirroring the server's `FORKING_ENABLED` / `BILLING_ENABLED`; set each pair + * together or the UI and API will disagree. + */ +export function useForkingAvailable(workspaceId?: string): boolean { + const { data } = useWorkspaceOwnerBilling(isBillingEnabledClient ? workspaceId : undefined) + if (!isBillingEnabledClient) return isForkingEnabledClient + return getSubscriptionAccessState(data).hasUsableEnterpriseAccess +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 499f79b91b2..4f80181daf1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -15,15 +15,21 @@ import { Plus, Send, Skeleton, + Tooltip, } from '@/components/emcn' -import { ManageWorkspace, PanelLeft } from '@/components/emcn/icons' +import { ManageWorkspace, PanelLeft, Shuffle } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal' +import { ForkWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-workspace-modal/fork-workspace-modal' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' +import { PromoteWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal' +import { useForkingAvailable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/use-forking-available' import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace' +import { useForkLineage } from '@/hooks/queries/workspace-fork' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -96,6 +102,8 @@ function WorkspaceHeaderImpl({ }: WorkspaceHeaderProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) + const [isForkModalOpen, setIsForkModalOpen] = useState(false) + const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false) @@ -120,6 +128,14 @@ function WorkspaceHeaderImpl({ }, []) const { navigateToSettings } = useSettingsNavigation() + const forkingAvailable = useForkingAvailable(workspaceId) + const { canAdmin } = useUserPermissionsContext() + // Forking and sync rewrite workflow state and deployments en masse, so they are + // workspace-admin only (org owners/admins derive workspace admin server-side via + // the resolved viewer permission). Every fork route re-checks this; gating the + // entry points here just keeps the UI honest. The server remains the boundary. + const canUseForking = forkingAvailable && canAdmin + const { data: forkLineage } = useForkLineage(workspaceId, canUseForking) const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null @@ -238,6 +254,18 @@ function WorkspaceHeaderImpl({ onUploadLogo(capturedWorkspaceRef.current.id) } + const handleForkAction = () => { + if (!canCreateWorkspace) { + if (isBillingEnabled) navigateToSettings({ section: 'billing' }) + return + } + setIsForkModalOpen(true) + } + + const handleSyncAction = () => { + setIsPromoteModalOpen(true) + } + /** * Handle leave workspace after confirmation */ @@ -383,6 +411,10 @@ function WorkspaceHeaderImpl({ const initial = (stripped[0] || workspace.name[0] || 'W').toUpperCase() const isActive = workspace.id === workspaceId const isMenuOpen = menuOpenWorkspaceId === workspace.id + const forkedFromName = workspace.forkedFromWorkspaceId + ? (workspaces.find((w) => w.id === workspace.forkedFromWorkspaceId)?.name ?? + 'another workspace') + : null return (
@@ -501,6 +533,16 @@ function WorkspaceHeaderImpl({ {workspace.name} + {forkedFromName ? ( + + + + + + + Fork of {forkedFromName} + + ) : null}