Skip to content

Commit 5a81341

Browse files
feat(workspaces): fork + push/pull (#5210)
* feat(workspaces): fork + push/pull * type fix * fix tests * progress on ux * remove modal section * improve UI of modal * update more ui * make rollback part of the footer * track skipped count correctly * address comments * make it workspace admin level * update skipped count * address more comments * deal with unbounded memory possibility * fix deleted kb article bug * no deployed workflow case * UI/UX cleanup * fix oauth dropdown case * fix oauth selector issue * infra work + activity log * consolidate migration * update modal state * more UI simplification * grammar * update audit report ui * perf improvements * fix tool input scenarios and add dependsOn UI handling * minor comments * fix webhook stability issues + drift detection removal * make dependsOn subblock mapping cleanly stored * fix: harden fork dependent-value mapping (clear stale rows, identity-guard first-sync fallback, perf + cleanup) * address comments * update comment * enforce admin perms for activity api * fix required + dependsOn combo
1 parent 3766582 commit 5a81341

90 files changed

Lines changed: 48261 additions & 206 deletions

File tree

Some content is hidden

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

apps/sim/app/api/webhooks/outbox/process/route.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { db } from '@sim/db'
12
import { createLogger } from '@sim/logger'
23
import { toError } from '@sim/utils/errors'
34
import { type NextRequest, NextResponse } from 'next/server'
@@ -7,6 +8,7 @@ import { processOutboxEvents } from '@/lib/core/outbox/service'
78
import { generateRequestId } from '@/lib/core/utils/request'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
910
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
11+
import { reapStaleBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
1012

1113
const logger = createLogger('OutboxProcessorAPI')
1214

@@ -33,12 +35,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3335
minRemainingMs: 95_000,
3436
})
3537

36-
logger.info('Outbox processing completed', { requestId, ...result })
38+
// Reap fork background-work rows stuck `processing` past their TTL (worker crash /
39+
// restart has no in-task hook). Independent of the outbox; a failure here must not
40+
// fail the outbox run, so it's guarded separately.
41+
let reapedBackgroundWork = 0
42+
try {
43+
reapedBackgroundWork = await reapStaleBackgroundWork(db)
44+
} catch (error) {
45+
logger.error('Background-work reap failed', { requestId, error: toError(error).message })
46+
}
47+
48+
logger.info('Outbox processing completed', { requestId, ...result, reapedBackgroundWork })
3749

