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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions apps/sim/app/api/workspaces/[id]/background-work/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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'
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'

export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
Expand All @@ -18,13 +18,9 @@ export const GET = withRouteHandler(
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.canAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// The fork Activity feed is a fork feature: gate it behind the same forking-enabled +
// workspace-admin check the other fork routes use, instead of a bare access check.
await assertWorkspaceAdminAccess(id, session.user.id)

const rows = await listSurfacedBackgroundWork(db, id)
return NextResponse.json({
Expand Down
39 changes: 35 additions & 4 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
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'
Expand All @@ -16,6 +18,8 @@ import {
forkDependentValueKey,
loadForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources'
import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs'
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
Expand Down Expand Up @@ -63,10 +67,17 @@ export const GET = withRouteHandler(
const replaceTargetIds = plan.items
.filter((item) => item.mode === 'replace')
.map((item) => item.targetWorkflowId)
const [storedValues, targetDraftByWorkflow] = await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
])
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
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),
Expand Down Expand Up @@ -108,6 +119,24 @@ export const GET = withRouteHandler(
),
}))

// 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]))
const clearedRefs = 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,
Expand Down Expand Up @@ -155,6 +184,8 @@ export const GET = withRouteHandler(
inlineSecretSources: plan.inlineSecretSources,
dependentReconfigs,
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
copyableUnmapped: plan.copyableUnmapped,
clearedRefs,
})
}
)
3 changes: 2 additions & 1 deletion apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(promoteForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction, dependentValues } = parsed.data.body
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body

const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)

Expand All @@ -36,6 +36,7 @@ export const POST = withRouteHandler(
direction,
userId: session.user.id,
dependentValues,
copyResources,
requestId,
})

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/workspaces/[id]/fork/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const POST = withRouteHandler(
knowledgeBases: copy?.knowledgeBases ?? [],
customTools: copy?.customTools ?? [],
skills: copy?.skills ?? [],
mcpServers: copy?.mcpServers ?? [],
workflowMcpServers: copy?.workflowMcpServers ?? [],
},
requestId,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useMarkdownMentions } from './use-markdown-mentions'
interface UseEditorMentionsOptions {
/** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */
navigable?: boolean
/** Force the `@` insertion menu off even with a workspace; existing tags still render. */
disableTagging?: boolean
}

/**
Expand All @@ -20,17 +22,18 @@ export function useEditorMentions(
const [active, setActive] = useState(false)
const items = useMarkdownMentions(workspaceId, { enabled: active })
const navigable = options?.navigable ?? false
const disableTagging = options?.disableTagging ?? false

useEffect(() => {
if (!editor) return
const hasWorkspace = Boolean(workspaceId)
editor.storage.mention.enabled = hasWorkspace
const taggingOn = Boolean(workspaceId) && !disableTagging
editor.storage.mention.enabled = taggingOn
editor.storage.mention.navigable = navigable
editor.storage.mention.onOpen = hasWorkspace ? () => setActive(true) : null
editor.storage.mention.onOpen = taggingOn ? () => setActive(true) : null
return () => {
editor.storage.mention.onOpen = null
}
}, [editor, workspaceId, navigable])
}, [editor, workspaceId, navigable, disableTagging])

useEffect(() => {
editor?.storage.mention.store.set(items)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ interface RichMarkdownEditorProps {
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
/** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */
disableTagging?: boolean
}

