Skip to content
Open
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
86 changes: 86 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
)
58 changes: 58 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
)
69 changes: 69 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts
Original file line number Diff line number Diff line change
@@ -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.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 })
}
)
80 changes: 80 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
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 { 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,
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,
})

return NextResponse.json(body)
}
)
26 changes: 26 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/resources/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
)
49 changes: 49 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
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 { assertCanRollback } from '@/lib/workspaces/fork/lineage/authz'
import { rollbackFork } from '@/lib/workspaces/fork/promote/rollback'

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,
})

return NextResponse.json(result)
}
)
Loading
Loading