3850
return NextResponse.json({
3951
success: true,
4052
requestId,
4153
result,
54+
reapedBackgroundWork,
4255
})
4356
} catch (error) {
4457
logger.error('Outbox processing failed', { requestId, error: toError(error).message })
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { db } from '@sim/db'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getWorkspaceBackgroundWorkContract } from '@/lib/api/contracts/workspace-fork'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { getSession } from '@/lib/auth'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
8+
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
9+
10+
export const GET = withRouteHandler(
11+
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
12+
const session = await getSession()
13+
if (!session?.user?.id) {
14+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
15+
}
16+
17+
const parsed = await parseRequest(getWorkspaceBackgroundWorkContract, req, context)
18+
if (!parsed.success) return parsed.response
19+
const { id } = parsed.data.params
20+
21+
const access = await checkWorkspaceAccess(id, session.user.id)
22+
if (!access.exists) {
23+
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
24+
}
25+
if (!access.canAdmin) {
26+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
27+
}
28+
29+
const rows = await listSurfacedBackgroundWork(db, id)
30+
return NextResponse.json({
31+
items: rows.map((row) => ({
32+
id: row.id,
33+
workspaceId: row.workspaceId,
34+
workflowId: row.workflowId,
35+
kind: row.kind,
36+
status: row.status,
37+
message: row.message,
38+
error: row.error,
39+
metadata: row.metadata ?? null,
40+
startedAt: row.startedAt.toISOString(),
41+
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
42+
})),
43+
})
44+
}
45+
)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { db } from '@sim/db'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { getSession } from '@/lib/auth'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { loadTargetDraftSubBlocks } from '@/lib/workspaces/fork/copy/copy-workflows'
8+
import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
9+
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
10+
import { loadForkBlockMap } from '@/lib/workspaces/fork/mapping/block-map-store'
11+
import {
12+
collectForkDependentReconfigs,
13+
collectForkResourceUsages,
14+
} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
15+
import {
16+
forkDependentValueKey,
17+
loadForkDependentValues,
18+
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
19+
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
20+
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
21+
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
22+
23+
export const GET = withRouteHandler(
24+
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
25+
const session = await getSession()
26+
if (!session?.user?.id) {
27+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
28+
}
29+
30+
const parsed = await parseRequest(getForkDiffContract, req, context)
31+
if (!parsed.success) return parsed.response
32+
const { id } = parsed.data.params
33+
const { otherWorkspaceId, direction } = parsed.data.query
34+
35+
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
36+
37+
const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(
38+
auth.sourceWorkspaceId
39+
)
40+
const plan = await computeForkPromotePlan({
41+
executor: db,
42+
edge: auth.edge,
43+
sourceWorkspaceId: auth.sourceWorkspaceId,
44+
targetWorkspaceId: auth.targetWorkspaceId,
45+
direction,
46+
deployedSourceWorkflows: deployedWorkflows,
47+
sourceStates,
48+
})
49+
50+
// Resolve dependent-reconfig target block ids through the SAME persisted block map the
51+
// sync will use, so a re-pick the modal keys by target block id lands on the block the
52+
// promote actually writes (on push that's the parent's original id, not a derived one).
53+
const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId
54+
const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId)
55+
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
56+
57+
// Stored dependent values are the source of truth for what each selector is set to. Overlay
58+
// them as each field's currentValue so the modal pre-fills what the user actually saved. For
59+
// an edge that predates the store the fallback is the TARGET's own configured value (loaded
60+
// from its draft) - never the source's, which would overwrite the target's selection on the
61+
// first sync. Both the stored read and the draft read are scoped to the plan's replace
62+
// targets, the only workflows with dependents to reconfigure.
63+
const replaceTargetIds = plan.items
64+
.filter((item) => item.mode === 'replace')
65+
.map((item) => item.targetWorkflowId)
66+
const [storedValues, targetDraftByWorkflow] = await Promise.all([
67+
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
68+
loadTargetDraftSubBlocks(db, replaceTargetIds),
69+
])
70+
const storedByKey = new Map(
71+
storedValues.map((entry) => [
72+
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
73+
entry.value,
74+
])
75+
)
76+
77+
// Source block subBlocks keyed by their resolved target identity, so the first-sync draft
78+
// fallback can identity-check a nested tool against the SOURCE dependent tool it came from -
79+
// an index alone may point at a different tool in the target draft, whose value isn't the
80+
// dependent's. Read structurally (only each subblock's `value`), so the in-memory state's
81+
// blocks pass without a cast.
82+
const sourceBlocksByTarget = new Map<string, Map<string, Record<string, { value?: unknown }>>>()
83+
for (const item of plan.items) {
84+
if (item.mode !== 'replace') continue
85+
const state = sourceStates.get(item.sourceWorkflowId)
86+
if (!state) continue
87+
const byBlock = new Map<string, Record<string, { value?: unknown }>>()
88+
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
89+
byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {})
90+
}
91+
sourceBlocksByTarget.set(item.targetWorkflowId, byBlock)
92+
}
93+
94+
const dependentReconfigs = collectForkDependentReconfigs(
95+
plan.items,
96+
sourceStates,
97+
resolveBlockId
98+
).map((field) => ({
99+
...field,
100+
currentValue:
101+
storedByKey.get(
102+
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
103+
) ??
104+
readTargetDraftDependentValue(
105+
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
106+
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
107+
field.subBlockKey
108+
),
109+
}))
110+
111+
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
112+
kind: reference.kind,
113+
sourceId: reference.sourceId,
114+
required: reference.required,
115+
blockName: reference.blockName,
116+
})
117+
118+
// Orient the mapping around the workspace the modal is open in (`id`): show the
119+
// caller's workflow name first, the sync partner's second, so renames are legible.
120+
const currentIsSource = auth.sourceWorkspaceId === id
121+
const workflows = [
122+
...plan.items.map((item) => {
123+
if (item.mode === 'create') {
124+
// The target inherits the source's name, so both sides read the same.
125+
return {
126+
action: 'create' as const,
127+
currentName: item.sourceMeta.name,
128+
otherName: item.sourceMeta.name,
129+
}
130+
}
131+
const targetName = item.targetName ?? item.sourceMeta.name
132+
return {
133+
action: 'update' as const,
134+
currentName: currentIsSource ? item.sourceMeta.name : targetName,
135+
otherName: currentIsSource ? targetName : item.sourceMeta.name,
136+
}
137+
}),
138+
...plan.archivedTargets.map((target) => ({
139+
action: 'archive' as const,
140+
currentName: target.name,
141+
otherName: target.name,
142+
})),
143+
]
144+
145+
return NextResponse.json({
146+
sourceWorkspaceId: auth.sourceWorkspaceId,
147+
targetWorkspaceId: auth.targetWorkspaceId,
148+
willUpdate: plan.willUpdate,
149+
willCreate: plan.willCreate,
150+
willArchive: plan.willArchive,
151+
workflows,
152+
unmappedRequired: plan.unmappedRequired.map(toRef),
153+
unmappedOptional: plan.unmappedOptional.map(toRef),
154+
mcpReauthServerIds: plan.mcpReauthServerIds,
155+
inlineSecretSources: plan.inlineSecretSources,
156+
dependentReconfigs,
157+
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
158+
})
159+
}
160+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { db } from '@sim/db'
2+
import { workspace } from '@sim/db/schema'
3+
import { eq } from 'drizzle-orm'
4+
import type { NextRequest } from 'next/server'
5+
import { NextResponse } from 'next/server'
6+
import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { getSession } from '@/lib/auth'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
11+
import { getForkParent } from '@/lib/workspaces/fork/lineage/lineage'
12+
import { getUndoableRunForTarget } from '@/lib/workspaces/fork/promote/promote-run-store'
13+
14+
export const GET = withRouteHandler(
15+
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
16+
const session = await getSession()
17+
if (!session?.user?.id) {
18+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
19+
}
20+
21+
const parsed = await parseRequest(getForkLineageContract, req, context)
22+
if (!parsed.success) return parsed.response
23+
const { id: workspaceId } = parsed.data.params
24+
25+
await assertWorkspaceAdminAccess(workspaceId, session.user.id)
26+
27+
const [parent, run] = await Promise.all([
28+
getForkParent(workspaceId),
29+
getUndoableRunForTarget(db, workspaceId),
30+
])
31+
32+
let undoableRun: {
33+
otherWorkspaceId: string
34+
otherName: string
35+
direction: 'push' | 'pull'
36+
} | null = null
37+
if (run) {
38+
const [other] = await db
39+
.select({ name: workspace.name })
40+
.from(workspace)
41+
.where(eq(workspace.id, run.sourceWorkspaceId))
42+
.limit(1)
43+
undoableRun = {
44+
otherWorkspaceId: run.sourceWorkspaceId,
45+
otherName: other?.name ?? 'workspace',
46+
direction: run.direction,
47+
}
48+
}
49+
50+
return NextResponse.json({
51+
workspaceId,
52+
parent,
53+
undoableRun,
54+
})
55+
}
56+
)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { db } from '@sim/db'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import {
4+
getForkMappingContract,
5+
updateForkMappingContract,
6+
} from '@/lib/api/contracts/workspace-fork'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { getSession } from '@/lib/auth'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
11+
import { acquireForkEdgeLock, setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
12+
import {
13+
applyForkMappingEntries,
14+
getForkMappingView,
15+
validateForkMappingTargets,
16+
} from '@/lib/workspaces/fork/mapping/mapping-service'
17+
18+
export const GET = withRouteHandler(
19+
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
20+
const session = await getSession()
21+
if (!session?.user?.id) {
22+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const parsed = await parseRequest(getForkMappingContract, req, context)
26+
if (!parsed.success) return parsed.response
27+
const { id } = parsed.data.params
28+
const { otherWorkspaceId, direction } = parsed.data.query
29+
30+
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
31+
32+
const { entries } = await getForkMappingView({
33+
edge: auth.edge,
34+
sourceWorkspaceId: auth.sourceWorkspaceId,
35+
targetWorkspaceId: auth.targetWorkspaceId,
36+
})
37+
38+
return NextResponse.json({
39+
childWorkspaceId: auth.edge.childWorkspaceId,
40+
parentWorkspaceId: auth.edge.parentWorkspaceId,
41+
sourceWorkspaceId: auth.sourceWorkspaceId,
42+
targetWorkspaceId: auth.targetWorkspaceId,
43+
entries,
44+
})
45+
}
46+
)
47+
48+
export const PUT = withRouteHandler(
49+
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
50+
const session = await getSession()
51+
if (!session?.user?.id) {
52+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
53+
}
54+
55+
const parsed = await parseRequest(updateForkMappingContract, req, context)
56+
if (!parsed.success) return parsed.response
57+
const { id } = parsed.data.params
58+
const { otherWorkspaceId, direction, entries } = parsed.data.body
59+
60+
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
61+
62+
await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries)
63+
64+
// Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted
65+
// then re-upserted parent-side) can't leave duplicate rows for the same source. Same
66+
// edge lock promote/rollback use, with a bounded wait.
67+
const updated = await db.transaction(async (tx) => {
68+
await setForkLockTimeout(tx)
69+
await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId)
70+
return applyForkMappingEntries(tx, auth.edge, session.user.id, direction, entries)
71+
})
72+
73+
return NextResponse.json({ success: true as const, updated })
74+
}
75+
)

0 commit comments

Comments
 (0)