/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
Expand All @@ -72,6 +74,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
streamIsIncremental,
disableStreamingAutoScroll = false,
previewContextKey,
disableTagging,
}: RichMarkdownEditorProps) {
const {
content,
Expand Down Expand Up @@ -113,6 +116,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
autoFocus={autoFocus}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
disableTagging={disableTagging}
onChange={setDraftContent}
onSaveShortcut={saveImmediately}
/>
Expand All @@ -131,6 +135,7 @@ interface LoadedRichMarkdownEditorProps {
/** See {@link RichMarkdownEditorProps.streamIsIncremental}. */
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
disableTagging?: boolean
onChange: (markdown: string) => void
onSaveShortcut: () => Promise<void>
}
Expand All @@ -155,6 +160,7 @@ export function LoadedRichMarkdownEditor({
autoFocus,
streamIsIncremental,
disableStreamingAutoScroll,
disableTagging,
onChange,
onSaveShortcut,
}: LoadedRichMarkdownEditorProps) {
Expand Down Expand Up @@ -339,7 +345,7 @@ export function LoadedRichMarkdownEditor({
}
}, [editor])

useEditorMentions(editor, workspaceId, { navigable: true })
useEditorMentions(editor, workspaceId, { navigable: true, disableTagging })

const wasStreamingRef = useRef(streamingAtMountRef.current)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ interface RichMarkdownFieldProps {
error?: boolean
/** Enables the `@` mention menu scoped to this workspace. Omit to disable mentions. */
workspaceId?: string
/** Force the `@` tag-insertion menu off even with a workspace set (existing tags still render). */
disableTagging?: boolean
/**
* Intercepts a plain-text paste before the editor handles it. Return `true` to consume the paste
* (e.g. a full document the host destructures elsewhere); `false` to fall through to normal
Expand All @@ -62,6 +64,7 @@ function LoadedRichMarkdownField({
maxHeight = 360,
error = false,
workspaceId,
disableTagging,
onPasteText,
}: RichMarkdownFieldProps) {
const containerRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -166,7 +169,7 @@ function LoadedRichMarkdownField({
if (editor.isEditable !== !disabled) editor.setEditable(!disabled)
}, [editor, value, isStreaming, disabled])

useEditorMentions(editor, workspaceId)
useEditorMentions(editor, workspaceId, { disableTagging })

return (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export function VersionDescriptionModal({
isStreaming={isGenerating}
error={description.length > MAX_DESCRIPTION_LENGTH}
workspaceId={workspaceId}
disableTagging
/>
</ChipModalField>
<ChipModalError>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents'
import { getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'

const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
({ name: 'Tool', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig

describe('clearDependentToolParams', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('clears a non-empty dependent when its parent changes', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
])
)
const result = clearDependentToolParams(
'gmail',
{ credential: 'cred-2', folder: 'INBOX' },
'credential'
)
expect(result.folder).toBe('')
// The changed param itself is untouched.
expect(result.credential).toBe('cred-2')
})

it('clears transitively (a grandchild dependent is also cleared)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
{ id: 'thread', title: 'Thread', type: 'short-input', dependsOn: ['folder'] },
])
)
const result = clearDependentToolParams(
'gmail',
{ credential: 'cred-2', folder: 'INBOX', thread: 't-1' },
'credential'
)
expect(result.folder).toBe('')
expect(result.thread).toBe('')
})

it('clears a dependent when a canonical-pair member changes (advanced member, dependent on the canonical id)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{
id: 'credential',
title: 'Credential',
type: 'oauth-input',
canonicalParamId: 'credential',
mode: 'basic',
},
{
id: 'manualCredential',
title: 'Credential ID',
type: 'short-input',
canonicalParamId: 'credential',
mode: 'advanced',
},
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
])
)
const result = clearDependentToolParams(
'gmail',
{ manualCredential: 'mc-2', folder: 'INBOX' },
'manualCredential'
)
// The shared walk expands the canonical group, so an advanced-member change clears the dependent.
expect(result.folder).toBe('')
})

it('leaves an already-empty dependent and a non-dependent param untouched (same reference)', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
{ id: 'subject', title: 'Subject', type: 'short-input' },
])
)
const params = { credential: 'cred-2', folder: '', subject: 'keep' }
const result = clearDependentToolParams('gmail', params, 'credential')
// The only dependent is already empty, so nothing changes - the same reference is returned.
expect(result).toBe(params)
expect(result.subject).toBe('keep')
})

it('returns equivalent params when the changed param has no dependents', () => {
vi.mocked(getBlock).mockReturnValue(
blockWith([
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
{ id: 'subject', title: 'Subject', type: 'short-input' },
])
)
const params = { credential: 'cred-2', subject: 'hello' }
const result = clearDependentToolParams('gmail', params, 'subject')
expect(result).toBe(params)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
import { getBlock } from '@/blocks/registry'

/**
* Clear every TRANSITIVE `dependsOn` descendant of `changedParamId` in a nested tool's params,
* mirroring the top-level block clear (`use-collaborative-workflow`). Reuses the shared
* {@link getWorkflowSearchDependentClears} walk - transitive BFS plus canonical-pair expansion, so a
* basic OR advanced member change clears the dependent - so both surfaces clear identically. Only
* descendants that currently hold a non-empty value are reset to `''`; the changed param itself and
* non-descendants are untouched. Returns the same reference when nothing changed.
*/
export function clearDependentToolParams(
toolType: string,
params: Record<string, string>,
changedParamId: string
): Record<string, string> {
const subBlocks = getBlock(toolType)?.subBlocks ?? []
let next: Record<string, string> | null = null
for (const { subBlockId } of getWorkflowSearchDependentClears(subBlocks, changedParamId)) {
if (!params[subBlockId]) continue
next ??= { ...params }
next[subBlockId] = ''
}
return next ?? params
}
Loading
Loading