chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getForkAvailabilityContract } 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 { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
|
||||
/**
|
||||
* Whether forking is available for this workspace: the server-evaluated verdict of the
|
||||
* same gate every fork route enforces (env/plan + the `workspace-forking` AppConfig
|
||||
* rollout flag). Member-readable — it only reveals feature on/off, and the client uses
|
||||
* it to show/hide the Forks settings tab and context-menu entries.
|
||||
*/
|
||||
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(getForkAvailabilityContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
|
||||
const access = await checkWorkspaceAccess(id, session.user.id)
|
||||
if (!access.exists || !access.workspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const available = await isForkingAvailableForWorkspace(
|
||||
access.workspace.organizationId,
|
||||
session.user.id
|
||||
)
|
||||
return NextResponse.json({ available })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
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 { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows'
|
||||
import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
|
||||
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
|
||||
import {
|
||||
collectForkDependentReconfigs,
|
||||
collectForkResourceUsages,
|
||||
} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
|
||||
import {
|
||||
forkDependentValueKey,
|
||||
loadForkDependentValues,
|
||||
} from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
|
||||
import { listForkResourceCandidates } from '@/ee/workspace-forking/lib/mapping/resources'
|
||||
import {
|
||||
annotateForkClearedRefSourceLiveness,
|
||||
collectForkClearedRefCandidates,
|
||||
} from '@/ee/workspace-forking/lib/promote/cleared-refs'
|
||||
import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
|
||||
import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
||||
import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
// Resolve dependent-reconfig target block ids through the SAME persisted block map the
|
||||
// sync will use, so a re-pick the modal keys by target block id lands on the block the
|
||||
// promote actually writes (on push that's the parent's original id, not a derived one).
|
||||
const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId
|
||||
const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId)
|
||||
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
|
||||
|
||||
// Stored dependent values are the source of truth for what each selector is set to. Overlay
|
||||
// them as each field's currentValue so the modal pre-fills what the user actually saved.
|
||||
// Before the FIRST sync populates the store (fork-create seeds mappings but no dependent
|
||||
// values), the fallback is the TARGET's own configured value (loaded from its draft) - never
|
||||
// the source's, which would overwrite the target's selection. The stored read spans EVERY
|
||||
// plan target: a create-mode (never-synced) workflow's deterministic target id is what the
|
||||
// first sync will use, so values pre-configured for it in the mapping editor pre-fill here
|
||||
// too. The draft read stays replace-scoped (creates have no target draft to fall back to).
|
||||
const replaceTargetIds = plan.items
|
||||
.filter((item) => item.mode === 'replace')
|
||||
.map((item) => item.targetWorkflowId)
|
||||
const allTargetIds = plan.items.map((item) => item.targetWorkflowId)
|
||||
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
|
||||
await Promise.all([
|
||||
loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
|
||||
loadTargetDraftSubBlocks(db, replaceTargetIds),
|
||||
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
|
||||
listForkResourceCandidates(db, auth.sourceWorkspaceId),
|
||||
db
|
||||
.select({ id: workflow.id, name: workflow.name })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
|
||||
])
|
||||
const storedByKey = new Map(
|
||||
storedValues.map((entry) => [
|
||||
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
|
||||
entry.value,
|
||||
])
|
||||
)
|
||||
|
||||
// Source block subBlocks keyed by their resolved target identity, so the first-sync draft
|
||||
// fallback can identity-check a nested tool against the SOURCE dependent tool it came from -
|
||||
// an index alone may point at a different tool in the target draft, whose value isn't the
|
||||
// dependent's. Read structurally (only each subblock's `value`), so the in-memory state's
|
||||
// blocks pass without a cast.
|
||||
const sourceBlocksByTarget = new Map<string, Map<string, Record<string, { value?: unknown }>>>()
|
||||
for (const item of plan.items) {
|
||||
if (item.mode !== 'replace') continue
|
||||
const state = sourceStates.get(item.sourceWorkflowId)
|
||||
if (!state) continue
|
||||
const byBlock = new Map<string, Record<string, { value?: unknown }>>()
|
||||
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
|
||||
byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {})
|
||||
}
|
||||
sourceBlocksByTarget.set(item.targetWorkflowId, byBlock)
|
||||
}
|
||||
|
||||
// Replace-target fields pre-fill from the store, falling back to the TARGET's own draft
|
||||
// value before the first sync populates the store (never the source's, which would
|
||||
// overwrite the target's selection). Create-target fields (never-synced workflows)
|
||||
// pre-fill from the store, falling back to the SOURCE value the collector emitted -
|
||||
// that's exactly what the first sync copies verbatim, so the pre-fill is honest and
|
||||
// configuring it ahead of the first sync is possible (the deterministic target ids
|
||||
// already exist).
|
||||
const dependentReconfigs = [
|
||||
...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({
|
||||
...field,
|
||||
currentValue:
|
||||
storedByKey.get(
|
||||
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
|
||||
) ??
|
||||
readTargetDraftDependentValue(
|
||||
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
|
||||
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
|
||||
field.subBlockKey
|
||||
),
|
||||
})),
|
||||
...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId, 'create').map(
|
||||
(field) => ({
|
||||
...field,
|
||||
currentValue:
|
||||
storedByKey.get(
|
||||
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
|
||||
) ?? field.currentValue,
|
||||
})
|
||||
),
|
||||
]
|
||||
|
||||
// References this sync will blank in the target (per block/field), for the pre-sync cleared-ref
|
||||
// list. Labels resolve from the source candidate lists + workflow names loaded above.
|
||||
const sourceLabels = new Map<string, string>()
|
||||
for (const [kind, candidates] of Object.entries(sourceCandidates)) {
|
||||
for (const candidate of candidates)
|
||||
sourceLabels.set(`${kind}:${candidate.id}`, candidate.label)
|
||||
}
|
||||
const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name]))
|
||||
// Annotate each reference-cause entry's source liveness so the client can phrase the blocker
|
||||
// reason (a deleted source can't be copied - it must be mapped to a live target resource).
|
||||
const clearedRefs = await annotateForkClearedRefSourceLiveness(
|
||||
db,
|
||||
auth.sourceWorkspaceId,
|
||||
collectForkClearedRefCandidates({
|
||||
items: plan.items,
|
||||
sourceStates,
|
||||
resolver: plan.resolver,
|
||||
workflowIdMap: plan.workflowIdMap,
|
||||
resolveBlockId,
|
||||
sourceLabels,
|
||||
sourceWorkflowNames,
|
||||
})
|
||||
)
|
||||
|
||||
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,
|
||||
dependentReconfigs,
|
||||
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
|
||||
copyableUnmapped: plan.copyableUnmapped,
|
||||
clearedRefs,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockAssertWorkspaceAdminAccess,
|
||||
mockGetForkParent,
|
||||
mockGetForkChildren,
|
||||
mockGetUndoableRunForTarget,
|
||||
mockGetEffectiveWorkspacePermission,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockAssertWorkspaceAdminAccess: vi.fn(),
|
||||
mockGetForkParent: vi.fn(),
|
||||
mockGetForkChildren: vi.fn(),
|
||||
mockGetUndoableRunForTarget: vi.fn(),
|
||||
mockGetEffectiveWorkspacePermission: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
|
||||
assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
|
||||
getForkParent: mockGetForkParent,
|
||||
getForkChildren: mockGetForkChildren,
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
|
||||
getUndoableRunForTarget: mockGetUndoableRunForTarget,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/workspaces/[id]/fork/lineage/route'
|
||||
|
||||
const WORKSPACE_ID = 'workspace-1'
|
||||
const VIEWER_ID = 'user-1'
|
||||
const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }
|
||||
|
||||
const parentNode = { id: 'parent-1', name: 'Parent', organizationId: 'org-1' }
|
||||
const childCreatedAt = new Date('2026-01-02T03:04:05.000Z')
|
||||
const childNode = (id: string, name: string) => ({
|
||||
id,
|
||||
name,
|
||||
organizationId: 'org-1',
|
||||
createdAt: childCreatedAt,
|
||||
})
|
||||
|
||||
describe('fork lineage route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID } })
|
||||
mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID })
|
||||
mockGetForkParent.mockResolvedValue(null)
|
||||
mockGetForkChildren.mockResolvedValue([])
|
||||
mockGetUndoableRunForTarget.mockResolvedValue(null)
|
||||
mockGetEffectiveWorkspacePermission.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('returns 401 when there is no session', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires admin on the current workspace before loading lineage', async () => {
|
||||
await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, VIEWER_ID)
|
||||
})
|
||||
|
||||
it('marks accessible and inaccessible nodes via the canonical permission resolver', async () => {
|
||||
mockGetForkParent.mockResolvedValue(parentNode)
|
||||
mockGetForkChildren.mockResolvedValue([
|
||||
childNode('fork-accessible', 'Accessible fork'),
|
||||
childNode('fork-hidden', 'Hidden fork'),
|
||||
])
|
||||
mockGetEffectiveWorkspacePermission.mockImplementation(
|
||||
async (_userId: string, ws: { id: string }) => {
|
||||
if (ws.id === parentNode.id) return 'read'
|
||||
if (ws.id === 'fork-accessible') return 'admin'
|
||||
return null
|
||||
}
|
||||
)
|
||||
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.parent).toEqual({ ...parentNode, viewerAccessible: true })
|
||||
expect(body.children).toEqual([
|
||||
{
|
||||
id: 'fork-accessible',
|
||||
name: 'Accessible fork',
|
||||
organizationId: 'org-1',
|
||||
createdAt: childCreatedAt.toISOString(),
|
||||
viewerAccessible: true,
|
||||
},
|
||||
{
|
||||
id: 'fork-hidden',
|
||||
name: 'Hidden fork',
|
||||
organizationId: 'org-1',
|
||||
createdAt: childCreatedAt.toISOString(),
|
||||
viewerAccessible: false,
|
||||
},
|
||||
])
|
||||
expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalledWith(
|
||||
VIEWER_ID,
|
||||
expect.objectContaining({ id: parentNode.id, organizationId: 'org-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('marks the parent inaccessible when the viewer holds no permission on it', async () => {
|
||||
mockGetForkParent.mockResolvedValue(parentNode)
|
||||
mockGetEffectiveWorkspacePermission.mockResolvedValue(null)
|
||||
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.parent).toEqual({ ...parentNode, viewerAccessible: false })
|
||||
expect(body.children).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a null parent null without resolving permissions', async () => {
|
||||
const res = await GET(createMockRequest('GET'), routeContext)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.parent).toBeNull()
|
||||
expect(body.children).toEqual([])
|
||||
expect(body.undoableRun).toBeNull()
|
||||
expect(mockGetEffectiveWorkspacePermission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils'
|
||||
import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store'
|
||||
|
||||
/**
|
||||
* Annotates a lineage node with whether the viewer holds any access to it (explicit
|
||||
* grant or org-admin derivation, via the canonical workspace-permission resolver).
|
||||
* Lineage rows are visible to any admin of the CURRENT workspace, who may have no
|
||||
* access to the other side of an edge; the flag drives per-action gating in the
|
||||
* Forks UI. Resolved per node - lineage children lists are small and bounded.
|
||||
*/
|
||||
async function withViewerAccess<T extends { id: string; organizationId: string | null }>(
|
||||
node: T,
|
||||
viewerId: string
|
||||
): Promise<T & { viewerAccessible: boolean }> {
|
||||
const permission = await getEffectiveWorkspacePermission(viewerId, node)
|
||||
return { ...node, viewerAccessible: permission !== null }
|
||||
}
|
||||
|
||||
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 [rawParent, rawChildren, run] = await Promise.all([
|
||||
getForkParent(workspaceId),
|
||||
getForkChildren(workspaceId),
|
||||
getUndoableRunForTarget(db, workspaceId),
|
||||
])
|
||||
|
||||
const [parent, children] = await Promise.all([
|
||||
rawParent ? withViewerAccess(rawParent, session.user.id) : null,
|
||||
Promise.all(rawChildren.map((child) => withViewerAccess(child, session.user.id))),
|
||||
])
|
||||
|
||||
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: children.map((child) => ({
|
||||
...child,
|
||||
createdAt: child.createdAt.toISOString(),
|
||||
})),
|
||||
undoableRun,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
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 '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { acquireForkEdgeLock, setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage'
|
||||
import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
|
||||
import {
|
||||
applyForkMappingEntries,
|
||||
getForkMappingView,
|
||||
validateForkMappingTargets,
|
||||
} from '@/ee/workspace-forking/lib/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, dependentValues } = parsed.data.body
|
||||
|
||||
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
|
||||
|
||||
await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries)
|
||||
|
||||
// Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted
|
||||
// then re-upserted parent-side) can't leave duplicate rows for the same source. Same
|
||||
// edge lock promote/rollback use, with a bounded wait.
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await setForkLockTimeout(tx)
|
||||
await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId)
|
||||
const applied = await applyForkMappingEntries(
|
||||
tx,
|
||||
auth.edge,
|
||||
session.user.id,
|
||||
direction,
|
||||
entries
|
||||
)
|
||||
// Store dependent-field values with the mapping (each named workflow's stored set is
|
||||
// replaced by exactly what was sent - promote's reconcile semantics, scoped to the
|
||||
// payload's workflows since a mapping save has no promote plan). Omitted = untouched;
|
||||
// rows for a workflow that never becomes a sync replace target are inert (promote
|
||||
// loads the store scoped to its plan's targets).
|
||||
if (dependentValues !== undefined) {
|
||||
const targetWorkflowIds = Array.from(
|
||||
new Set(dependentValues.map((entry) => entry.workflowId))
|
||||
)
|
||||
await reconcileForkDependentValues(
|
||||
tx,
|
||||
auth.edge.childWorkspaceId,
|
||||
targetWorkflowIds,
|
||||
dependentValues.map((entry) => ({
|
||||
targetWorkflowId: entry.workflowId,
|
||||
targetBlockId: entry.blockId,
|
||||
subBlockKey: entry.subBlockKey,
|
||||
value: entry.value,
|
||||
}))
|
||||
)
|
||||
}
|
||||
return applied
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true as const, updated })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
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 '@/ee/workspace-forking/lib/background-work/store'
|
||||
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { promoteFork } from '@/ee/workspace-forking/lib/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, dependentValues, copyResources } = 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,
|
||||
userId: session.user.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
dependentValues,
|
||||
copyResources,
|
||||
requestId,
|
||||
})
|
||||
|
||||
const body = {
|
||||
promoteRunId: result.promoteRunId,
|
||||
updated: result.updated,
|
||||
created: result.created,
|
||||
archived: result.archived,
|
||||
redeployed: result.redeployed,
|
||||
deployFailed: result.deployFailed,
|
||||
unmappedRequired: result.unmappedRequired,
|
||||
blockers: result.blockers,
|
||||
needsConfiguration: result.needsConfiguration,
|
||||
clearedOptional: result.clearedOptional,
|
||||
}
|
||||
|
||||
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 ||
|
||||
result.needsConfiguration.length > 0 ||
|
||||
result.clearedOptional.length > 0
|
||||
? 'completed_with_warnings'
|
||||
: 'completed',
|
||||
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
|
||||
metadata: {
|
||||
actorName: session.user.name ?? undefined,
|
||||
otherWorkspaceId,
|
||||
otherWorkspaceName: otherName,
|
||||
direction,
|
||||
updated: result.updated,
|
||||
created: result.created,
|
||||
archived: result.archived,
|
||||
redeployed: result.redeployed,
|
||||
deployFailed: result.deployFailed,
|
||||
updatedNames: result.updatedNames,
|
||||
createdNames: result.createdNames,
|
||||
archivedNames: result.archivedNames,
|
||||
needsConfiguration: result.needsConfiguration,
|
||||
clearedOptional: result.clearedOptional,
|
||||
},
|
||||
}).catch((error) =>
|
||||
logger.error(`[${requestId}] Failed to record sync activity`, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json(body)
|
||||
}
|
||||
)
|
||||
@@ -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 '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { listForkCopyableResources } from '@/ee/workspace-forking/lib/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)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
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 '@/ee/workspace-forking/lib/background-work/store'
|
||||
import { assertCanRollback } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { rollbackFork } from '@/ee/workspace-forking/lib/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: {
|
||||
actorName: session.user.name ?? undefined,
|
||||
otherWorkspaceId,
|
||||
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)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
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 '@/ee/workspace-forking/lib/create-fork'
|
||||
import { assertCanFork } from '@/ee/workspace-forking/lib/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,
|
||||
actorName: session.user.name ?? undefined,
|
||||
name: parsed.data.body.name,
|
||||
selection: {
|
||||
files: copy?.files ?? [],
|
||||
tables: copy?.tables ?? [],
|
||||
knowledgeBases: copy?.knowledgeBases ?? [],
|
||||
customTools: copy?.customTools ?? [],
|
||||
skills: copy?.skills ?? [],
|
||||
mcpServers: copy?.mcpServers ?? [],
|
||||
workflowMcpServers: copy?.workflowMcpServers ?? [],
|
||||
},
|
||||
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 })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { unlinkForkContract } 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 { assertCanUnlink } from '@/ee/workspace-forking/lib/lineage/authz'
|
||||
import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
|
||||
|
||||
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(unlinkForkContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const { otherWorkspaceId } = parsed.data.body
|
||||
|
||||
const { edge, current } = await assertCanUnlink(id, otherWorkspaceId, session.user.id)
|
||||
const result = await unlinkForkEdge(edge, requestId)
|
||||
|
||||
if (result.unlinked) {
|
||||
recordAudit({
|
||||
workspaceId: id,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.WORKSPACE_FORK_UNLINKED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: current.name,
|
||||
description: `Disconnected the fork relationship with workspace "${otherWorkspaceId}"`,
|
||||
metadata: {
|
||||
otherWorkspaceId,
|
||||
childWorkspaceId: edge.childWorkspaceId,
|
||||
parentWorkspaceId: edge.parentWorkspaceId,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user