diff --git a/apps/sim/app/api/workspaces/[id]/background-work/route.ts b/apps/sim/app/api/workspaces/[id]/background-work/route.ts index 066ba5c4a8b..e7d2862ac47 100644 --- a/apps/sim/app/api/workspaces/[id]/background-work/route.ts +++ b/apps/sim/app/api/workspaces/[id]/background-work/route.ts @@ -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 }> }) => { @@ -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({ diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts index 4631e16fda2..e717958e081 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -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' @@ -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' @@ -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), @@ -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() + 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, @@ -155,6 +184,8 @@ export const GET = withRouteHandler( inlineSecretSources: plan.inlineSecretSources, dependentReconfigs, resourceUsages: collectForkResourceUsages(plan.items, sourceStates), + copyableUnmapped: plan.copyableUnmapped, + clearedRefs, }) } ) diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts index eba0e7b1c8f..6b6228620eb 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts @@ -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) @@ -36,6 +36,7 @@ export const POST = withRouteHandler( direction, userId: session.user.id, dependentValues, + copyResources, requestId, }) diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.ts b/apps/sim/app/api/workspaces/[id]/fork/route.ts index 27bea2f1b99..b3183465a6c 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/route.ts @@ -39,7 +39,7 @@ export const POST = withRouteHandler( knowledgeBases: copy?.knowledgeBases ?? [], customTools: copy?.customTools ?? [], skills: copy?.skills ?? [], - mcpServers: copy?.mcpServers ?? [], + workflowMcpServers: copy?.workflowMcpServers ?? [], }, requestId, }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts index edd533d1dd0..5ac2ce036fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts @@ -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 } /** @@ -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) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index db394d23174..52f8982c1bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -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. */ @@ -72,6 +74,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ streamIsIncremental, disableStreamingAutoScroll = false, previewContextKey, + disableTagging, }: RichMarkdownEditorProps) { const { content, @@ -113,6 +116,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({ autoFocus={autoFocus} streamIsIncremental={streamIsIncremental} disableStreamingAutoScroll={disableStreamingAutoScroll} + disableTagging={disableTagging} onChange={setDraftContent} onSaveShortcut={saveImmediately} /> @@ -131,6 +135,7 @@ interface LoadedRichMarkdownEditorProps { /** See {@link RichMarkdownEditorProps.streamIsIncremental}. */ streamIsIncremental?: boolean disableStreamingAutoScroll?: boolean + disableTagging?: boolean onChange: (markdown: string) => void onSaveShortcut: () => Promise } @@ -155,6 +160,7 @@ export function LoadedRichMarkdownEditor({ autoFocus, streamIsIncremental, disableStreamingAutoScroll, + disableTagging, onChange, onSaveShortcut, }: LoadedRichMarkdownEditorProps) { @@ -339,7 +345,7 @@ export function LoadedRichMarkdownEditor({ } }, [editor]) - useEditorMentions(editor, workspaceId, { navigable: true }) + useEditorMentions(editor, workspaceId, { navigable: true, disableTagging }) const wasStreamingRef = useRef(streamingAtMountRef.current) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 0df1030a7a8..202ed9b4651 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -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 @@ -62,6 +64,7 @@ function LoadedRichMarkdownField({ maxHeight = 360, error = false, workspaceId, + disableTagging, onPasteText, }: RichMarkdownFieldProps) { const containerRef = useRef(null) @@ -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 (
MAX_DESCRIPTION_LENGTH} workspaceId={workspaceId} + disableTagging /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.test.ts new file mode 100644 index 00000000000..0871609bca1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.test.ts @@ -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) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.ts new file mode 100644 index 00000000000..a860066f290 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents.ts @@ -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, + changedParamId: string +): Record { + const subBlocks = getBlock(toolType)?.subBlocks ?? [] + let next: Record | null = null + for (const { subBlockId } of getWorkflowSearchDependentClears(subBlocks, changedParamId)) { + if (!params[subBlockId]) continue + next ??= { ...params } + next[subBlockId] = '' + } + return next ?? params +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index d6470cac6bd..7eefba9528b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -42,6 +42,7 @@ import { import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector' import { ParameterWithLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter' import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer' +import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents' import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' import { isCustomToolAlreadySelected, @@ -105,31 +106,11 @@ import { resolveCanonicalMode, resolveDependencyValue, type SubBlockCondition, + scopeCanonicalModesForTool, } from '@/tools/params-resolver' const logger = createLogger('ToolInput') -/** - * Extracts canonical mode overrides scoped to a specific tool type. - * Canonical modes are stored with `{blockType}:{canonicalId}` keys to prevent - * cross-tool collisions when multiple tools share the same canonicalParamId. - */ -function scopeCanonicalOverrides( - overrides: CanonicalModeOverrides | undefined, - blockType: string | undefined -): CanonicalModeOverrides | undefined { - if (!overrides || !blockType) return undefined - const prefix = `${blockType}:` - let scoped: CanonicalModeOverrides | undefined - for (const [key, val] of Object.entries(overrides)) { - if (key.startsWith(prefix) && val) { - if (!scoped) scoped = {} - scoped[key.slice(prefix.length)] = val - } - } - return scoped -} - /** * Renders the input for workflow_executor's inputMapping parameter. * This is a special case that doesn't map to any SubBlockConfig, so it's kept here. @@ -534,15 +515,7 @@ export const ToolInput = memo(function ToolInput({ const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type) if (!blockConfig?.subBlocks) continue const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks) - const scopedOverrides: CanonicalModeOverrides = {} - if (canonicalModeOverrides) { - for (const [key, val] of Object.entries(canonicalModeOverrides)) { - const prefix = `${tool.type}:` - if (key.startsWith(prefix) && val) { - scopedOverrides[key.slice(prefix.length)] = val as 'basic' | 'advanced' - } - } - } + const scopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type) const reactiveSubBlock = blockConfig.subBlocks.find( (sb: { reactiveCondition?: unknown }) => sb.reactiveCondition ) @@ -976,17 +949,17 @@ export const ToolInput = memo(function ToolInput({ if (isPreview || disabled) return setStoreValue( - selectedTools.map((tool, index) => - index === toolIndex - ? { - ...tool, - params: { - ...tool.params, - [paramId]: paramValue, - }, - } - : tool - ) + selectedTools.map((tool, index) => { + if (index !== toolIndex) return tool + // Clear the changed param's transitive `dependsOn` descendants (mirrors the top-level + // block clear), so a child scoped to the old parent isn't left stale. + const params = clearDependentToolParams( + tool.type, + { ...tool.params, [paramId]: paramValue }, + paramId + ) + return { ...tool, params } + }) ) }, [isPreview, disabled, selectedTools, setStoreValue] @@ -1706,7 +1679,7 @@ export const ToolInput = memo(function ToolInput({ }) : null - const toolScopedOverrides = scopeCanonicalOverrides(canonicalModeOverrides, tool.type) + const toolScopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type) const subBlocksResult: SubBlocksForToolInput | null = !isCustomTool && !isMcpTool && currentToolId @@ -1731,6 +1704,7 @@ export const ToolInput = memo(function ToolInput({ subBlocks: toolBlock!.subBlocks, canonicalIndex: toolCanonicalIndex, values: { operation: tool.operation, ...tool.params }, + overrides: toolScopedOverrides, }) : tool.params || {} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel.tsx index d4327c9d897..a559bf14786 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel.tsx @@ -122,7 +122,7 @@ function jobReport(job: BackgroundWorkItem): JobReport { addGroup('Files', m.fileNames) addGroup('Custom tools', m.customToolNames) addGroup('Skills', m.skillNames) - addGroup('MCP servers', m.mcpServerNames) + addGroup('Workflow MCP servers', m.workflowMcpServerNames) // Pre-names entries fall back to the per-kind counts. if (groups.length === 0) { const counts = [ @@ -139,6 +139,9 @@ function jobReport(job: BackgroundWorkItem): JobReport { if (m.failed && m.failed > 0) { notes.push({ value: `${plural(m.failed, 'resource')} failed to copy`, warning: true }) } + if (m.clearingFailed) { + notes.push({ value: 'Reference cleanup incomplete', warning: true }) + } return { groups, notes } } @@ -247,9 +250,9 @@ function ForkJobRow({ job }: { job: BackgroundWorkItem }) { {report.groups.map((group) => ( ))} - {report.notes.map((note) => ( + {report.notes.map((note, index) => ( { + it('groups files under their folder and lifts un-foldered files to the root bucket', () => { + const { folders, rootFiles } = groupForkFilesIntoFolders([ + { id: 'f1', label: 'b.png', folderId: 'fld-1', folderName: 'Images' }, + { id: 'f2', label: 'a.png', folderId: 'fld-1', folderName: 'Images' }, + { id: 'f3', label: 'root.txt', folderId: null, folderName: null }, + { id: 'f4', label: 'doc.pdf', folderId: 'fld-2', folderName: 'Docs' }, + ]) + // Folders are sorted by name; each folder's files are sorted by label. + expect(folders.map((folder) => folder.name)).toEqual(['Docs', 'Images']) + expect(folders[1].files.map((file) => file.label)).toEqual(['a.png', 'b.png']) + expect(rootFiles.map((file) => file.label)).toEqual(['root.txt']) + }) + + it('treats a file whose folder was deleted (id set, name null) as a root file', () => { + const { folders, rootFiles } = groupForkFilesIntoFolders([ + { id: 'f1', label: 'orphan.png', folderId: 'fld-deleted', folderName: null }, + ]) + expect(folders).toEqual([]) + expect(rootFiles.map((file) => file.id)).toEqual(['f1']) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-file-tree/fork-file-tree.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-file-tree/fork-file-tree.tsx new file mode 100644 index 00000000000..76fa5a5e2b1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-file-tree/fork-file-tree.tsx @@ -0,0 +1,197 @@ +'use client' + +import { useId, useState } from 'react' +import { Checkbox, ChevronDown, cn } from '@sim/emcn' + +export interface ForkFileTreeItem { + id: string + label: string +} + +export interface ForkFileTreeFolder { + id: string + name: string + files: ForkFileTreeItem[] +} + +/** A flat copyable file with its folder grouping, before {@link groupForkFilesIntoFolders}. */ +export interface ForkFlatFile { + id: string + label: string + folderId: string | null + folderName: string | null +} + +/** + * Group flat files into folders (sorted by name, each file sorted by label) plus a root bucket + * for files with no folder. A file whose folder id is set but whose name is null (its folder was + * deleted) falls into the root bucket, so it stays selectable rather than hiding under a phantom. + */ +export function groupForkFilesIntoFolders(files: ForkFlatFile[]): { + folders: ForkFileTreeFolder[] + rootFiles: ForkFileTreeItem[] +} { + const folderById = new Map() + const rootFiles: ForkFileTreeItem[] = [] + for (const file of files) { + const item: ForkFileTreeItem = { id: file.id, label: file.label } + if (file.folderId && file.folderName) { + let folder = folderById.get(file.folderId) + if (!folder) { + folder = { id: file.folderId, name: file.folderName, files: [] } + folderById.set(file.folderId, folder) + } + folder.files.push(item) + } else { + rootFiles.push(item) + } + } + const folders = Array.from(folderById.values()).sort((a, b) => a.name.localeCompare(b.name)) + for (const folder of folders) folder.files.sort((a, b) => a.label.localeCompare(b.label)) + rootFiles.sort((a, b) => a.label.localeCompare(b.label)) + return { folders, rootFiles } +} + +interface ForkFileTreeProps { + folders: ForkFileTreeFolder[] + rootFiles: ForkFileTreeItem[] + isSelected: (fileId: string) => boolean + onToggleFile: (fileId: string, checked: boolean) => void + /** Toggle every file in a folder at once (the folder-level select-all). */ + onToggleMany: (fileIds: string[], checked: boolean) => void + disabled?: boolean +} + +/** + * Folder ▸ file collapsible tree shared by the fork picker and the sync copy selector, so both + * surfaces group files identically. Each folder is a tri-state select-all header that expands to + * its files; files with no folder render at the top level. Files are the only copyable kind that + * nests - tables, knowledge bases, custom tools, and skills stay flat at the top level. + */ +export function ForkFileTree({ + folders, + rootFiles, + isSelected, + onToggleFile, + onToggleMany, + disabled = false, +}: ForkFileTreeProps) { + return ( +
+ {folders.map((folder) => ( + + ))} + {rootFiles.map((file) => ( + + ))} +
+ ) +} + +interface ForkFileFolderRowProps { + folder: ForkFileTreeFolder + isSelected: (fileId: string) => boolean + onToggleFile: (fileId: string, checked: boolean) => void + onToggleMany: (fileIds: string[], checked: boolean) => void + disabled: boolean +} + +function ForkFileFolderRow({ + folder, + isSelected, + onToggleFile, + onToggleMany, + disabled, +}: ForkFileFolderRowProps) { + const [expanded, setExpanded] = useState(false) + const fileIds = folder.files.map((file) => file.id) + const total = fileIds.length + const selectedCount = fileIds.filter(isSelected).length + const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate' + + return ( +
+
+ onToggleMany(fileIds, headerState !== true)} + disabled={disabled} + /> + +
+ {expanded ? ( +
+ {folder.files.map((file) => ( + + ))} +
+ ) : null} +
+ ) +} + +interface ForkFileRowProps { + file: ForkFileTreeItem + checked: boolean + onToggle: (fileId: string, checked: boolean) => void + disabled: boolean +} + +function ForkFileRow({ file, checked, onToggle, disabled }: ForkFileRowProps) { + const itemId = useId() + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx new file mode 100644 index 00000000000..964aadd907e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx @@ -0,0 +1,215 @@ +'use client' + +import { useId, useMemo, useState } from 'react' +import { Checkbox, ChevronDown, ChipInput, cn } from '@sim/emcn' +import { Search } from 'lucide-react' +import { + ForkFileTree, + type ForkFlatFile, + groupForkFilesIntoFolders, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-file-tree/fork-file-tree' + +/** A flat copyable resource (table / KB / custom tool / skill / MCP server) in the picker. */ +export interface ForkResourcePickerItem { + id: string + label: string +} + +/** Show the inline search once a kind has more entries than fit comfortably. */ +const SEARCH_THRESHOLD = 8 + +interface ResourceKindRowProps { + label: string + items: ForkResourcePickerItem[] + selected: Set + /** Toggle the given ids on/off. Used for select-all over the currently-VISIBLE (filtered) subset. */ + onToggleMany: (ids: string[], checked: boolean) => void + onToggleItem: (id: string, checked: boolean) => void + disabled?: boolean +} + +/** + * One expandable resource kind in the fork / sync copy 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. Shared by the fork modal's "Copy resources" + * and the sync modal's "Copy resources" so the two surfaces stay identical. Files nest in a + * folder tree instead - use {@link FileKindRow}. + */ +export function ResourceKindRow({ + label, + items, + selected, + onToggleMany, + onToggleItem, + disabled = false, +}: ResourceKindRowProps) { + const [expanded, setExpanded] = useState(false) + const [query, setQuery] = useState('') + const fieldId = useId() + + const filtered = useMemo(() => { + const trimmed = query.trim().toLowerCase() + if (!trimmed) return items + return items.filter((item) => item.label.toLowerCase().includes(trimmed)) + }, [items, query]) + + // Count + header state + select-all are scoped to the VISIBLE (filtered) items so a search never + // selects or counts hidden ones. With no filter, `filtered === items`, so behavior is unchanged. + const total = filtered.length + const selectedCount = filtered.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0) + const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate' + + return ( +
+
+ + onToggleMany( + filtered.map((item) => item.id), + 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} +
+ ) +} + +interface FileKindRowProps { + label: string + files: ForkFlatFile[] + selected: Set + onToggleAll: (selectAll: boolean) => void + onToggleItem: (id: string, checked: boolean) => void + onToggleMany: (ids: string[], checked: boolean) => void + disabled?: boolean +} + +/** + * The Files kind: a tri-state "select all" header (count selected / total) that expands to a + * folder ▸ file tree, so the user can copy a whole folder or a specific file. Files are the only + * copyable kind that nests; every other kind uses the flat {@link ResourceKindRow}. Shared by the + * fork and sync copy pickers so both group files identically. + */ +export function FileKindRow({ + label, + files, + selected, + onToggleAll, + onToggleItem, + onToggleMany, + disabled = false, +}: FileKindRowProps) { + const [expanded, setExpanded] = useState(false) + + const total = files.length + const selectedCount = files.filter((file) => selected.has(file.id)).length + const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate' + + const { folders, rootFiles } = useMemo(() => groupForkFilesIntoFolders(files), [files]) + + return ( +
+
+ onToggleAll(headerState !== true)} + disabled={disabled} + /> + +
+ + {expanded ? ( +
+ selected.has(id)} + onToggleFile={onToggleItem} + onToggleMany={onToggleMany} + disabled={disabled} + /> +
+ ) : 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 index 3ed9a6c7b64..9083d7da184 100644 --- 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 @@ -1,9 +1,7 @@ 'use client' -import { useEffect, useId, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { - Checkbox, - ChevronDown, Chip, ChipConfirmModal, ChipCopyInput, @@ -15,19 +13,19 @@ import { type ChipModalFooterSlotAction, ChipModalHeader, ChipModalTabs, - cn, Tooltip, toast, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { Search } from 'lucide-react' +import { AlertTriangle } from 'lucide-react' import { useRouter } from 'next/navigation' -import type { - ForkCopyableResource, - GetForkResourcesResponse, -} from '@/lib/api/contracts/workspace-fork' +import type { GetForkResourcesResponse } from '@/lib/api/contracts/workspace-fork' 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 { + FileKindRow, + ResourceKindRow, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker' import { type ForkDirection, useForkResources, @@ -57,127 +55,24 @@ const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [ { key: 'knowledgeBases', label: 'Knowledge bases' }, { key: 'customTools', label: 'Custom tools' }, { key: 'skills', label: 'Skills' }, - { key: 'mcpServers', label: 'MCP servers' }, + { key: 'workflowMcpServers', label: 'Workflow 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(), + workflowMcpServers: 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} -
- ) +const fullSelection = (data: GetForkResourcesResponse): ResourceSelection => { + const selection = emptySelection() + for (const kind of RESOURCE_KINDS) { + selection[kind.key] = new Set((data[kind.key] ?? []).map((item) => item.id)) + } + return selection } /** @@ -200,6 +95,7 @@ export function ForkWorkspaceModal({ const resources = useForkResources(sourceWorkspaceId, open) const [name, setName] = useState('') const [selected, setSelected] = useState(emptySelection) + const [defaulted, setDefaulted] = useState(false) const [error, setError] = useState(null) const [activeTab, setActiveTab] = useState<'config' | 'activity'>('config') @@ -210,6 +106,7 @@ export function ForkWorkspaceModal({ if (open) { setName(`${sourceWorkspaceName} (fork)`) setSelected(emptySelection()) + setDefaulted(false) setError(null) setActiveTab('config') setForkedWorkspace(null) @@ -217,6 +114,12 @@ export function ForkWorkspaceModal({ } }, [open, sourceWorkspaceName]) + useEffect(() => { + if (!open || !resources.data || defaulted) return + setDefaulted(true) + setSelected(fullSelection(resources.data)) + }, [open, resources.data, defaulted]) + const isForking = forkWorkspace.isPending const availableKinds = useMemo( @@ -224,6 +127,15 @@ export function ForkWorkspaceModal({ [resources.data] ) + const hasDeselection = useMemo( + () => + defaulted && + availableKinds.some( + (kind) => selected[kind.key].size < (resources.data?.[kind.key]?.length ?? 0) + ), + [defaulted, availableKinds, selected, 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 @@ -239,11 +151,14 @@ export function ForkWorkspaceModal({ return } const trimmed = name.trim() - if (!trimmed || isForking) return + // Block until the resources query resolves: building `copy` from an unloaded `resources.data` + // would send an empty selection and silently clear every reference in the fork. The Fork + // action is disabled in this state too; this is the defense-in-depth guard. + if (!trimmed || isForking || !resources.data) return setError(null) - const copy = resources.data - ? Object.fromEntries(RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])])) - : undefined + const copy = Object.fromEntries( + RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])]) + ) forkWorkspace.mutate( { workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } }, { @@ -360,34 +275,83 @@ export function ForkWorkspaceModal({ {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. -

+ {availableKinds.map((kind) => + kind.key === 'files' ? ( + + setSelected((prev) => ({ + ...prev, + files: selectAll + ? new Set((resources.data?.files ?? []).map((item) => item.id)) + : new Set(), + })) + } + onToggleItem={(id, checked) => + setSelected((prev) => { + const next = new Set(prev.files) + if (checked) next.add(id) + else next.delete(id) + return { ...prev, files: next } + }) + } + onToggleMany={(ids, checked) => + setSelected((prev) => { + const next = new Set(prev.files) + for (const id of ids) { + if (checked) next.add(id) + else next.delete(id) + } + return { ...prev, files: next } + }) + } + disabled={isForking} + /> + ) : ( + + setSelected((prev) => { + const next = new Set(prev[kind.key]) + for (const id of ids) { + if (checked) next.add(id) + else next.delete(id) + } + return { ...prev, [kind.key]: next } + }) + } + 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} + /> + ) + )} + {hasDeselection ? ( +
+ + + Some resources are not selected — references to them in your workflows + will be cleared in the fork. + +
+ ) : ( +

+ Everything referenced by your workflows is copied. Deselect a resource to + skip it — its references will be cleared. +

+ )}
) : null} @@ -414,8 +378,14 @@ export function ForkWorkspaceModal({ : { label: isForking ? 'Forking...' : 'Fork', onClick: handleSubmit, - // At the cap the button stays clickable (no name needed) so it can route to upgrade. - disabled: isForking || (canFork && !name.trim()), + // At the cap the button stays clickable (no name needed) so it can route to + // upgrade. Otherwise it needs a name AND the resources query loaded - forking + // before `resources.data` arrives would clear every reference (P1-C). + disabled: isForking || (canFork && (!name.trim() || !resources.data)), + disabledTooltip: + canFork && name.trim() && !resources.data + ? 'Loading workspace resources…' + : undefined, } } /> @@ -437,7 +407,15 @@ export function ForkWorkspaceModal({ pending: rollback.isPending, pendingLabel: 'Rolling back...', }} - /> + > +
+ + + Resources copied into this workspace during syncs may remain afterward — rollback + restores workflows to their prior versions but does not remove copied resources. + +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts new file mode 100644 index 00000000000..677c6c8f4fc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' +import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' + +type ReferenceRef = Extract +type WorkflowRef = Extract +type DependentRef = Extract + +const base = { + targetWorkflowId: 'wf-tgt', + workflowName: 'Workflow', + blockId: 'block-1', + blockLabel: 'Block', + sourceLabel: 'Source', +} + +const referenceRef = ( + kind: ReferenceRef['kind'], + sourceId: string, + fieldLabel = 'Field' +): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId }) + +const workflowRef = (sourceId: string, fieldLabel = 'Workflow'): WorkflowRef => ({ + ...base, + fieldLabel, + cause: 'workflow', + kind: 'workflow', + sourceId, +}) + +const dependentRef = ( + parentKind: DependentRef['parentKind'], + parentSourceId: string, + fieldLabel = 'Field' +): DependentRef => ({ + ...base, + fieldLabel, + cause: 'dependent', + kind: parentKind, + sourceId: parentSourceId, + parentKind, + parentSourceId, +}) + +// The modal's predicate is `mapped || copied`; here we model each disposition as a resolved key so +// the document-under-KB case is exercised for both a copied parent and a mapped parent. +const resolvedKeys = (...keys: string[]) => { + const set = new Set(keys) + return (kind: string, sourceId: string) => set.has(`${kind}:${sourceId}`) +} + +const documentDependent = dependentRef('knowledge-base', 'kb-1', 'Document') + +describe('selectVisibleClearedRefs', () => { + it('drops a document dependent when its parent KB is selected for copy', () => { + expect( + selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1')) + ).toEqual([]) + }) + + it('drops a document dependent when its parent KB is mapped', () => { + // Map vs copy both resolve the parent through the same predicate; modeled identically here. + expect( + selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1')) + ).toEqual([]) + }) + + it('keeps a document dependent while its parent KB is neither mapped nor copied', () => { + expect(selectVisibleClearedRefs([documentDependent], resolvedKeys())).toEqual([ + documentDependent, + ]) + }) + + it('keeps a credential-anchored dependent even when the credential is mapped (label still clears)', () => { + const labelDependent = dependentRef('credential', 'cred-1', 'Label') + // A mapped credential remaps to a different account, so the account-scoped label is cleared + // regardless - the entry must stay even though the parent is "resolved". + expect(selectVisibleClearedRefs([labelDependent], resolvedKeys('credential:cred-1'))).toEqual([ + labelDependent, + ]) + expect(selectVisibleClearedRefs([labelDependent], resolvedKeys())).toEqual([labelDependent]) + }) + + it('keeps a table-anchored dependent even when the table is copied/mapped (column still clears)', () => { + const columnDependent = dependentRef('table', 'tbl-1', 'Column') + expect(selectVisibleClearedRefs([columnDependent], resolvedKeys('table:tbl-1'))).toEqual([ + columnDependent, + ]) + }) + + it('drops the parent KB reference AND its child document together when the KB is resolved', () => { + const kbReference = referenceRef('knowledge-base', 'kb-1', 'Knowledge Base') + expect( + selectVisibleClearedRefs( + [kbReference, documentDependent], + resolvedKeys('knowledge-base:kb-1') + ) + ).toEqual([]) + }) + + it('applies the same predicate to a reference entry (drops resolved, keeps unresolved)', () => { + const credentialReference = referenceRef('credential', 'cred-1') + expect( + selectVisibleClearedRefs([credentialReference], resolvedKeys('credential:cred-1')) + ).toEqual([]) + expect(selectVisibleClearedRefs([credentialReference], resolvedKeys())).toEqual([ + credentialReference, + ]) + }) + + it('always keeps a workflow reference (it cannot be resolved in the modal)', () => { + const workflowReference = workflowRef('wf-other') + expect( + selectVisibleClearedRefs([workflowReference], resolvedKeys('workflow:wf-other')) + ).toEqual([workflowReference]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts new file mode 100644 index 00000000000..3f0bba304fe --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts @@ -0,0 +1,39 @@ +import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' + +/** Whether a resource is resolved by the current selection (mapped to a target OR selected for copy). */ +export type ClearedRefResolvedPredicate = (kind: string, sourceId: string) => boolean + +/** + * Parent kinds whose dependent child is itself a resource carried alongside the parent, so + * resolving the parent (mapping or copying it) PRESERVES the child rather than clearing it - + * currently just knowledge bases: a referenced document is copied with its KB, or auto-copied into + * a mapped KB, and remapped in place. Any other parent's child (a credential's label, a table's + * column) is account/table-scoped and cleared whenever the parent is remapped (the engine's + * `clearDependentsOnRemap`), so those entries stay regardless of the parent's disposition. + */ +const PARENT_KINDS_THAT_PRESERVE_CHILD: ReadonlySet = new Set(['knowledge-base']) + +/** + * Narrow the diff's cleared-ref candidates to those still cleared under the live selection: + * - `reference`: drops off once its own resource is resolved (mapped or copied). + * - `dependent`: drops off once its PARENT resource (`parentKind`/`parentSourceId`) is resolved - + * using the SAME predicate as `reference` - but ONLY when the child follows that parent (a + * document under a KB). A credential- or table-anchored dependent is cleared on any parent remap, + * so it stays even after the parent is mapped. + * - `workflow`: always stays - a cross-workflow reference cannot be resolved in the modal. + * + * Pure so the reactive list is unit-testable independent of the modal's selection state. + */ +export function selectVisibleClearedRefs( + clearedRefs: ForkClearedRef[], + isResolved: ClearedRefResolvedPredicate +): ForkClearedRef[] { + return clearedRefs.filter((ref) => { + if (ref.cause === 'reference') return !isResolved(ref.kind, ref.sourceId) + // The discriminated union guarantees `parentKind`/`parentSourceId` on a `dependent` variant. + if (ref.cause === 'dependent' && PARENT_KINDS_THAT_PRESERVE_CHILD.has(ref.parentKind)) { + return !isResolved(ref.parentKind, ref.parentSourceId) + } + return true + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.test.ts new file mode 100644 index 00000000000..04b164512c2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork' +import { + effectiveForkTarget, + forkCopyingKeys, + forkMappedCopyableKeys, + forkRefKey, + forkRequiredPending, + forkVisibleCopyables, + isForkRequiredComplete, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation' + +const entry = (overrides: Partial): ForkMappingEntry => ({ + kind: 'credential', + resourceType: 'oauth_credential', + sourceId: 'src', + sourceLabel: 'Src', + targetId: null, + suggested: false, + required: false, + candidates: [], + candidatesTruncated: false, + ...overrides, +}) + +const copyable = (overrides: Partial): ForkCopyableUnmapped => ({ + kind: 'knowledge-base', + sourceId: 'kb', + label: 'KB', + parentId: null, + parentLabel: null, + ...overrides, +}) + +describe('forkRefKey / effectiveForkTarget', () => { + it('shares the kind:sourceId keyspace for entries and copyables', () => { + expect(forkRefKey({ kind: 'knowledge-base', sourceId: 'kb-1' })).toBe('knowledge-base:kb-1') + expect(forkRefKey(entry({ kind: 'table', sourceId: 't-1' }))).toBe('table:t-1') + }) + + it('prefers an in-session override, else the persisted target, else empty', () => { + const e = entry({ kind: 'table', sourceId: 't-1', targetId: 'persisted' }) + expect(effectiveForkTarget(e, { 'table:t-1': 'in-session' })).toBe('in-session') + expect(effectiveForkTarget(e, {})).toBe('persisted') + expect(effectiveForkTarget(entry({ kind: 'table', sourceId: 't-1' }), {})).toBe('') + }) +}) + +describe('copy-vs-map reconciliation', () => { + it('drops a mapped copyable from the visible copy list (maps win over copy)', () => { + const entries = [ + entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }), + entry({ kind: 'table', sourceId: 'tbl-1' }), + ] + const candidates = [ + copyable({ kind: 'knowledge-base', sourceId: 'kb-1' }), + copyable({ kind: 'table', sourceId: 'tbl-1' }), + ] + const mapped = forkMappedCopyableKeys(entries, {}) + expect(mapped.has('knowledge-base:kb-1')).toBe(true) + expect(mapped.has('table:tbl-1')).toBe(false) + expect(forkVisibleCopyables(candidates, mapped).map(forkRefKey)).toEqual(['table:tbl-1']) + }) + + it('an in-session mapping target also drops the copyable', () => { + const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1' })] + const mapped = forkMappedCopyableKeys(entries, { 'knowledge-base:kb-1': 'kb-tgt' }) + expect( + forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mapped) + ).toEqual([]) + }) + + it('copyingKeys = the visible candidates that are selected for copy', () => { + const visible = [ + copyable({ kind: 'table', sourceId: 'tbl-1' }), + copyable({ kind: 'skill', sourceId: 'sk-1' }), + ] + expect([...forkCopyingKeys(visible, new Set(['table:tbl-1']))]).toEqual(['table:tbl-1']) + }) +}) + +describe('isForkRequiredComplete', () => { + it('a required ref is satisfied by a mapping target', () => { + const entries = [ + entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' }), + ] + expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true) + }) + + it('a required ref is satisfied by a copy selection (server accepts copy as resolving it)', () => { + const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })] + expect(isForkRequiredComplete(entries, {}, new Set(['knowledge-base:kb-1']))).toBe(true) + }) + + it('a required ref neither mapped nor copied blocks', () => { + const entries = [entry({ kind: 'credential', sourceId: 'c1', required: true })] + expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false) + }) + + it('optional refs never block', () => { + const entries = [entry({ kind: 'table', sourceId: 't1', required: false })] + expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true) + }) +}) + +describe('forkRequiredPending', () => { + it('is true when a required ref is neither mapped nor selected for copy', () => { + const items = [entry({ kind: 'credential', sourceId: 'c1', required: true })] + expect(forkRequiredPending(items, {}, new Set())).toBe(true) + }) + + it('is false when the required ref is selected for copy', () => { + const items = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })] + expect(forkRequiredPending(items, {}, new Set(['knowledge-base:kb-1']))).toBe(false) + }) + + it('is false when the required ref is mapped', () => { + const items = [entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' })] + expect(forkRequiredPending(items, {}, new Set())).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts new file mode 100644 index 00000000000..f1707ad1a3b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts @@ -0,0 +1,85 @@ +import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork' + +/** `${kind}:${sourceId}` - the shared key for a mapping entry and its copy candidate. */ +export const forkRefKey = (ref: { kind: string; sourceId: string }): string => + `${ref.kind}:${ref.sourceId}` + +/** Effective mapping target: the in-session override, else the persisted target, else ''. */ +export function effectiveForkTarget( + entry: ForkMappingEntry, + targets: Record +): string { + return targets[forkRefKey(entry)] ?? entry.targetId ?? '' +} + +/** + * Keys of copyable resources that already have a mapping target (in-session or persisted). Maps win + * over copy, so these drop out of the copy list - the copy-vs-map reconciliation. + */ +export function forkMappedCopyableKeys( + entries: ForkMappingEntry[], + targets: Record +): Set { + const keys = new Set() + for (const entry of entries) { + if (effectiveForkTarget(entry, targets) !== '') keys.add(forkRefKey(entry)) + } + return keys +} + +/** Copy candidates the user has not mapped (a mapped copyable is excluded - copy-vs-map reconcile). */ +export function forkVisibleCopyables( + copyableUnmapped: ForkCopyableUnmapped[], + mappedKeys: ReadonlySet +): ForkCopyableUnmapped[] { + return copyableUnmapped.filter((candidate) => !mappedKeys.has(forkRefKey(candidate))) +} + +/** Keys of the visible copy candidates actually selected for copy. */ +export function forkCopyingKeys( + visibleCopyables: ForkCopyableUnmapped[], + copySelected: ReadonlySet +): Set { + const keys = new Set() + for (const candidate of visibleCopyables) { + const key = forkRefKey(candidate) + if (copySelected.has(key)) keys.add(key) + } + return keys +} + +/** + * Whether every required reference is satisfied - it has a mapping target OR is selected for copy. + * The server accepts a copy as resolving a required ref (promote.ts `willResolve`), so the client + * gate must too. No double-count: a mapped copyable is excluded from the copy candidates, so the two + * branches are mutually exclusive. + */ +export function isForkRequiredComplete( + entries: ForkMappingEntry[], + targets: Record, + copyingKeys: ReadonlySet +): boolean { + return entries.every( + (entry) => + !entry.required || + effectiveForkTarget(entry, targets) !== '' || + copyingKeys.has(forkRefKey(entry)) + ) +} + +/** + * Whether any reference in a kind is required AND still unmapped AND not selected for copy - drives + * the overview's amber "pending" badge. Mirrors {@link isForkRequiredComplete}'s satisfied rule. + */ +export function forkRequiredPending( + items: ForkMappingEntry[], + targets: Record, + copyingKeys: ReadonlySet +): boolean { + return items.some( + (entry) => + entry.required && + effectiveForkTarget(entry, targets) === '' && + !copyingKeys.has(forkRefKey(entry)) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value.test.ts new file mode 100644 index 00000000000..16f49d4de4a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork' +import { + dependentKey, + effectiveDependentValue, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value' + +const field = (overrides: Partial = {}): ForkDependentReconfig => ({ + parentKind: 'credential', + parentSourceId: 'cred-1', + parentContextKey: 'oauthCredential', + targetWorkflowId: 'wf-1', + targetBlockId: 'blk-1', + blockName: 'Gmail', + subBlockKey: 'folder', + selectorKey: 'gmail.labels', + title: 'Label', + currentValue: 'INBOX', + required: false, + consumesContextKeys: [], + context: {}, + ...overrides, +}) + +describe('dependentKey', () => { + it('keys by target workflow + block + subblock', () => { + expect( + dependentKey(field({ targetWorkflowId: 'w', targetBlockId: 'b', subBlockKey: 's' })) + ).toBe('w:b:s') + }) +}) + +describe('effectiveDependentValue', () => { + it('returns the in-session re-pick when present', () => { + const f = field() + expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_42' }, false)).toBe('Label_42') + }) + + it('returns the stored currentValue when no re-pick and the parent is unchanged', () => { + expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, false)).toBe('INBOX') + }) + + it('returns blank when the parent changed (the stored value no longer resolves)', () => { + expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, true)).toBe('') + }) + + it('an in-session re-pick wins even when the parent changed', () => { + const f = field({ currentValue: 'INBOX' }) + expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_99' }, true)).toBe('Label_99') + }) + + it('an explicit empty re-pick is respected (not treated as absent)', () => { + const f = field({ currentValue: 'INBOX' }) + expect(effectiveDependentValue(f, { [dependentKey(f)]: '' }, false)).toBe('') + }) +}) 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 index 0ffd90fb96c..c2186faff72 100644 --- 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 @@ -16,6 +16,7 @@ import { import { getErrorMessage } from '@sim/utils/errors' import { ArrowRight } from 'lucide-react' import type { + ForkCopyableUnmapped, ForkDependentReconfig, ForkLineageNodeApi, ForkMappingEntry, @@ -23,7 +24,21 @@ import type { ForkWorkflowChange, } from '@/lib/api/contracts/workspace-fork' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + FileKindRow, + ResourceKindRow, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker' +import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' import { ResourceReconfigure } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure' +import { + effectiveForkTarget, + forkCopyingKeys, + forkMappedCopyableKeys, + forkRefKey, + forkRequiredPending, + forkVisibleCopyables, + isForkRequiredComplete, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation' import { dependentKey, effectiveDependentValue, @@ -43,7 +58,7 @@ interface PromoteWorkspaceModalProps { parent: ForkLineageNodeApi | null } -const entryKey = (entry: ForkMappingEntry) => `${entry.kind}:${entry.sourceId}` +const entryKey = (entry: ForkMappingEntry) => forkRefKey(entry) /** * Whether a mapping entry needs an in-place reconfigure: its effective target was changed @@ -90,19 +105,46 @@ function takenTargetOwners( return owners } +/** + * The mapping kinds that can be a standalone mapping entry. `knowledge-document` is excluded: + * the mapping view (`getForkMappingView`) skips documents — they ride their parent KB via the + * reconfigure flow — so a `knowledge-document` mapping section is never reachable. + */ +type MappableMappingKind = Exclude + /** Section label + display order per mapping kind (one mapping step per kind). */ -const MAPPING_SECTION: Record = { +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 }, + file: { label: 'Files', order: 4 }, + 'mcp-server': { label: 'MCP servers', order: 5 }, + 'custom-tool': { label: 'Custom tools', order: 6 }, + skill: { label: 'Skills', order: 7 }, } +/** + * Copyable kinds as expandable sections in the sync "Copy resources" picker, ordered + labeled to + * match the fork modal's resource picker exactly. Files nest in a folder ▸ file tree; every other + * kind is a flat searchable list. + */ +const COPYABLE_KIND_SECTIONS: ReadonlyArray<{ + kind: ForkCopyableUnmapped['kind'] + label: string +}> = [ + { kind: 'file', label: 'Files' }, + { kind: 'table', label: 'Tables' }, + { kind: 'knowledge-base', label: 'Knowledge bases' }, + { kind: 'custom-tool', label: 'Custom tools' }, + { kind: 'skill', label: 'Skills' }, +] + +const copyableKey = (candidate: ForkCopyableUnmapped) => forkRefKey(candidate) + +/** Sentinel option value for the editor's "Copy instead" entry - handled via onSelect, never sent. */ +const COPY_INSTEAD_VALUE = '__copy_instead__' + interface EdgeOption { value: string label: string @@ -157,6 +199,11 @@ export function PromoteWorkspaceModal({ // `dependentKey`. Folded into the full effective set sent on sync, which promote persists as // the stored mapping - so the selection survives every future sync without re-picking. const [reconfig, setReconfig] = useState>({}) + // Referenced-but-unmapped resources the user chose to copy into the target (keyed by + // `${kind}:${sourceId}`); default-selected once the diff loads. Selected ones are copied on + // sync so their references resolve to the copy instead of being cleared. + const [copySelected, setCopySelected] = useState>(new Set()) + const [copyDefaulted, setCopyDefaulted] = useState(false) // 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) @@ -176,6 +223,8 @@ export function PromoteWorkspaceModal({ setStep(0) setTargets({}) setReconfig({}) + setCopySelected(new Set()) + setCopyDefaulted(false) }, [open, selectedKey]) const selected = edgeOptions.find((option) => option.value === selectedKey) @@ -193,6 +242,64 @@ export function PromoteWorkspaceModal({ [diff.data?.dependentReconfigs] ) const resourceUsages = useMemo(() => diff.data?.resourceUsages ?? [], [diff.data?.resourceUsages]) + const copyableUnmapped = useMemo( + () => diff.data?.copyableUnmapped ?? [], + [diff.data?.copyableUnmapped] + ) + const clearedRefs = useMemo(() => diff.data?.clearedRefs ?? [], [diff.data?.clearedRefs]) + + // Keys the backend offers as copy candidates, so the editor shows a "Copy instead" affordance only + // for those - clearing a name-match suggestion returns the ref to the copy list (it re-enters + // `visibleCopyables` once its effective target is ''). + const copyableKeys = useMemo(() => new Set(copyableUnmapped.map(copyableKey)), [copyableUnmapped]) + + // Copy-vs-map reconciliation: a copyable resource the user has given an effective (in-session + // or persisted) mapping target must NOT also appear in the copy list - the user picked map, not + // copy. `copyableKey` shares the `${kind}:${sourceId}` keyspace with `entryKey`, so a mapped + // entry's key directly excludes its copy candidate. The server enforces the same precedence: + // a mapped resource resolves != null, so it never reaches the plan's `copyableUnmapped`, and a + // copy request for it is dropped by `buildPromoteCopySelection`. + const mappedCopyableKeys = useMemo( + () => forkMappedCopyableKeys(entries, targets), + [entries, targets] + ) + + const visibleCopyables = useMemo( + () => forkVisibleCopyables(copyableUnmapped, mappedCopyableKeys), + [copyableUnmapped, mappedCopyableKeys] + ) + + // Copyables actually selected for copy (visible + checked), keyed for an O(1) lookup so a + // copyable mapping entry in the editor walk can show a "will be copied" note. + const copyingKeys = useMemo( + () => forkCopyingKeys(visibleCopyables, copySelected), + [visibleCopyables, copySelected] + ) + + // Group the visible copy candidates by kind so each renders as its own expandable section + // (chevron + tri-state select-all + count), matching the fork picker. Files nest in a folder ▸ + // file tree inside their section; every other kind is a flat searchable list. + const copyablesByKind = useMemo(() => { + const groups = new Map() + for (const candidate of visibleCopyables) { + const list = groups.get(candidate.kind) + if (list) list.push(candidate) + else groups.set(candidate.kind, [candidate]) + } + return groups + }, [visibleCopyables]) + + // Default every copyable referenced resource to "copy" once the diff loads, so the common case + // (bring the referenced resources along) needs no clicks; the user can deselect to clear instead. + // Seed ONLY from a settled diff for the current direction: on a direction switch the reset clears + // `copyDefaulted`, but `useForkDiff` keeps the previous direction's payload (placeholderData) until + // the new fetch resolves - seeding from it would latch against stale keys and leave the real + // copyables unchecked, clearing their references on Sync. + useEffect(() => { + if (!open || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return + setCopyDefaulted(true) + setCopySelected(new Set(copyableUnmapped.map(copyableKey))) + }, [open, diff.isPlaceholderData, copyableUnmapped, copyDefaulted]) // Group dependents by their parent (kind:sourceId) once, so each mapping entry below gets a // STABLE `dependents` array reference - a fresh `.filter` per render would defeat @@ -211,9 +318,11 @@ export function PromoteWorkspaceModal({ // 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 targetFor = (entry: ForkMappingEntry) => effectiveForkTarget(entry, targets) - const requiredComplete = entries.every((entry) => !entry.required || targetFor(entry) !== '') + // A required reference is satisfied when it has a mapping target OR the user selected it for copy + // (the server accepts a copy as resolving a required ref). See `isForkRequiredComplete`. + const requiredComplete = isForkRequiredComplete(entries, targets, copyingKeys) // Every workflow a mapping entry's resource is used in, for the always-on reconfigure // listing rendered beneath that mapping (so the credential/KB stays in context). @@ -226,10 +335,34 @@ export function PromoteWorkspaceModal({ const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] => dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS + // Set an entry's in-session mapping target. A `value` of '' explicitly clears it, overriding any + // name-match suggestion (effectiveForkTarget's `?? ` treats '' as present, so the suggestion no + // longer wins) - so the resource re-enters `visibleCopyables` and is copy-selectable again. + // Changing the parent invalidates its dependents' in-session re-picks (chosen against the old + // account), so drop them. + const applyTargetChange = (entry: ForkMappingEntry, value: string) => { + setTargets((prev) => ({ ...prev, [entryKey(entry)]: value })) + setReconfig((prev) => { + let changed = false + const next = { ...prev } + for (const dependent of dependentsForEntry(entry)) { + const key = dependentKey(dependent) + if (key in next) { + delete next[key] + changed = true + } + } + return changed ? next : prev + }) + } + // Group mappings by resource type - one step per kind, required types first. const groupedEntries = useMemo(() => { - const groups = new Map() + const groups = new Map() for (const entry of entries) { + // The mapping view never emits a document entry (it rides its KB), so the section is + // unreachable - skip defensively so the narrowed `MAPPING_SECTION` lookup stays sound. + if (entry.kind === 'knowledge-document') continue const list = groups.get(entry.kind) if (list) list.push(entry) else groups.set(entry.kind, [entry]) @@ -277,6 +410,32 @@ export function PromoteWorkspaceModal({ return dependentValueFor(field, parent) !== '' }) + // Kinds with a required DEPENDENT that still has no value (its parent is mapped): these block + // Sync via `reconfigComplete`, so the overview badge for that kind must not read "Fully mapped". + const reconfigPendingByKind = new Set() + for (const field of dependentReconfigs) { + if (!field.required) continue + const parent = entryForDependent(field) + if (!parent || targetFor(parent) === '') continue + if (dependentValueFor(field, parent) === '') { + reconfigPendingByKind.add(parent.kind as MappableMappingKind) + } + } + + // The references this sync will blank, reactively narrowed to the current selection. A resource + // is "resolved" once it has a mapping target OR is selected for copy - the same predicate drives + // a `reference` (its own resource) and a `dependent` (its PARENT resource), so mapping or copying + // a parent KB makes its child document drop off. `workflow` refs always clear (not resolvable here). + const clearedRefsToShow = useMemo(() => { + const isResolved = (kind: string, sourceId: string) => { + const key = `${kind}:${sourceId}` + const entry = entriesByParent.get(key) + const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false + return mapped || copyingKeys.has(key) + } + return selectVisibleClearedRefs(clearedRefs, isResolved) + }, [clearedRefs, entriesByParent, targets, copyingKeys]) + // 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 @@ -284,8 +443,22 @@ export function PromoteWorkspaceModal({ 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 } + // Copy-selected items are resolved too (their refs are kept), so they count toward completion + // and render as "copied" rather than unconfigured. mapped/copied are disjoint: a mapped + // copyable is excluded from the copy candidates, so copyingKeys never overlaps a mapped entry. + const copied = group.items.filter((entry) => copyingKeys.has(entryKey(entry))).length + // Mirror the Sync gate: a required ref selected for copy is satisfied, so it is not "pending". + const requiredPending = forkRequiredPending(group.items, targets, copyingKeys) + const reconfigPending = reconfigPendingByKind.has(group.kind) + return { + kind: group.kind, + label: group.label, + total, + mapped, + copied, + requiredPending, + reconfigPending, + } }) // Step 0 is the overview; each subsequent step edits one resource kind, entered via @@ -296,16 +469,18 @@ export function PromoteWorkspaceModal({ const safeStep = Math.min(step, Math.max(0, stepCount - 1)) const isLastStep = safeStep >= stepCount - 1 const currentGroup = safeStep >= 1 ? (groupedEntries[safeStep - 1] ?? null) : null - // Gate Sync on the diff being loaded too, not just the mapping: until `diff.data` arrives - // `dependentReconfigs` is empty, so `reconfigComplete` is vacuously true and `runPromote` would - // omit `dependentValues` - i.e. Sync before the diff loads would bypass dependent gating. - const syncDisabled = - submitting || - !otherWorkspaceId || - !requiredComplete || - !reconfigComplete || + // Sync details still settling for the current direction: loading, a failed/empty mapping + // (`!mapping.data` must not read as "nothing required"), or the PREVIOUS direction's placeholder + // after a switch (syncing on it would send stale mappings/copies and clear references). Until + // `diff.data` arrives `dependentReconfigs` is empty, so `reconfigComplete` is vacuously true. + const dataPending = mapping.isLoading || - !diff.data + !mapping.data || + mapping.isPlaceholderData || + !diff.data || + diff.isPlaceholderData + const syncDisabled = + submitting || !otherWorkspaceId || !requiredComplete || !reconfigComplete || dataPending const headsUp = (diff.data?.mcpReauthServerIds.length ?? 0) > 0 || (diff.data?.inlineSecretSources.length ?? 0) > 0 @@ -344,6 +519,25 @@ export function PromoteWorkspaceModal({ ] }) + // Copy the referenced-but-unmapped resources the user kept selected, excluding any the user + // mapped in-session (reconciliation: maps win). The backend validates each id against the + // plan's copy candidates too, so a mapped/stale id is dropped server-side regardless. + const selectedCopyables = visibleCopyables.filter((candidate) => + copySelected.has(copyableKey(candidate)) + ) + const copyResources = { + knowledgeBases: selectedCopyables + .filter((c) => c.kind === 'knowledge-base') + .map((c) => c.sourceId), + tables: selectedCopyables.filter((c) => c.kind === 'table').map((c) => c.sourceId), + customTools: selectedCopyables + .filter((c) => c.kind === 'custom-tool') + .map((c) => c.sourceId), + skills: selectedCopyables.filter((c) => c.kind === 'skill').map((c) => c.sourceId), + // Files are identified by storage key (the copyable candidate's sourceId is the key). + files: selectedCopyables.filter((c) => c.kind === 'file').map((c) => c.sourceId), + } + const result = await promote.mutateAsync({ workspaceId, body: { @@ -354,12 +548,25 @@ export function PromoteWorkspaceModal({ // stored rows. Collapsing `[]` into omission would make the backend PRESERVE stale rows. // Only omit before the diff loads (set unknown), so the existing store is left untouched. ...(diff.data ? { dependentValues } : {}), + ...(selectedCopyables.length > 0 ? { copyResources } : {}), }, }) if (!result.promoteRunId) { if (result.unmappedRequired.length > 0) { - toast.error('Map all required credentials and secrets first') + // Name the actual blocking kinds rather than always blaming credentials: the server + // blocks on required REFERENCES (credentials and/or secrets); required dependents are + // gated client-side before this runs (see the Sync button's disabled tooltip). + const kinds = new Set(result.unmappedRequired.map((reference) => reference.kind)) + const what = + kinds.has('credential') && kinds.has('env-var') + ? 'credentials and secrets' + : kinds.has('credential') + ? 'credentials' + : kinds.has('env-var') + ? 'secrets' + : 'references' + toast.error(`Map all required ${what} first`) return } toast.error('Sync did not complete') @@ -387,8 +594,16 @@ export function PromoteWorkspaceModal({ optionalBlocks > 0 ? ` (+${optionalBlocks} block${optionalBlocks === 1 ? '' : 's'} with optional fields cleared)` : '' + // Surfaced alongside a needs-config warning too, so concurrent deploy failures aren't only in + // logs/Activity when both happen (the needs-config branch would otherwise win alone). + const deployFailedSuffix = + result.deployFailed > 0 + ? ` (+${result.deployFailed} workflow${result.deployFailed === 1 ? '' : 's'} failed to deploy)` + : '' if (needsConfig.length > 0) { - toast.warning(`${label}. Re-check ${formatWhere(needsConfig)}.${optionalSuffix}`) + toast.warning( + `${label}. Re-check ${formatWhere(needsConfig)}.${deployFailedSuffix}${optionalSuffix}` + ) } else if (result.deployFailed > 0) { const n = result.deployFailed toast.warning( @@ -447,6 +662,20 @@ export function PromoteWorkspaceModal({ /> + {/* Surface a failed/pending fetch so the modal never renders blank below the picker. */} + {mapping.isError || diff.isError ? ( + +
+ {getErrorMessage( + mapping.error ?? diff.error, + "Couldn't load sync details. Close and reopen to retry." + )} +
+
+ ) : !diff.data ? ( +
Loading sync details…
+ ) : null} + {/* Always shown once the diff loads so the user sees the section even with nothing deployed - an empty change list means the source has no deployed workflows (every deployed workflow appears here, changed or not), so the muted state nudges a deploy. */} @@ -506,23 +735,138 @@ export function PromoteWorkspaceModal({ {kindSummaries.length > 0 ? (
- {kindSummaries.map(({ kind, label, total, mapped, requiredPending }) => { - const complete = mapped === total - return ( -
- {label} - - {complete ? 'Fully mapped' : `${mapped}/${total} mapped`} - -
+ {kindSummaries.map( + ({ + kind, + label, + total, + mapped, + copied, + requiredPending, + reconfigPending, + }) => { + const resolved = mapped + copied + const complete = resolved === total && !reconfigPending + const badgeLabel = complete + ? mapped === total + ? 'Fully mapped' + : copied === total + ? 'Copied' + : 'Mapped & copied' + : reconfigPending && resolved === total + ? 'Needs setup' + : copied > 0 + ? `${resolved}/${total} ready` + : `${mapped}/${total} mapped` + return ( +
+ {label} + + {badgeLabel} + +
+ ) + } + )} +
+
+ ) : null} + + {clearedRefsToShow.length > 0 ? ( + +
+ {clearedRefsToShow.map((ref, index) => ( +
+ {ref.blockLabel} will lose{' '} + {ref.fieldLabel} in{' '} + {ref.workflowName} +
+ ))} +
+

+ Map or copy a reference to keep it. Fields that reference another workflow, or + that hang off a remapped credential or knowledge base, are cleared regardless. +

+
+ ) : null} + + {visibleCopyables.length > 0 ? ( + +
+ {COPYABLE_KIND_SECTIONS.map((section) => { + const candidates = copyablesByKind.get(section.kind) + if (!candidates || candidates.length === 0) return null + // The picker rows track item ids; copy selection is keyed `${kind}:${id}` + // (matching `copyableKey`), so derive the per-kind selected-id subset and + // re-prefix on toggle. + const selectedIds = new Set( + candidates + .filter((candidate) => copySelected.has(copyableKey(candidate))) + .map((candidate) => candidate.sourceId) + ) + const toggleMany = (ids: string[], checked: boolean) => + setCopySelected((prev) => { + const next = new Set(prev) + for (const id of ids) { + const key = `${section.kind}:${id}` + if (checked) next.add(key) + else next.delete(key) + } + return next + }) + const toggleAll = (selectAll: boolean) => + toggleMany( + candidates.map((candidate) => candidate.sourceId), + selectAll + ) + return section.kind === 'file' ? ( + ({ + id: candidate.sourceId, + label: candidate.label, + folderId: candidate.parentId, + folderName: candidate.parentLabel, + }))} + selected={selectedIds} + onToggleAll={toggleAll} + onToggleItem={(id, checked) => toggleMany([id], checked)} + onToggleMany={toggleMany} + disabled={submitting} + /> + ) : ( + ({ + id: candidate.sourceId, + label: candidate.label, + }))} + selected={selectedIds} + onToggleMany={toggleMany} + onToggleItem={(id, checked) => toggleMany([id], checked)} + disabled={submitting} + /> ) })} +

+ These referenced resources aren't in the target yet. Selected ones are copied + during the sync; deselected ones have their references cleared. +

) : null} @@ -551,36 +895,31 @@ export function PromoteWorkspaceModal({ > { - const owner = takenOwners.get(candidate.id) - return { - label: owner - ? `${candidate.label} · mapped to ${owner}` - : candidate.label, - value: candidate.id, - disabled: owner !== undefined, - } - })} - value={targetFor(entry) || undefined} - onChange={(value) => { - setTargets((prev) => ({ ...prev, [entryKey(entry)]: value })) - // Changing the parent invalidates any in-session re-picks of its - // dependents - they were chosen against the old account and won't resolve - // against the new one, so drop them; otherwise a stale re-pick (which - // wins over the parent-changed check) would be sent to the new account. - setReconfig((prev) => { - let changed = false - const next = { ...prev } - for (const dependent of dependentsForEntry(entry)) { - const key = dependentKey(dependent) - if (key in next) { - delete next[key] - changed = true - } + options={[ + // Let the user revert a name-match suggestion (or any in-session map) of a + // copyable resource back to the copy flow - clears the target via onSelect. + ...(copyableKeys.has(entryKey(entry)) && targetFor(entry) !== '' + ? [ + { + label: 'Copy instead', + value: COPY_INSTEAD_VALUE, + onSelect: () => applyTargetChange(entry, ''), + }, + ] + : []), + ...entry.candidates.map((candidate) => { + const owner = takenOwners.get(candidate.id) + return { + label: owner + ? `${candidate.label} · mapped to ${owner}` + : candidate.label, + value: candidate.id, + disabled: owner !== undefined, } - return changed ? next : prev - }) - }} + }), + ]} + value={targetFor(entry) || undefined} + onChange={(value) => applyTargetChange(entry, value)} placeholder='Select target' /> {entry.candidatesTruncated ? ( @@ -589,6 +928,12 @@ export function PromoteWorkspaceModal({ one, narrow it down by name.
) : null} + {copyingKeys.has(entryKey(entry)) ? ( +
+ Selected for copy in the overview — it'll be copied into the target. Pick a + target above to map it to an existing one instead. +
+ ) : null} {/* Always-on: every workflow this resource is used in, each expandable to its blocks + dependent selectors (greyed when nothing to configure). */} diff --git a/apps/sim/lib/api/contracts/workspace-fork.test.ts b/apps/sim/lib/api/contracts/workspace-fork.test.ts index f1851ec9686..bdfdf40ea66 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.test.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.test.ts @@ -12,6 +12,10 @@ describe('forkMappableResourceTypeSchema', () => { expect(forkMappableResourceTypeSchema.safeParse('workflow').success).toBe(false) }) + it('rejects knowledge_document (a document follows its parent knowledge base)', () => { + expect(forkMappableResourceTypeSchema.safeParse('knowledge_document').success).toBe(false) + }) + it('accepts user-mappable resource types', () => { for (const type of [ 'oauth_credential', @@ -19,7 +23,6 @@ describe('forkMappableResourceTypeSchema', () => { 'env_var', 'table', 'knowledge_base', - 'knowledge_document', 'file', 'mcp_server', 'custom_tool', diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index d7f183001ec..cf7e2430dfa 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -32,16 +32,37 @@ export const forkResourceTypeSchema = z.enum([ ]) /** - * Resource types a user may map via the mapping editor. Excludes `workflow`: - * workflow identity is system-managed (seeded at fork, maintained by promote, - * dissolved by rollback) and must never be written through the mapping editor, or - * a crafted entry could repoint a promote at the wrong target workflow. + * Resource types a user may map via the mapping editor. Excludes `workflow` (identity is + * system-managed - seeded at fork, maintained by promote, dissolved by rollback - and must never + * be written through the editor, or a crafted entry could repoint a promote at the wrong target + * workflow) AND `knowledge_document` (a document is never a standalone mapping: it follows its + * parent knowledge base, re-picked in that KB's reconfigure flow and auto-remapped when the KB is + * copied - the mapping view never emits one and `listForkResourceCandidates` returns none). */ -export const forkMappableResourceTypeSchema = forkResourceTypeSchema.exclude(['workflow']) +export const forkMappableResourceTypeSchema = forkResourceTypeSchema.exclude([ + 'workflow', + 'knowledge_document', +]) export type ForkMappableResourceType = z.infer export const forkDirectionSchema = z.enum(['push', 'pull']) +/** + * The remappable, copyable resource kinds a sync can copy into the target when they are + * referenced but unmapped (the fork-style copy at promote time). Excludes credentials, env + * vars, and external MCP servers (never copied this way); documents are auto-copied with their + * parent knowledge base, not selected individually. Workspace `file` references are keyed by + * storage key (not `workspace_files.id`) and copied like fork does. + */ +export const forkCopyableKindSchema = z.enum([ + 'knowledge-base', + 'table', + 'custom-tool', + 'skill', + 'file', +]) +export type ForkCopyableKind = z.infer + export const forkLineageNodeSchema = z.object({ id: z.string(), name: z.string(), @@ -79,7 +100,9 @@ export const forkResourceSelectionSchema = z.object({ knowledgeBases: forkResourceIdList, customTools: forkResourceIdList, skills: forkResourceIdList, - mcpServers: forkResourceIdList, + // External MCP servers are never copied (they carry secrets / require re-auth); only + // workflow-publishing MCP servers are copyable, as config-only shells with no workflows. + workflowMcpServers: forkResourceIdList, }) export const forkWorkspaceBodySchema = z.object({ @@ -106,6 +129,18 @@ export type ForkWorkspaceResponse = z.output + +/** + * A copyable workspace file plus its folder grouping. `folderId`/`folderName` are null when + * the file sits at the workspace root (or its folder was deleted). Files are the only copyable + * kind that nests in the picker (folder ▸ file); every other kind stays flat at the top level. + */ +export const forkCopyableFileSchema = forkCopyableResourceSchema.extend({ + folderId: z.string().nullable(), + folderName: z.string().nullable(), +}) +export type ForkCopyableFile = z.output + export const getForkResourcesContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/fork/resources', @@ -113,12 +148,12 @@ export const getForkResourcesContract = defineRouteContract({ response: { mode: 'json', schema: z.object({ - files: z.array(forkCopyableResourceSchema), + files: z.array(forkCopyableFileSchema), tables: z.array(forkCopyableResourceSchema), knowledgeBases: z.array(forkCopyableResourceSchema), customTools: z.array(forkCopyableResourceSchema), skills: z.array(forkCopyableResourceSchema), - mcpServers: z.array(forkCopyableResourceSchema), + workflowMcpServers: z.array(forkCopyableResourceSchema), deployedWorkflowCount: z.number().int(), }), }, @@ -284,6 +319,52 @@ export const forkResourceUsageSchema = z.object({ }) export type ForkResourceUsage = z.output +/** Fields shared by every cleared-ref variant: the labels to phrase the "will be cleared" line. */ +const forkClearedRefBaseSchema = z.object({ + targetWorkflowId: z.string(), + workflowName: z.string(), + blockId: z.string(), + blockLabel: z.string(), + fieldLabel: z.string(), + sourceId: z.string(), + sourceLabel: z.string(), +}) + +/** + * A reference in a synced source workflow that WILL be blanked in the target by this sync, with the + * labels to phrase it as "{blockLabel} will lose {fieldLabel} in workflow {workflowName}". A + * discriminated union on `cause` so clients narrow exhaustively (only `dependent` carries the parent + * fields): + * - `reference`: an unmapped remappable resource (`kind`) - drops off the list once the user maps + * OR copies it (matched to a mapping entry by `${kind}:${sourceId}`). + * - `workflow`: a `workflow-selector`/`workflow_input` ref to a workflow not in the target - + * always cleared (cannot be fixed in the modal). + * - `dependent`: a create-target dependent selector a remapped parent clears. Carries the parent + * (`parentKind`/`parentSourceId`); when the child follows its parent (a document under a knowledge + * base) the client drops it once that parent is mapped/copied, else it stays (credential label / + * table column). + */ +export const forkClearedRefSchema = z.discriminatedUnion('cause', [ + forkClearedRefBaseSchema.extend({ + cause: z.literal('reference'), + /** The unmapped remappable resource (never `workflow`). */ + kind: forkRemapKindSchema, + }), + forkClearedRefBaseSchema.extend({ + cause: z.literal('workflow'), + kind: z.literal('workflow'), + }), + forkClearedRefBaseSchema.extend({ + cause: z.literal('dependent'), + /** Mirrors `parentKind` - the parent resource the cleared dependent hangs off. */ + kind: forkRemapKindSchema, + /** The dependsOn parent; the entry drops off once this parent is mapped/copied (KB-document case). */ + parentKind: forkRemapKindSchema, + parentSourceId: z.string(), + }), +]) +export type ForkClearedRef = z.output + export const getForkDiffQuerySchema = z.object({ otherWorkspaceId: workspaceIdSchema, direction: forkDirectionSchema, @@ -313,11 +394,35 @@ export const getForkDiffContract = defineRouteContract({ dependentReconfigs: z.array(forkDependentReconfigSchema), /** Every workflow each mapped resource is used in, for the always-on reconfigure listing. */ resourceUsages: z.array(forkResourceUsageSchema), + /** + * Referenced resources with no target mapping that the sync can copy into the target + * (fork-style), so the user can copy instead of mapping each one by hand. Default-selected + * in the modal; documents under a selected knowledge base are copied automatically. + * `parentId`/`parentLabel` carry the folder grouping for file entries (id + name); they + * are null for non-file kinds and for files at the workspace root. + */ + copyableUnmapped: z.array( + z.object({ + kind: forkCopyableKindSchema, + sourceId: z.string(), + label: z.string(), + parentId: z.string().nullable(), + parentLabel: z.string().nullable(), + }) + ), + /** + * References this sync will blank in the target, with labels for a pre-sync "what will be + * cleared" list. The client filters this against the current mapping/copy selection so a + * `reference` item disappears once mapped or selected for copy; `workflow`/`dependent` items + * always clear (informational). + */ + clearedRefs: z.array(forkClearedRefSchema), }), }, }) export type GetForkDiffResponse = z.output export type ForkWorkflowChange = z.output +export type ForkCopyableUnmapped = GetForkDiffResponse['copyableUnmapped'][number] /** * A workflow whose required dependent fields a sync cleared because their parent @@ -347,6 +452,21 @@ export const forkDependentValueEntrySchema = z.object({ }) export type ForkDependentValueEntry = z.input +/** + * Source resource ids (by kind) the user chose to copy into the target before the sync gate, + * for referenced-but-unmapped resources. Each kind's documents under a copied knowledge base + * are discovered + copied automatically (the user selects only the parent resources). + */ +export const promoteCopyResourcesSchema = z.object({ + knowledgeBases: forkResourceIdList, + tables: forkResourceIdList, + customTools: forkResourceIdList, + skills: forkResourceIdList, + /** Workspace files to copy, identified by storage key (not `workspace_files.id`). */ + files: forkResourceIdList, +}) +export type PromoteCopyResources = z.input + export const promoteForkBodySchema = z.object({ otherWorkspaceId: workspaceIdSchema, direction: forkDirectionSchema, @@ -357,6 +477,8 @@ export const promoteForkBodySchema = z.object({ * an explicit `[]` clears it for the written replace targets. */ dependentValues: z.array(forkDependentValueEntrySchema).max(2000).optional(), + /** Referenced-but-unmapped resources to copy into the target before the sync gate (U17). */ + copyResources: promoteCopyResourcesSchema.optional(), }) export const promoteForkContract = defineRouteContract({ method: 'POST', @@ -397,6 +519,10 @@ export const backgroundWorkMetadataSchema = z files: z.number().int().optional(), copied: z.number().int().optional(), failed: z.number().int().optional(), + /** Count of failed resources whose dangling references were cleared post-fork (U8). */ + clearedReferences: z.number().int().optional(), + /** True when a reference-clear phase threw, so cleanup is incomplete (placeholders not dropped). */ + clearingFailed: z.boolean().optional(), /** Names of the resources a fork copied, by kind, for the report breakdown. */ workflowNames: z.array(z.string()).optional(), tableNames: z.array(z.string()).optional(), @@ -404,7 +530,7 @@ export const backgroundWorkMetadataSchema = z fileNames: z.array(z.string()).optional(), customToolNames: z.array(z.string()).optional(), skillNames: z.array(z.string()).optional(), - mcpServerNames: z.array(z.string()).optional(), + workflowMcpServerNames: z.array(z.string()).optional(), // Sync / rollback otherWorkspaceName: z.string().optional(), direction: z.enum(['push', 'pull']).optional(), diff --git a/apps/sim/lib/table/trigger.ts b/apps/sim/lib/table/trigger.ts index 93da9dd00ae..16b55852a37 100644 --- a/apps/sim/lib/table/trigger.ts +++ b/apps/sim/lib/table/trigger.ts @@ -10,6 +10,7 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys' import type { RowData, TableRow, TableSchema } from '@/lib/table/types' +import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical' const logger = createLogger('TableTrigger') @@ -70,7 +71,13 @@ export async function fireTableTrigger( // Filter to webhooks watching this table with a matching event type const matching = webhooks.filter((entry) => { const config = entry.webhook.providerConfig as WebhookConfig | null - const configTableId = config?.tableId ?? config?.tableSelector ?? config?.manualTableId + // Canonical key `tableId` first; `tableSelector`/`manualTableId` are a transitional + // basic-first fallback for configs deployed before the canonical key was written. + const configTableId = readCanonicalTriggerValue( + config?.tableId, + config?.tableSelector, + config?.manualTableId + ) if (configTableId !== tableId) return false const configEventType = config?.eventType ?? 'insert' diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts new file mode 100644 index 00000000000..ca7e1949af7 --- /dev/null +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubBlockConfig } from '@/blocks/types' +import type { BlockState } from '@/stores/workflows/workflow/types' + +// deploy.ts pulls in the trigger/block/provider registries at module load; none are exercised by +// buildProviderConfig (a pure function), so stub them to keep this unit test fast and isolated. +vi.mock('@/blocks', () => ({ getBlock: vi.fn() })) +vi.mock('@/triggers', () => ({ getTrigger: vi.fn(), isTriggerValid: vi.fn(() => true) })) +vi.mock('@/lib/oauth', () => ({ getProviderIdFromServiceId: vi.fn() })) +vi.mock('@/lib/webhooks/providers', () => ({ getProviderHandler: vi.fn() })) +vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ + cleanupExternalWebhook: vi.fn(), + createExternalWebhookSubscription: vi.fn(), + shouldRecreateExternalWebhookSubscription: vi.fn(), +})) +vi.mock('@/lib/webhooks/utils.server', () => ({ + findConflictingWebhookPathOwner: vi.fn(), + syncWebhooksForCredentialSet: vi.fn(), +})) +vi.mock('@/lib/webhooks/pending-verification', () => ({ + PendingWebhookVerificationTracker: vi.fn(), +})) + +import { buildProviderConfig } from '@/lib/webhooks/deploy' + +const trigger = (subBlocks: Partial[]): { subBlocks: SubBlockConfig[] } => ({ + subBlocks: subBlocks as SubBlockConfig[], +}) + +const driveTrigger = trigger([ + { id: 'triggerCredentials', mode: 'trigger', canonicalParamId: 'oauthCredential' }, + { id: 'folderId', mode: 'trigger', canonicalParamId: 'folderId', required: false }, + { id: 'manualFolderId', mode: 'trigger-advanced', canonicalParamId: 'folderId', required: false }, +]) + +const tableTrigger = trigger([ + { id: 'tableSelector', mode: 'trigger', canonicalParamId: 'tableId', required: true }, + { id: 'manualTableId', mode: 'trigger-advanced', canonicalParamId: 'tableId', required: true }, +]) + +function makeBlock( + type: string, + subBlockValues: Record, + canonicalModes?: Record +): BlockState { + const subBlocks: Record = {} + for (const [key, value] of Object.entries(subBlockValues)) subBlocks[key] = { value } + return { + id: 'block-1', + type, + subBlocks, + ...(canonicalModes ? { data: { canonicalModes } } : {}), + } as unknown as BlockState +} + +describe('buildProviderConfig canonical collapse', () => { + beforeEach(() => vi.clearAllMocks()) + + it('writes the basic value under the canonical key in basic mode', () => { + const block = makeBlock('google_drive_poller', { folderId: 'BASIC' }) + const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger) + expect(providerConfig.folderId).toBe('BASIC') + }) + + it('writes the active (advanced) value under the canonical key when only advanced is set', () => { + const block = makeBlock('google_drive_poller', { manualFolderId: 'ADVANCED' }) + const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger) + // Heuristic: empty basic + populated advanced => advanced is active. + expect(providerConfig.folderId).toBe('ADVANCED') + // Raw advanced key kept for transitional readers. + expect(providerConfig.manualFolderId).toBe('ADVANCED') + }) + + it('collapses a drift block (stale basic + active advanced via override) to the active value', () => { + const block = makeBlock( + 'google_drive_poller', + { folderId: 'STALE', manualFolderId: 'ACTIVE' }, + { folderId: 'advanced' } + ) + const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger) + // The canonical key collapses to the active (advanced) value, not the stale basic value. + expect(providerConfig.folderId).toBe('ACTIVE') + expect(providerConfig.manualFolderId).toBe('ACTIVE') + }) + + it('honors a basic-mode override even when advanced is populated', () => { + const block = makeBlock( + 'google_drive_poller', + { folderId: 'BASIC', manualFolderId: 'ADVANCED' }, + { folderId: 'basic' } + ) + const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger) + expect(providerConfig.folderId).toBe('BASIC') + }) + + it('omits the canonical key when the active value is empty (optional field)', () => { + const block = makeBlock('google_drive_poller', {}) + const { providerConfig } = buildProviderConfig(block, 'google_drive_poller', driveTrigger) + expect(providerConfig.folderId).toBeUndefined() + }) + + it('writes a distinct canonical key (tableId) for the table trigger', () => { + const block = makeBlock('table_new_row', { tableSelector: 'TBL' }) + const { providerConfig } = buildProviderConfig(block, 'table_new_row', tableTrigger) + expect(providerConfig.tableId).toBe('TBL') + // Raw basic key kept for transitional readers. + expect(providerConfig.tableSelector).toBe('TBL') + }) + + it('collapses a drift table block to the active value under tableId', () => { + const block = makeBlock( + 'table_new_row', + { tableSelector: 'STALE', manualTableId: 'ACTIVE' }, + { tableId: 'advanced' } + ) + const { providerConfig } = buildProviderConfig(block, 'table_new_row', tableTrigger) + expect(providerConfig.tableId).toBe('ACTIVE') + }) +}) diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 686c974df5a..0fcb668f8e1 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -17,7 +17,12 @@ import { findConflictingWebhookPathOwner, syncWebhooksForCredentialSet, } from '@/lib/webhooks/utils.server' -import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility' +import { + buildCanonicalIndex, + buildSubBlockValues, + isCanonicalPair, + resolveActiveCanonicalValue, +} from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -250,7 +255,13 @@ function getConfigValue(block: BlockState, subBlock: SubBlockConfig): unknown { return fieldValue } -function buildProviderConfig( +/** + * Build the persisted `webhook.providerConfig` for a trigger block at deploy time. + * + * Exported for unit testing the canonical-collapse pass; not part of the public + * deploy API. + */ +export function buildProviderConfig( block: BlockState, triggerId: string, triggerDef: { subBlocks: SubBlockConfig[] } @@ -308,6 +319,25 @@ function buildProviderConfig( } } + // Collapse each canonical pair (basic + advanced swap) to its ACTIVE value under the + // canonical key, so pollers read one authoritative key instead of guessing basic-first. + // resolveActiveCanonicalValue is the shared SOT: an explicit block.data.canonicalModes + // override, else the value heuristic. The raw subblock keys written in the first pass are + // kept for transitional readers (removable in a follow-up contract phase). This only runs on + // a (re)deploy, so any drift collapse is scoped to the new deployment version — already + // deployed rows are migrated separately and keep their current resource. + const canonicalModes = block.data?.canonicalModes + const flatSubBlockValues = buildSubBlockValues(block.subBlocks || {}) + for (const group of Object.values(canonicalIndex.groupsById)) { + if (!isCanonicalPair(group)) continue + const activeValue = resolveActiveCanonicalValue(group, flatSubBlockValues, canonicalModes) + if (activeValue !== null && activeValue !== undefined && activeValue !== '') { + providerConfig[group.canonicalId] = activeValue + } else { + delete providerConfig[group.canonicalId] + } + } + const credentialConfig = triggerDef.subBlocks.find( (subBlock) => subBlock.id === 'triggerCredentials' ) diff --git a/apps/sim/lib/webhooks/polling/canonical.test.ts b/apps/sim/lib/webhooks/polling/canonical.test.ts new file mode 100644 index 00000000000..60701cc27b1 --- /dev/null +++ b/apps/sim/lib/webhooks/polling/canonical.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical' + +describe('readCanonicalTriggerValue', () => { + it('returns the canonical value when present', () => { + expect(readCanonicalTriggerValue('canon', 'basic', 'advanced')).toBe('canon') + }) + + it('falls back basic-first when the canonical key is absent (transitional)', () => { + expect(readCanonicalTriggerValue(undefined, 'basic', 'advanced')).toBe('basic') + }) + + it('falls back to the advanced value when canonical and basic are absent', () => { + expect(readCanonicalTriggerValue(undefined, undefined, 'advanced')).toBe('advanced') + }) + + it('treats empty strings as unset', () => { + expect(readCanonicalTriggerValue('', '', 'advanced')).toBe('advanced') + expect(readCanonicalTriggerValue('', '')).toBeUndefined() + }) + + it('ignores non-string members', () => { + expect(readCanonicalTriggerValue(null, 42 as unknown, 'advanced')).toBe('advanced') + expect(readCanonicalTriggerValue(undefined, undefined)).toBeUndefined() + }) + + /** + * No-repoint invariant: for every config shape the previous app version could + * have stored, the new canonical-first read must resolve to the SAME resource + * the old basic-first read returned — whether the row has been backfilled + * (canonical key present) or not (transitional fallback). The legacy + * basic-first read is reproduced inline as the source of truth. + */ + describe('no-repoint across legacy and backfilled shapes', () => { + interface GoogleDriveConfig { + folderId?: string + manualFolderId?: string + } + // The exact pre-change Google Drive poller read (basic key === canonical key). + const legacyDriveRead = (c: GoogleDriveConfig) => c.folderId || c.manualFolderId + // Mirror of the migration 0253 backfill: fill the canonical key basic-first + // ONLY when absent. For Drive the canonical key IS the basic key (folderId). + const backfillDrive = (c: GoogleDriveConfig): GoogleDriveConfig => + c.folderId ? c : { ...c, folderId: c.folderId || c.manualFolderId } + + const driveShapes: GoogleDriveConfig[] = [ + { folderId: 'basic-only' }, + { manualFolderId: 'advanced-only' }, + // Drift: stale basic + active advanced both stored. Current poller reads basic. + { folderId: 'STALE', manualFolderId: 'ACTIVE' }, + {}, + ] + + it.each(driveShapes)('Drive legacy config %o reads same resource as before', (config) => { + const before = legacyDriveRead(config) + const after = readCanonicalTriggerValue(config.folderId, config.manualFolderId) + expect(after).toBe(before || undefined) + }) + + it.each(driveShapes)('Drive backfilled config %o reads same resource as before', (config) => { + const before = legacyDriveRead(config) + const backfilled = backfillDrive(config) + const after = readCanonicalTriggerValue(backfilled.folderId, backfilled.manualFolderId) + expect(after).toBe(before || undefined) + }) + + interface TableConfig { + tableId?: string + tableSelector?: string + manualTableId?: string + } + // The exact pre-change table reader (already canonical-first, but tableId was + // never written, so it fell through to the raw keys). + const legacyTableRead = (c: TableConfig) => c.tableId ?? c.tableSelector ?? c.manualTableId + // Mirror of the migration 0253 backfill for table (canonical key is distinct). + const backfillTable = (c: TableConfig): TableConfig => + c.tableId ? c : { ...c, tableId: c.tableSelector ?? c.manualTableId } + + const tableShapes: TableConfig[] = [ + { tableSelector: 'basic-only' }, + { manualTableId: 'advanced-only' }, + { tableSelector: 'STALE', manualTableId: 'ACTIVE' }, + { tableId: 'collapsed', tableSelector: 'STALE', manualTableId: 'ACTIVE' }, + {}, + ] + + it.each(tableShapes)('table legacy config %o reads same resource as before', (config) => { + const before = legacyTableRead(config) + const after = readCanonicalTriggerValue( + config.tableId, + config.tableSelector, + config.manualTableId + ) + expect(after).toBe(before ?? undefined) + }) + + it.each(tableShapes)('table backfilled config %o reads same resource as before', (config) => { + const before = legacyTableRead(config) + const backfilled = backfillTable(config) + const after = readCanonicalTriggerValue( + backfilled.tableId, + backfilled.tableSelector, + backfilled.manualTableId + ) + expect(after).toBe(before ?? undefined) + }) + }) +}) diff --git a/apps/sim/lib/webhooks/polling/canonical.ts b/apps/sim/lib/webhooks/polling/canonical.ts new file mode 100644 index 00000000000..239c3d0b581 --- /dev/null +++ b/apps/sim/lib/webhooks/polling/canonical.ts @@ -0,0 +1,31 @@ +/** + * Resolve the live resource id for a canonical trigger field from a deployed + * `webhook.providerConfig`. + * + * The canonical key — written at the deploy boundary by `buildProviderConfig` + * and populated on pre-existing rows by migration `0253` — is authoritative and + * is read first. The `transitionalFallback` values are read basic-first ONLY + * when the canonical key is absent, i.e. for configs deployed before the + * canonical key existed and not yet backfilled (including webhooks created by + * the previous app version during a deploy cutover). + * + * The fallback is TRANSITIONAL: once the backfill is confirmed and every + * deployed config carries the canonical key, it can be deleted in a follow-up + * contract phase and callers can read the canonical key alone. It exists solely + * for migration safety, not as permanent precedence logic. + * + * Empty strings are treated as unset (matching the previous basic-first `||` + * reads); returns `undefined` when no member is set. + * + * @param canonicalValue - value stored under the group's `canonicalParamId` + * @param transitionalFallback - the raw subblock values in basic-first order + */ +export function readCanonicalTriggerValue( + canonicalValue: unknown, + ...transitionalFallback: unknown[] +): string | undefined { + for (const value of [canonicalValue, ...transitionalFallback]) { + if (typeof value === 'string' && value.length > 0) return value + } + return undefined +} diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index f7740127b13..22ebfb0de0b 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -1,6 +1,7 @@ import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { pollingIdempotency } from '@/lib/core/idempotency/service' +import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical' import { getProviderConfig, type PollingProviderHandler, @@ -106,7 +107,10 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { ) const config = getProviderConfig(webhookData.providerConfig) - const calendarId = config.calendarId || config.manualCalendarId || 'primary' + // Canonical key `calendarId` first; `manualCalendarId` is a transitional basic-first + // fallback. Defaults to the user's primary calendar when none is set. + const calendarId = + readCanonicalTriggerValue(config.calendarId, config.manualCalendarId) || 'primary' // First poll: seed timestamp, emit nothing if (!config.lastCheckedTimestamp) { diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index ecd343d1e4d..57a7ceba0f8 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -1,6 +1,7 @@ import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { pollingIdempotency } from '@/lib/core/idempotency/service' +import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical' import { getProviderConfig, type PollingProviderHandler, @@ -335,7 +336,8 @@ function filterChanges( if (file.trashed) return false - const folderId = config.folderId || config.manualFolderId + // Canonical key `folderId` first; `manualFolderId` is a transitional basic-first fallback. + const folderId = readCanonicalTriggerValue(config.folderId, config.manualFolderId) if (folderId) { if (!file.parents || !file.parents.includes(folderId)) { return false diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index 6905d36c06f..1239993ffb6 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -1,6 +1,7 @@ import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { pollingIdempotency } from '@/lib/core/idempotency/service' +import { readCanonicalTriggerValue } from '@/lib/webhooks/polling/canonical' import { getProviderConfig, type PollingProviderHandler, @@ -63,8 +64,13 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { ) const config = getProviderConfig(webhookData.providerConfig) - const spreadsheetId = config.spreadsheetId || config.manualSpreadsheetId - const sheetName = config.sheetName || config.manualSheetName + // Canonical keys (`spreadsheetId`/`sheetName`) first; the `manual*` keys are a transitional + // basic-first fallback for configs deployed before the canonical key existed. + const spreadsheetId = readCanonicalTriggerValue( + config.spreadsheetId, + config.manualSpreadsheetId + ) + const sheetName = readCanonicalTriggerValue(config.sheetName, config.manualSheetName) const now = new Date() if (!spreadsheetId || !sheetName) { diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index fa9dbb37f9a..ac866070a14 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -182,7 +182,11 @@ function extractSelectorContext( ): SelectorContext { const block = currentState.blocks?.[blockId] if (!block?.subBlocks) return { workflowId, workspaceId } - return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId, workspaceId }) + return buildSelectorContextFromBlock(block.type, block.subBlocks, { + workflowId, + workspaceId, + canonicalModes: block.data?.canonicalModes, + }) } /** diff --git a/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts b/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts index e15c07a17ce..d6261bc01c5 100644 --- a/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts +++ b/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts @@ -99,6 +99,91 @@ describe('remapWorkflowReferencesInSubBlocks', () => { expect(tools[0].params?.workflowId).toBe('sub-dst') }) + it('clears the sibling inputMapping when an unmapped workflow selector is cleared (U10)', () => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowId.value).toBe('') + expect(result.inputMapping.value).toBe('') + }) + + it('keeps inputMapping when the workflow selector is remapped (not cleared)', () => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowId.value).toBe('wf-dst') + expect(result.inputMapping.value).toBe('{"a":"b"}') + }) + + // The `inputMapping` belongs to the ACTIVE canonical mode's workflow only. resolveCanonicalMode + // picks the active mode (block.data.canonicalModes override, else the value heuristic); the wipe + // fires iff the ACTIVE mode's workflowId was removed by the remap. clearUnmapped: true throughout. + it('keeps inputMapping: active basic valid + dormant advanced stale (no override)', () => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowId.value).toBe('wf-dst') + expect(result.manualWorkflowId.value).toBe('') + expect(result.inputMapping.value).toBe('{"a":"b"}') + }) + + it('wipes inputMapping: active advanced stale (canonicalModes override) + dormant basic valid', () => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { + clearUnmapped: true, + canonicalModes: { workflowId: 'advanced' }, + }) + expect(result.workflowId.value).toBe('wf-dst') + expect(result.manualWorkflowId.value).toBe('') + expect(result.inputMapping.value).toBe('') + }) + + it('wipes inputMapping: active basic stale (heuristic) + dormant advanced valid', () => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' }, + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowId.value).toBe('') + expect(result.manualWorkflowId.value).toBe('wf-dst') + expect(result.inputMapping.value).toBe('') + }) + + it('wipes inputMapping: active advanced stale + basic empty (heuristic)', () => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' }, + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.manualWorkflowId.value).toBe('') + expect(result.inputMapping.value).toBe('') + }) + + it('keeps inputMapping: both modes valid', () => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowId.value).toBe('wf-dst') + expect(result.manualWorkflowId.value).toBe('sub-dst') + expect(result.inputMapping.value).toBe('{"a":"b"}') + }) + it('remaps the advanced-mode manualWorkflowId override', () => { const subBlocks: SubBlockRecord = { manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' }, @@ -134,6 +219,61 @@ describe('remapWorkflowReferencesInSubBlocks', () => { const result = remapWorkflowReferencesInSubBlocks(subBlocks, map) expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst']) }) + + // create-fork scopes its workflow id map to the workflows ACTUALLY copied (deployed state + // loaded). With BOTH the parent (`wf-src`) and child (`sub-src`) workflows copied, every + // reference variety must remap to the child id (NOT clear), even under fork-create's + // clearUnmapped policy - the explicit "both deployed and copied" guard. + it('remaps every reference variety when both referenced workflows are copied (clearUnmapped)', () => { + const subBlocks: SubBlockRecord = { + selector: { id: 'selector', type: 'workflow-selector', value: 'wf-src' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' }, + manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' }, + workflowSelector: { id: 'workflowSelector', type: 'dropdown', value: ['wf-src', 'sub-src'] }, + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { type: 'workflow_input', params: { workflowId: 'sub-src', inputMapping: '{}' } }, + { type: 'custom-tool', customToolId: 'ct-1' }, + ], + }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.selector.value).toBe('wf-dst') + expect(result.inputMapping.value).toBe('{"a":"b"}') + expect(result.manualWorkflowId.value).toBe('sub-dst') + expect(result.manualWorkflowIds.value).toBe('wf-dst,sub-dst') + expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst']) + const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }> + expect(tools[0].params?.workflowId).toBe('sub-dst') + expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' }) + }) + + // A deployed source workflow whose state failed to load is excluded from the scoped fork map, + // so a copied workflow's reference to it clears (never dangles at a never-created child id). + it('clears references to a deployed-but-uncopied workflow absent from the scoped map', () => { + const subBlocks: SubBlockRecord = { + selector: { id: 'selector', type: 'workflow-selector', value: 'wf-uncopied' }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + manualWorkflowIds: { + id: 'manualWorkflowIds', + type: 'short-input', + value: 'wf-src,wf-uncopied', + }, + tools: { + id: 'tools', + type: 'tool-input', + value: [{ type: 'workflow_input', params: { workflowId: 'wf-uncopied' } }], + }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.selector.value).toBe('') + expect(result.inputMapping.value).toBe('') + expect(result.manualWorkflowIds.value).toBe('wf-dst') + expect(result.tools.value as unknown[]).toHaveLength(0) + }) }) describe('coerceObjectArray', () => { diff --git a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts index e71b9497367..d21d12c6e1e 100644 --- a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts +++ b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { remapConditionBlockIds } from '@/lib/workflows/condition-ids' +import { resolveCanonicalMode } from '@/lib/workflows/subblocks/visibility' import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' const logger = createLogger('WorkflowRemapInternalIds') @@ -140,7 +141,7 @@ export function remapVariableIdsInSubBlocks( export function remapWorkflowReferencesInSubBlocks( subBlocks: SubBlockRecord, workflowIdMap: Map | undefined, - options?: { clearUnmapped?: boolean } + options?: { clearUnmapped?: boolean; canonicalModes?: Record } ): SubBlockRecord { if (!workflowIdMap?.size) return subBlocks const clearUnmapped = options?.clearUnmapped ?? false @@ -149,10 +150,24 @@ export function remapWorkflowReferencesInSubBlocks( if (mapped) return mapped return clearUnmapped ? '' : value } + // The `workflowId` canonical pair: basic `workflow-selector` + advanced `manualWorkflowId`. Capture + // each key (by type/baseKey, regardless of value) and its ORIGINAL value so the inputMapping wipe + // below can decide on the ACTIVE mode's disposition via `resolveCanonicalMode`. + let basicId: string | undefined + let basicValue = '' + let advancedId: string | undefined + let advancedValue = '' const updated: SubBlockRecord = {} for (const [key, subBlock] of Object.entries(subBlocks)) { if (subBlock && typeof subBlock === 'object') { const baseKey = key.replace(/_\d+$/, '') + if (subBlock.type === 'workflow-selector' && basicId === undefined) { + basicId = key + basicValue = typeof subBlock.value === 'string' ? subBlock.value : '' + } else if (baseKey === 'manualWorkflowId' && advancedId === undefined) { + advancedId = key + advancedValue = typeof subBlock.value === 'string' ? subBlock.value : '' + } if ( (subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') && typeof subBlock.value === 'string' && @@ -178,6 +193,29 @@ export function remapWorkflowReferencesInSubBlocks( } updated[key] = subBlock } + + if (basicId !== undefined || advancedId !== undefined) { + const isEmptyValue = (value: unknown) => value === '' || value == null + const values: Record = {} + if (basicId !== undefined) values[basicId] = basicValue + if (advancedId !== undefined) values[advancedId] = advancedValue + const activeMode = resolveCanonicalMode( + { canonicalId: 'workflowId', basicId, advancedIds: advancedId ? [advancedId] : [] }, + values, + options?.canonicalModes + ) + const activeKey = activeMode === 'advanced' ? advancedId : basicId + const originalActive = activeKey === basicId ? basicValue : advancedValue + const postActive = activeKey !== undefined ? updated[activeKey]?.value : undefined + if (activeKey !== undefined && !isEmptyValue(originalActive) && isEmptyValue(postActive)) { + for (const [key, subBlock] of Object.entries(updated)) { + if (key.replace(/_\d+$/, '') !== 'inputMapping') continue + if (!subBlock || typeof subBlock !== 'object') continue + if (subBlock.value === '' || subBlock.value == null) continue + updated[key] = { ...subBlock, value: '' } + } + } + } return updated } diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index bfd1662eaa4..2a412fedd6c 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -35,6 +35,7 @@ import { normalizeDependencyValue, parseDependsOn, resolveDependencyValue, + scopeCanonicalModesForTool, shouldUseSubBlockForTriggerModeCanonicalIndex, } from '@/lib/workflows/subblocks/visibility' import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' @@ -518,22 +519,6 @@ function safeParseJson(value: string): unknown { } } -function scopeToolCanonicalModes( - canonicalModes: CanonicalModeOverrides | undefined, - blockType: string | undefined -): CanonicalModeOverrides | undefined { - if (!canonicalModes || !blockType) return undefined - - const prefix = `${blockType}:` - let scoped: CanonicalModeOverrides | undefined - for (const [key, value] of Object.entries(canonicalModes)) { - if (!key.startsWith(prefix) || !value) continue - scoped = scoped ?? {} - scoped[key.slice(prefix.length)] = value - } - return scoped -} - function parseToolParamValue(value: unknown, subBlockType: SubBlockType): unknown { if (value === undefined || value === null) return '' if (typeof value !== 'string') return value @@ -738,7 +723,7 @@ export function getToolInputParamConfigs({ if (!toolId) return genericFallback() - const scopedCanonicalModes = scopeToolCanonicalModes(parentCanonicalModes, tool.type) + const scopedCanonicalModes = scopeCanonicalModesForTool(parentCanonicalModes, tool.type) const blockConfig = tool.type !== 'custom-tool' && tool.type !== 'mcp' ? (blockConfigs?.[tool.type] ?? getBlock(tool.type)) diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index 29cad46d941..7103152601e 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -36,6 +36,30 @@ describe('buildSelectorContextFromBlock', () => { expect(ctx.knowledgeBaseId).toBe('manual-kb-id') }) + it('resolves the ACTIVE member when both basic and advanced hold values (no last-write-wins)', () => { + const subBlocks = { + operation: { id: 'operation', type: 'dropdown', value: 'search' }, + knowledgeBaseSelector: { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + value: 'kb-basic', + }, + manualKnowledgeBaseId: { + id: 'manualKnowledgeBaseId', + type: 'short-input', + value: 'kb-advanced', + }, + } + // No override: the value heuristic keeps basic (matches a default-basic migrated block). + expect(buildSelectorContextFromBlock('knowledge', subBlocks).knowledgeBaseId).toBe('kb-basic') + // Explicit advanced toggle: the active member wins (the dormant basic value never leaks). + expect( + buildSelectorContextFromBlock('knowledge', subBlocks, { + canonicalModes: { knowledgeBaseId: 'advanced' }, + }).knowledgeBaseId + ).toBe('kb-advanced') + }) + it('should skip null/empty values', () => { const ctx = buildSelectorContextFromBlock('knowledge', { knowledgeBaseSelector: { diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index a1b6a9076bd..4b71917e460 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -1,7 +1,12 @@ import { getBlock } from '@/blocks' import type { SelectorContext } from '@/hooks/selectors/types' import type { SubBlockState } from '@/stores/workflows/workflow/types' -import { buildCanonicalIndex } from './visibility' +import { + buildCanonicalIndex, + buildSubBlockValues, + type CanonicalModeOverrides, + resolveActiveCanonicalValue, +} from './visibility' /** * Canonical param IDs (or raw subblock IDs) that correspond to SelectorContext fields. @@ -42,7 +47,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ export function buildSelectorContextFromBlock( blockType: string, subBlocks: Record, - opts?: { workflowId?: string; workspaceId?: string } + opts?: { workflowId?: string; workspaceId?: string; canonicalModes?: CanonicalModeOverrides } ): SelectorContext { const context: SelectorContext = {} if (opts?.workflowId) context.workflowId = opts.workflowId @@ -52,17 +57,30 @@ export function buildSelectorContextFromBlock( if (!blockConfig) return context const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const values = buildSubBlockValues(subBlocks) + const resolvedGroups = new Set() - for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { - const val = subBlock?.value - if (val === null || val === undefined) continue - const strValue = typeof val === 'string' ? val : String(val) - if (!strValue) continue + const setField = (key: string, value: unknown) => { + if (value === null || value === undefined) return + const strValue = typeof value === 'string' ? value : String(value) + if (!strValue) return + if (SELECTOR_CONTEXT_FIELDS.has(key as keyof SelectorContext)) { + context[key as keyof SelectorContext] = strValue + } + } - const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[subBlockId] ?? subBlockId - if (SELECTOR_CONTEXT_FIELDS.has(canonicalKey as keyof SelectorContext)) { - context[canonicalKey as keyof SelectorContext] = strValue + for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId] + if (canonicalId) { + // A canonical group resolves to its ACTIVE member only (no last-write-wins between a + // basic/advanced pair when both hold values), honoring an explicit mode override. + if (resolvedGroups.has(canonicalId)) continue + resolvedGroups.add(canonicalId) + const group = canonicalIndex.groupsById[canonicalId] + setField(canonicalId, resolveActiveCanonicalValue(group, values, opts?.canonicalModes)) + continue } + setField(subBlockId, subBlock?.value) } return context diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index b49d2730df5..7cce5d9575e 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -241,6 +241,47 @@ export function getCanonicalValues( return { basicValue, advancedValue, advancedSourceId } } +/** + * Resolve the ACTIVE canonical member's value for a group: the basic value in basic mode, the + * advanced value in advanced mode (per {@link resolveCanonicalMode} - honoring an explicit + * override, then the value heuristic). Strict: returns ONLY the active member's value with no + * cross-mode fallback, so a dormant mode's stale value can never leak. The single source of truth + * for "what value is live for this canonical pair" - use it instead of basic-first `||` / + * `?? 'basic'` reads or last-write-wins scans. + */ +export function resolveActiveCanonicalValue( + group: CanonicalGroup, + values: Record, + overrides?: CanonicalModeOverrides +): unknown { + const mode = resolveCanonicalMode(group, values, overrides) + const { basicValue, advancedValue } = getCanonicalValues(group, values) + return mode === 'advanced' ? advancedValue : basicValue +} + +/** + * Strip the `${toolType}:` prefix from canonical-mode override keys, returning the overrides for a + * nested tool keyed by bare `canonicalId`. An agent block stores its nested tools' modes scoped as + * `${toolType}:${canonicalId}` (to avoid cross-tool collisions when tools share a `canonicalParamId`), + * so this is the canonical un-scoping primitive. Returns `undefined` when there are no overrides, no + * `toolType`, or no matching keys. + */ +export function scopeCanonicalModesForTool( + overrides: CanonicalModeOverrides | undefined, + toolType: string | undefined +): CanonicalModeOverrides | undefined { + if (!overrides || !toolType) return undefined + const prefix = `${toolType}:` + let scoped: CanonicalModeOverrides | undefined + for (const [key, value] of Object.entries(overrides)) { + if (key.startsWith(prefix) && value) { + scoped = scoped ?? {} + scoped[key.slice(prefix.length)] = value + } + } + return scoped +} + /** * Check if a block has any standalone advanced-only fields (not part of canonical pairs). * These require the block-level advanced mode toggle to be visible. diff --git a/apps/sim/lib/workspaces/fork/copy/cleanup-failed.test.ts b/apps/sim/lib/workspaces/fork/copy/cleanup-failed.test.ts new file mode 100644 index 00000000000..1aa6378e8ed --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/cleanup-failed.test.ts @@ -0,0 +1,433 @@ +/** + * @vitest-environment node + */ +import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const dbMock = vi.hoisted(() => { + const reads = new Map() + const updates: Array<{ table: unknown; values: Record }> = [] + const deletes: Array<{ table: unknown }> = [] + + const nextPage = (table: unknown): unknown[] => { + const pages = reads.get(table) + return pages && pages.length > 0 ? (pages.shift() as unknown[]) : [] + } + + // A drizzle-style read builder bound to one table: `.where`/`.orderBy`/`.limit` chain back to + // the same builder, and awaiting it (at `.where()` or `.limit()`) shifts that table's next page. + const makeReadBuilder = (table: unknown) => { + const builder = { + where: () => builder, + orderBy: () => builder, + limit: () => builder, + then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (error: unknown) => unknown) => + Promise.resolve(nextPage(table)).then(onFulfilled, onRejected), + } + return builder + } + + const db = { + select: () => ({ from: (table: unknown) => makeReadBuilder(table) }), + update: (table: unknown) => ({ + set: (values: Record) => ({ + where: () => { + updates.push({ table, values }) + return Promise.resolve([]) + }, + }), + }), + delete: (table: unknown) => ({ + where: () => { + deletes.push({ table }) + return Promise.resolve([]) + }, + }), + } + + return { + db, + updates, + deletes, + queueRead: (table: unknown, ...pages: unknown[][]) => reads.set(table, pages), + reset: () => { + reads.clear() + updates.length = 0 + deletes.length = 0 + }, + } +}) + +const { mockInvalidateDeployedStateCache } = vi.hoisted(() => ({ + mockInvalidateDeployedStateCache: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: dbMock.db, + dbReplica: dbMock.db, + runOutsideTransactionContext: (fn: () => T): T => fn(), + instrumentPoolClient: (client: T): T => client, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + invalidateDeployedStateCache: mockInvalidateDeployedStateCache, + CREDENTIAL_SUBBLOCK_IDS: new Set(['credential', 'manualCredential', 'triggerCredentials']), +})) + +// The reference indexer resolves a tool's params via the tool registry; stub it so loading the +// remap module never pulls the full registry (this file only exercises top-level selectors). +vi.mock('@/tools/params', () => ({ + getToolIdForOperation: () => undefined, + getToolParametersConfig: () => null, + getSubBlocksForToolInput: ( + _toolId: string, + _type: string, + _values: unknown, + _modes: unknown, + provided?: { subBlocks?: SubBlockConfig[] } + ) => ({ subBlocks: provided?.subBlocks ?? [] }), + formatParameterLabel: (label: string) => label, +})) + +import { + clearFailedForkResourceReferences, + clearFailedReferencesInDeploymentVersions, + clearFailedReferencesInWorkflows, + rewriteDeploymentVersionState, +} from '@/lib/workspaces/fork/copy/cleanup-failed' +import type { ForkCopyResolver } from '@/lib/workspaces/fork/remap/fork-bootstrap' +import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' +import { getBlock } from '@/blocks/registry' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' + +const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig => + ({ name: 'Knowledge', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig + +/** A KB block whose `documentId` (document-selector) hangs off `knowledgeBaseId` (kb-selector). */ +const kbBlockConfig = () => + blockWith([ + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { id: 'documentId', title: 'Doc', type: 'document-selector', dependsOn: ['knowledgeBaseId'] }, + ]) + +/** An agent block whose `tools` tool-input holds a KB tool with a nested `knowledgeBaseId` param. */ +const agentToolConfig = (type: string): BlockConfig => { + if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + if (type === 'kbtool') + return blockWith([{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }]) + return undefined as unknown as BlockConfig +} + +/** A deployment-version state whose agent block references `kbId` inside a tool-input tool param. */ +const agentVersionState = (kbId: string) => ({ + blocks: { + 'agent-1': { + id: 'agent-1', + type: 'agent', + name: 'Agent', + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [{ type: 'kbtool', toolId: 'kbtool_search', params: { knowledgeBaseId: kbId } }], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, +}) + +type AgentStateBlocks = { + blocks: { + 'agent-1': { subBlocks: { tools: { value: Array<{ params: { knowledgeBaseId: string } }> } } } + } +} +const nestedKbValue = (state: unknown) => + (state as AgentStateBlocks).blocks['agent-1'].subBlocks.tools.value[0].params.knowledgeBaseId + +/** A serialized deployment-version state whose single block points its KB selector at `kbId`. */ +const versionState = (kbId: string) => ({ + blocks: { + 'block-1': { + id: 'block-1', + type: 'knowledge', + name: 'KB Block', + subBlocks: { + knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: kbId }, + documentId: { id: 'documentId', type: 'document-selector', value: 'doc-keep' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, +}) + +const draftBlockRow = (kbId: string) => ({ + id: 'b-1', + workflowId: 'wf-1', + type: 'knowledge', + subBlocks: { + knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: kbId }, + documentId: { id: 'documentId', type: 'document-selector', value: 'doc-keep' }, + }, +}) + +/** A draft block whose `file-upload` subblock points at a copied workspace-file storage key. */ +const fileBlockRow = (fileKey: string) => ({ + id: 'b-file', + workflowId: 'wf-1', + type: 'agent', + subBlocks: { + file: { id: 'file', type: 'file-upload', value: { key: fileKey, name: 'a.png' } }, + }, +}) + +const failedKbResolver: ForkCopyResolver = (kind, id) => + kind === 'knowledge-base' && id === 'failed-kb' ? null : id + +const failedByKind = () => + new Map>([['knowledge-base', new Set(['failed-kb'])]]) + +type StateBlocks = { blocks: Record }> } +const kbValue = (state: unknown) => + (state as StateBlocks).blocks['block-1'].subBlocks.knowledgeBaseId.value +const docValue = (state: unknown) => + (state as StateBlocks).blocks['block-1'].subBlocks.documentId.value + +describe('cleanup-failed', () => { + beforeEach(() => { + vi.clearAllMocks() + dbMock.reset() + vi.mocked(getBlock).mockReturnValue(kbBlockConfig()) + }) + + describe('rewriteDeploymentVersionState', () => { + it('clears a block ref that resolves to a failed id and its dependents', () => { + const result = rewriteDeploymentVersionState(versionState('failed-kb'), failedKbResolver) + expect(result.changed).toBe(true) + expect(kbValue(result.state)).toBe('') + // documentId hangs off knowledgeBaseId, so the cleared parent clears it too. + expect(docValue(result.state)).toBe('') + }) + + it('leaves a state that references no failed id untouched (same reference, not changed)', () => { + const input = versionState('other-kb') + const result = rewriteDeploymentVersionState(input, failedKbResolver) + expect(result.changed).toBe(false) + expect(result.state).toBe(input) + }) + + it('is tolerant of a malformed state shape', () => { + const input = { not: 'a workflow state' } + const result = rewriteDeploymentVersionState(input, failedKbResolver) + expect(result.changed).toBe(false) + expect(result.state).toBe(input) + }) + + // The deployed-version sweep must clear EVERY subblock variety the draft sweep does - + // including a failed id nested in an agent block's `tool-input` tool params, not only + // top-level selectors - via the shared remapForkSubBlocks/clearFailedSubBlockReferences. + it('clears a failed id nested in an agent tool-input param inside a deployed version', () => { + vi.mocked(getBlock).mockImplementation((type) => agentToolConfig(type)) + const result = rewriteDeploymentVersionState(agentVersionState('failed-kb'), failedKbResolver) + expect(result.changed).toBe(true) + expect(nestedKbValue(result.state)).toBe('') + }) + + it('leaves an agent tool-input param that references no failed id untouched', () => { + vi.mocked(getBlock).mockImplementation((type) => agentToolConfig(type)) + const input = agentVersionState('other-kb') + const result = rewriteDeploymentVersionState(input, failedKbResolver) + expect(result.changed).toBe(false) + expect(result.state).toBe(input) + }) + }) + + describe('clearFailedReferencesInWorkflows', () => { + it('sweeps the draft blocks and returns the affected workflow ids', async () => { + dbMock.queueRead(workflow, [{ id: 'wf-1' }]) + dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) + + const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test') + + expect([...affected]).toEqual(['wf-1']) + expect(dbMock.updates).toHaveLength(1) + expect(dbMock.updates[0].table).toBe(workflowBlocks) + const cleared = dbMock.updates[0].values.subBlocks as Record + expect(cleared.knowledgeBaseId.value).toBe('') + expect(cleared.documentId.value).toBe('') + }) + + it('returns an empty set and writes nothing when no block references a failed id', async () => { + dbMock.queueRead(workflow, [{ id: 'wf-1' }]) + dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) + + const affected = await clearFailedReferencesInWorkflows('child-ws', failedByKind(), 'test') + + expect(affected.size).toBe(0) + expect(dbMock.updates).toHaveLength(0) + }) + }) + + describe('clearFailedReferencesInDeploymentVersions', () => { + it('rewrites a version referencing a failed id and invalidates its deployed-state cache', async () => { + dbMock.queueRead(workflowDeploymentVersion, [ + { id: 'dv-1', version: 5, state: versionState('failed-kb') }, + ]) + + await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') + + expect(dbMock.updates).toHaveLength(1) + expect(dbMock.updates[0].table).toBe(workflowDeploymentVersion) + expect(kbValue(dbMock.updates[0].values.state)).toBe('') + expect(docValue(dbMock.updates[0].values.state)).toBe('') + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1') + }) + + it('leaves a version that does not reference a failed id unwritten and uncached', async () => { + dbMock.queueRead(workflowDeploymentVersion, [ + { id: 'dv-old', version: 3, state: versionState('other-kb') }, + ]) + + await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') + + expect(dbMock.updates).toHaveLength(0) + expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() + }) + + it('writes only the changed version when a workflow mixes referencing and non-referencing versions', async () => { + dbMock.queueRead(workflowDeploymentVersion, [ + { id: 'dv-active', version: 5, state: versionState('failed-kb') }, + { id: 'dv-old', version: 4, state: versionState('other-kb') }, + ]) + + await clearFailedReferencesInDeploymentVersions(new Set(['wf-1']), failedByKind(), 'test') + + expect(dbMock.updates).toHaveLength(1) + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active') + }) + + it('does nothing when no workflows were affected', async () => { + await clearFailedReferencesInDeploymentVersions(new Set(), failedByKind(), 'test') + expect(dbMock.updates).toHaveLength(0) + expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() + }) + }) + + describe('clearFailedForkResourceReferences', () => { + it('threads the draft sweep into the deployed sweep, then drops the placeholder', async () => { + dbMock.queueRead(workflow, [{ id: 'wf-1' }]) + dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) + dbMock.queueRead(workflowDeploymentVersion, [ + { id: 'dv-active', version: 5, state: versionState('failed-kb') }, + { id: 'dv-old', version: 4, state: versionState('other-kb') }, + ]) + + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + requestId: 'test', + }) + + expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) + // One draft block update + one deployed version update (only the referencing version). + const updatedTables = dbMock.updates.map((u) => u.table) + expect(updatedTables).toEqual([workflowBlocks, workflowDeploymentVersion]) + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledTimes(1) + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-active') + // The orphaned KB placeholder is dropped after both sweeps. + expect(dbMock.deletes).toHaveLength(1) + expect(dbMock.deletes[0].table).toBe(knowledgeBase) + }) + + it('still drops the placeholder when no workflow referenced the failed resource', async () => { + dbMock.queueRead(workflow, [{ id: 'wf-1' }]) + dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) + + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + requestId: 'test', + }) + + expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) + // No draft block referenced the failed id AND no deployed targets were threaded, so the + // deployed sweep is skipped entirely. + expect(dbMock.updates).toHaveLength(0) + expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled() + expect(dbMock.deletes).toHaveLength(1) + expect(dbMock.deletes[0].table).toBe(knowledgeBase) + }) + + it('sweeps a deployed target version even when no draft referenced the failed id', async () => { + // Draft is clean (other-kb), but a deployed target version still points at the dropped + // placeholder - the deployed-target scope (not draft divergence) catches it. + dbMock.queueRead(workflow, [{ id: 'wf-1' }]) + dbMock.queueRead(workflowBlocks, [draftBlockRow('other-kb')]) + dbMock.queueRead(workflowDeploymentVersion, [ + { id: 'dv-1', version: 5, state: versionState('failed-kb') }, + ]) + + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + deployedTargetWorkflowIds: ['wf-deployed'], + requestId: 'test', + }) + + expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) + expect(dbMock.updates.map((u) => u.table)).toContain(workflowDeploymentVersion) + expect(mockInvalidateDeployedStateCache).toHaveBeenCalledWith('dv-1') + // Clearing succeeded, so the placeholder is dropped. + expect(dbMock.deletes[0].table).toBe(knowledgeBase) + }) + + it('clears a file-upload reference to a failed copied blob and drops no row', async () => { + dbMock.queueRead(workflow, [{ id: 'wf-1' }]) + dbMock.queueRead(workflowBlocks, [fileBlockRow('workspace/child/failed.png')]) + + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'file', childKey: 'workspace/child/failed.png' }], + requestId: 'test', + }) + + expect(cleaned).toEqual({ cleared: 1, clearingFailed: false }) + expect(dbMock.updates).toHaveLength(1) + expect(dbMock.updates[0].table).toBe(workflowBlocks) + const cleared = dbMock.updates[0].values.subBlocks as Record + expect(cleared.file.value).toBe('') + // A failed file has no placeholder row to drop (the metadata row stays re-uploadable). + expect(dbMock.deletes).toHaveLength(0) + }) + + it('reports cleared:0 + clearingFailed and skips the placeholder drop when a clear phase throws', async () => { + // A clear-phase failure must not drop the placeholder: that would turn an empty placeholder + // into a dangling reference to a deleted row. Make the draft block UPDATE throw. + dbMock.queueRead(workflow, [{ id: 'wf-1' }]) + dbMock.queueRead(workflowBlocks, [draftBlockRow('failed-kb')]) + const originalUpdate = dbMock.db.update + dbMock.db.update = () => { + throw new Error('update failed') + } + try { + const cleaned = await clearFailedForkResourceReferences({ + childWorkspaceId: 'child-ws', + failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }], + requestId: 'test', + }) + // The count must NOT overstate: nothing was cleared and the flag marks cleanup incomplete. + expect(cleaned).toEqual({ cleared: 0, clearingFailed: true }) + } finally { + dbMock.db.update = originalUpdate + } + // The drop is skipped, so the placeholder row survives (no delete issued). + expect(dbMock.deletes).toHaveLength(0) + }) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts b/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts new file mode 100644 index 00000000000..5f557f68f7a --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts @@ -0,0 +1,356 @@ +import { db } from '@sim/db' +import { + document, + knowledgeBase, + userTableDefinitions, + workflow, + workflowBlocks, + workflowDeploymentVersion, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, asc, eq, gt, inArray } from 'drizzle-orm' +import { isRecord, type SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import { invalidateDeployedStateCache } from '@/lib/workflows/persistence/utils' +import type { ForkFailedResource } from '@/lib/workspaces/fork/copy/copy-resources' +import type { ForkCopyResolver } from '@/lib/workspaces/fork/remap/fork-bootstrap' +import { + clearDependentsOnRemap, + type ForkRemapKind, + remapForkSubBlocks, +} from '@/lib/workspaces/fork/remap/remap-references' + +const logger = createLogger('WorkspaceForkCleanupFailed') + +/** Child workflow ids loaded per page so the block sweep never materializes the whole workspace. */ +const WORKFLOW_PAGE = 200 + +/** Deployment versions loaded per page so a workflow with many versions never loads all at once. */ +const DEPLOYMENT_VERSION_PAGE = 100 + +/** Identity-or-clear resolver: a failed id resolves to null (cleared), any other id to itself. */ +function buildFailedResolver(failedByKind: Map>): ForkCopyResolver { + return (kind, id) => (failedByKind.get(kind)?.has(id) ? null : id) +} + +/** + * Apply the identity-or-clear resolver to one block's subBlocks: a value (top-level selector or + * nested tool param) that resolves to a failed id is cleared, and its `dependsOn` children with it; + * everything else is left untouched. Returns the rewritten record plus whether anything changed. + * Shared by the draft block sweep and the deployment-version state sweep so both clear identically. + */ +function clearFailedSubBlockReferences( + subBlocks: SubBlockRecord, + blockType: string, + resolve: ForkCopyResolver +): { subBlocks: SubBlockRecord; changed: boolean } { + const result = remapForkSubBlocks(subBlocks, resolve, 'create') + // remappedKeys is non-empty only when a failed id was actually cleared, so a block that + // referenced nothing failed is reported unchanged without a write. + if (result.remappedKeys.size === 0) return { subBlocks, changed: false } + return { + subBlocks: clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys), + changed: true, + } +} + +/** + * Clean up after a resource whose post-commit content fill failed: clear every subblock + * reference in the child workspace's workflows that points at the failed resource (so no + * subblock keeps a dead id), then drop the orphaned placeholder rows (a KB cascade-drops its + * documents + embeddings; a table cascade-drops its rows). In-content references inside copied + * skill/markdown bodies are intentionally left as graceful broken links rather than mutated. + * + * The deployed-version sweep covers the draft-affected workflows UNION this sync's deployed + * target workflows (`deployedTargetWorkflowIds`): a deployed version can reference the dropped + * placeholder even when the draft no longer does (the user edited the empty-looking block in the + * fill window), so scoping to draft divergence alone would miss it. + * + * Best-effort and isolated: a failure cleaning one resource is logged and the rest continue, so + * a cleanup error never aborts the others. The placeholder drop is SKIPPED when a reference-clear + * phase threw - dropping it then would turn an empty placeholder into a dangling reference to a + * deleted row; leaving it keeps the reference resolvable (to empty content) until a later retry. + * + * Returns `{ cleared, clearingFailed }` for the fork activity metadata. `clearingFailed` is true + * when a reference-clear phase threw - placeholders were then NOT dropped - and `cleared` is 0 in + * that case, so the report never claims references it did not actually clear. On success `cleared` + * is the count of failed resources whose references were cleared. + */ +export async function clearFailedForkResourceReferences(params: { + childWorkspaceId: string + failures: ForkFailedResource[] + /** Target workflows this sync deployed; their deployed versions are swept regardless of draft. */ + deployedTargetWorkflowIds?: string[] + requestId?: string +}): Promise<{ cleared: number; clearingFailed: boolean }> { + const { childWorkspaceId, failures, requestId = 'unknown' } = params + if (failures.length === 0) return { cleared: 0, clearingFailed: false } + + const failedByKind = new Map>() + const markFailed = (kind: ForkRemapKind, id: string) => { + const set = failedByKind.get(kind) + if (set) set.add(id) + else failedByKind.set(kind, new Set([id])) + } + const tableIds: string[] = [] + const kbIds: string[] = [] + // Standalone documents copied into an already-existing target KB (the doc-into-mapped-KB sync + // path) - dropped individually, since their KB is not ours to remove. + const docIds: string[] = [] + for (const failure of failures) { + if (failure.kind === 'table') { + markFailed('table', failure.childId) + tableIds.push(failure.childId) + } else if (failure.kind === 'knowledge-document') { + markFailed('knowledge-document', failure.childId) + docIds.push(failure.childId) + } else if (failure.kind === 'file') { + // A failed file blob: clear `file-upload` references to its copied storage key. No row to + // drop - the metadata row is left in place so the user can re-upload the missing blob. + markFailed('file', failure.childKey) + } else { + markFailed('knowledge-base', failure.childId) + for (const docId of failure.documentChildIds) markFailed('knowledge-document', docId) + kbIds.push(failure.childId) + } + } + + // Whether BOTH reference-clear phases completed without throwing. The placeholder drop below is + // gated on this: if clearing threw, a workflow (draft or deployed version) may still reference + // the failed id, so dropping its placeholder would create a dangling reference to a deleted row. + let clearingSucceeded = true + + let affectedWorkflowIds: Set = new Set() + try { + affectedWorkflowIds = await clearFailedReferencesInWorkflows( + childWorkspaceId, + failedByKind, + requestId + ) + } catch (error) { + clearingSucceeded = false + logger.error(`[${requestId}] Failed to clear references for failed fork resources`, { + childWorkspaceId, + error: getErrorMessage(error), + }) + } + + // The same dead id also lives in DEPLOYED version states (the active one re-cut from the + // placeholder-referencing draft at this sync's redeploy, plus any newer version from a rare + // race), not only the draft just swept above. Sweep those too so "Load deployment" can't + // re-poison the cleaned draft with a dropped id, and the execute path never runs against content + // that no longer exists. Scope is the draft-affected workflows UNION this sync's deployed target + // workflows: a deployed version may reference the placeholder even when the draft no longer does + // (edited in the fill window), so draft divergence alone is not a reliable scope. Isolated: a + // failure here never aborts a sibling resource's cleanup. + const deployedSweepIds = new Set(affectedWorkflowIds) + for (const id of params.deployedTargetWorkflowIds ?? []) deployedSweepIds.add(id) + try { + await clearFailedReferencesInDeploymentVersions(deployedSweepIds, failedByKind, requestId) + } catch (error) { + clearingSucceeded = false + logger.error(`[${requestId}] Failed to clear references in fork deployment versions`, { + childWorkspaceId, + error: getErrorMessage(error), + }) + } + + // Drop the orphaned placeholders. The KB delete cascades its documents + embeddings; the + // table delete cascades its rows; a standalone document delete cascades its embeddings. Done + // after the refs are cleared so a drop failure can't strand a workflow still pointing at the + // (now content-less) resource - and ONLY when clearing succeeded, so a clear failure never + // turns an empty placeholder into a dangling reference to a deleted row. + if (!clearingSucceeded) { + logger.warn( + `[${requestId}] Skipping fork resource placeholder drop after a reference-clear failure`, + { + childWorkspaceId, + tables: tableIds.length, + knowledgeBases: kbIds.length, + documents: docIds.length, + } + ) + return { cleared: 0, clearingFailed: true } + } + try { + if (tableIds.length > 0) { + await db.delete(userTableDefinitions).where(inArray(userTableDefinitions.id, tableIds)) + } + if (kbIds.length > 0) { + await db.delete(knowledgeBase).where(inArray(knowledgeBase.id, kbIds)) + } + if (docIds.length > 0) { + await db.delete(document).where(inArray(document.id, docIds)) + } + } catch (error) { + logger.error(`[${requestId}] Failed to drop orphaned fork resource placeholders`, { + childWorkspaceId, + error: getErrorMessage(error), + }) + } + + return { cleared: failures.length, clearingFailed: false } +} + +/** + * Sweep the child workspace's workflow blocks and clear any subblock value (top-level selector + * or nested tool param) that resolves to a failed child resource id. Reuses the create-mode + * remap with an identity-or-clear resolver: a non-failed id resolves to itself (left unchanged), + * a failed id resolves to null and is cleared, and its `dependsOn` children are cleared too. + * Returns the ids of the workflows whose blocks actually had a reference cleared, so the deployed + * version sweep can scope itself to exactly those workflows. + */ +export async function clearFailedReferencesInWorkflows( + childWorkspaceId: string, + failedByKind: Map>, + requestId: string +): Promise> { + const resolve = buildFailedResolver(failedByKind) + const affectedWorkflowIds = new Set() + + let afterWorkflowId: string | null = null + for (;;) { + const workflowRows = await db + .select({ id: workflow.id }) + .from(workflow) + .where( + afterWorkflowId === null + ? eq(workflow.workspaceId, childWorkspaceId) + : and(eq(workflow.workspaceId, childWorkspaceId), gt(workflow.id, afterWorkflowId)) + ) + .orderBy(asc(workflow.id)) + .limit(WORKFLOW_PAGE) + if (workflowRows.length === 0) break + + const workflowIds = workflowRows.map((row) => row.id) + const blocks = await db + .select({ + id: workflowBlocks.id, + workflowId: workflowBlocks.workflowId, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(inArray(workflowBlocks.workflowId, workflowIds)) + + for (const block of blocks) { + const current = (block.subBlocks ?? {}) as SubBlockRecord + const { subBlocks: cleared, changed } = clearFailedSubBlockReferences( + current, + block.type, + resolve + ) + if (!changed) continue + await db + .update(workflowBlocks) + .set({ subBlocks: cleared }) + .where(eq(workflowBlocks.id, block.id)) + affectedWorkflowIds.add(block.workflowId) + } + + if (workflowRows.length < WORKFLOW_PAGE) break + afterWorkflowId = workflowIds[workflowIds.length - 1] + } + + return affectedWorkflowIds +} + +/** + * Rewrite a deployment version's serialized state in memory, clearing every block subblock that + * resolves to a failed id (and its `dependsOn` children). Returns `changed: false` with the + * original state when no block referenced a failed id - so a version cut before this sync, which + * cannot contain the new placeholder id, is a no-op and never written back. Tolerant of a + * malformed/legacy state shape (anything that is not `{ blocks: {...} }` is left untouched). + */ +export function rewriteDeploymentVersionState( + state: unknown, + resolve: ForkCopyResolver +): { state: unknown; changed: boolean } { + if (!isRecord(state) || !isRecord(state.blocks)) return { state, changed: false } + + let nextBlocks: Record | null = null + for (const [blockId, block] of Object.entries(state.blocks)) { + if (!isRecord(block)) continue + const blockType = typeof block.type === 'string' ? block.type : undefined + if (!blockType || !isRecord(block.subBlocks)) continue + const { subBlocks: cleared, changed } = clearFailedSubBlockReferences( + block.subBlocks as SubBlockRecord, + blockType, + resolve + ) + if (!changed) continue + nextBlocks ??= { ...state.blocks } + nextBlocks[blockId] = { ...block, subBlocks: cleared } + } + + if (!nextBlocks) return { state, changed: false } + return { state: { ...state, blocks: nextBlocks }, changed: true } +} + +/** + * Rewrite the DEPLOYED version states of the workflows whose draft blocks were just swept. The dead + * placeholder id lives in any version re-cut from the (placeholder-referencing) draft at this sync's + * redeploy - in practice the active one - so it must be cleared there too, not only in the draft. + * Every version of each affected workflow is examined, but only versions whose state actually + * changes are written: an older version predates the sync and cannot contain the new id, so it is a + * no-op. After a version is rewritten its cached deployed state is evicted so execute/serve rebuilds + * from the cleaned snapshot. Bounded work (no long transaction): per-version short UPDATEs, versions + * keyset-paginated, and a per-workflow failure is logged without aborting the other workflows. + */ +export async function clearFailedReferencesInDeploymentVersions( + workflowIds: ReadonlySet, + failedByKind: Map>, + requestId: string +): Promise { + if (workflowIds.size === 0) return + const resolve = buildFailedResolver(failedByKind) + + for (const workflowId of workflowIds) { + try { + let afterVersion: number | null = null + for (;;) { + const versions = await db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + state: workflowDeploymentVersion.state, + }) + .from(workflowDeploymentVersion) + .where( + afterVersion === null + ? eq(workflowDeploymentVersion.workflowId, workflowId) + : and( + eq(workflowDeploymentVersion.workflowId, workflowId), + gt(workflowDeploymentVersion.version, afterVersion) + ) + ) + .orderBy(asc(workflowDeploymentVersion.version)) + .limit(DEPLOYMENT_VERSION_PAGE) + if (versions.length === 0) break + + for (const version of versions) { + const { state: nextState, changed } = rewriteDeploymentVersionState( + version.state, + resolve + ) + if (!changed) continue + await db + .update(workflowDeploymentVersion) + .set({ state: nextState }) + .where(eq(workflowDeploymentVersion.id, version.id)) + // Evict the post-migration deployed state cached by this immutable version id so the + // execute/serve path rebuilds from the cleaned snapshot. + invalidateDeployedStateCache(version.id) + } + + if (versions.length < DEPLOYMENT_VERSION_PAGE) break + afterVersion = versions[versions.length - 1].version + } + } catch (error) { + logger.error(`[${requestId}] Failed to clear references in deployment versions`, { + workflowId, + error: getErrorMessage(error), + }) + } + } +} diff --git a/apps/sim/lib/workspaces/fork/copy/content-copy-runner.test.ts b/apps/sim/lib/workspaces/fork/copy/content-copy-runner.test.ts new file mode 100644 index 00000000000..0dea89e5db4 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/content-copy-runner.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + hasForkContentToCopy, + serializeContentRefMaps, +} from '@/lib/workspaces/fork/copy/content-copy-runner' +import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files' +import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources' + +describe('serializeContentRefMaps', () => { + it('converts each map to a record and drops empty maps', () => { + const result = serializeContentRefMaps({ + workspaceId: { from: 'src', to: 'dst' }, + fileKeys: new Map([['k1', 'k2']]), + fileIds: new Map(), + workflows: new Map([['wf-src', 'wf-dst']]), + knowledgeBases: new Map([['kb-src', 'kb-dst']]), + skills: new Map([['sk-src', 'sk-dst']]), + }) + + expect(result.workspaceId).toEqual({ from: 'src', to: 'dst' }) + expect(result.fileKeys).toEqual({ k1: 'k2' }) + // An empty map is omitted rather than serialized to `{}`. + expect(result.fileIds).toBeUndefined() + expect(result.workflows).toEqual({ 'wf-src': 'wf-dst' }) + expect(result.knowledgeBases).toEqual({ 'kb-src': 'kb-dst' }) + expect(result.skills).toEqual({ 'sk-src': 'sk-dst' }) + // Maps not supplied stay undefined. + expect(result.tables).toBeUndefined() + expect(result.folders).toBeUndefined() + }) + + it('passes an undefined workspaceId through unchanged', () => { + const result = serializeContentRefMaps({}) + expect(result.workspaceId).toBeUndefined() + expect(result.fileKeys).toBeUndefined() + expect(result.skills).toBeUndefined() + }) +}) + +describe('hasForkContentToCopy', () => { + const emptyPlan = (): ForkContentPlan => ({ + sourceWorkspaceId: 'src', + childWorkspaceId: 'child', + userId: 'u', + tables: [], + knowledgeBases: [], + skills: [], + documents: [], + }) + // The helper only inspects array lengths, so a single placeholder entry per kind is enough. + const oneSkill = [{}] as unknown as ForkContentPlan['skills'] + const oneDoc = [{}] as unknown as ForkContentPlan['documents'] + const oneTable = [{}] as unknown as ForkContentPlan['tables'] + const oneKb = [{}] as unknown as ForkContentPlan['knowledgeBases'] + const oneBlob = [{}] as unknown as BlobCopyTask[] + const noBlobs: BlobCopyTask[] = [] + + it('is true when skills are non-empty (the create-fork skill-only fix)', () => { + expect(hasForkContentToCopy({ ...emptyPlan(), skills: oneSkill }, noBlobs)).toBe(true) + }) + + it('is true when documents are non-empty', () => { + expect(hasForkContentToCopy({ ...emptyPlan(), documents: oneDoc }, noBlobs)).toBe(true) + }) + + it('is true when tables are non-empty', () => { + expect(hasForkContentToCopy({ ...emptyPlan(), tables: oneTable }, noBlobs)).toBe(true) + }) + + it('is true when knowledgeBases are non-empty', () => { + expect(hasForkContentToCopy({ ...emptyPlan(), knowledgeBases: oneKb }, noBlobs)).toBe(true) + }) + + it('is true when there are blob tasks', () => { + expect(hasForkContentToCopy(emptyPlan(), oneBlob)).toBe(true) + }) + + it('is false for an all-empty plan with no blob tasks', () => { + expect(hasForkContentToCopy(emptyPlan(), noBlobs)).toBe(false) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/content-copy-runner.ts b/apps/sim/lib/workspaces/fork/copy/content-copy-runner.ts index 16f76dbf3d0..2a60f33f333 100644 --- a/apps/sim/lib/workspaces/fork/copy/content-copy-runner.ts +++ b/apps/sim/lib/workspaces/fork/copy/content-copy-runner.ts @@ -1,10 +1,52 @@ import { db } from '@sim/db' +import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { runDetached } from '@/lib/core/utils/background' import { finishBackgroundWork } from '@/lib/workspaces/fork/background-work/store' +import { clearFailedForkResourceReferences } from '@/lib/workspaces/fork/copy/cleanup-failed' import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files' import { executeForkFileBlobCopies } from '@/lib/workspaces/fork/copy/copy-files' -import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources' +import type { ForkContentPlan, ForkFailedResource } from '@/lib/workspaces/fork/copy/copy-resources' import { copyForkResourceContent } from '@/lib/workspaces/fork/copy/copy-resources' +import type { ForkContentRefMaps } from '@/lib/workspaces/fork/remap/remap-content-refs' + +const logger = createLogger('WorkspaceForkContentCopy') + +/** + * Whether a fork/sync has any heavy content to copy after the commit: table rows, KB documents, + * copied skill bodies, standalone documents, or file blobs. The single gate for scheduling the + * background content fill - shared by fork-create and promote so the two can't diverge. A + * skill-only (or documents-only) copy must still schedule it, otherwise the in-content `sim:` / + * serve-link rewrite in {@link runForkContentCopy} never runs and copied bodies keep source links. + */ +export function hasForkContentToCopy( + contentPlan: ForkContentPlan, + blobTasks: BlobCopyTask[] +): boolean { + return ( + contentPlan.tables.length > 0 || + contentPlan.knowledgeBases.length > 0 || + contentPlan.skills.length > 0 || + contentPlan.documents.length > 0 || + blobTasks.length > 0 + ) +} + +/** + * JSON-serializable form of {@link ForkContentRefMaps} (Maps become Records) so the in-content + * reference maps survive the Trigger.dev payload boundary. Rehydrated to Maps in the runner. + */ +export interface SerializableForkContentRefMaps { + workspaceId?: { from: string; to: string } + fileKeys?: Record + fileIds?: Record + workflows?: Record + knowledgeBases?: Record + tables?: Record + skills?: Record + folders?: Record +} /** * Serializable payload for the post-fork heavy-content copy. Runs either as a @@ -14,15 +56,66 @@ import { copyForkResourceContent } from '@/lib/workspaces/fork/copy/copy-resourc export interface ForkContentCopyPayload { contentPlan: ForkContentPlan blobTasks: BlobCopyTask[] + /** In-content reference maps for rewriting copied markdown blobs (serialized form). */ + contentRefMaps?: SerializableForkContentRefMaps /** * `background_work_status` row to finish when the copy ends, so the source workspace's * Manage Forks -> Activity entry resolves (completed / warning / error). Started right * after the fork commits so it's visible immediately. */ statusId?: string + /** + * Target workflow ids this sync deployed (promote's deploy loop). When a copied resource's + * fill fails, its dropped placeholder must be cleared from these workflows' DEPLOYED version + * states too - a deployed version can reference the placeholder even when the draft no longer + * does (edited in the fill window), so the cleanup unions these with the draft-affected set + * rather than relying on draft divergence. Empty/omitted for fork-create (child is undeployed). + */ + deployedTargetWorkflowIds?: string[] requestId?: string } +const toRefMap = (record?: Record): Map | undefined => + record ? new Map(Object.entries(record)) : undefined + +const fromRefMap = (map?: ReadonlyMap): Record | undefined => + map && map.size > 0 ? Object.fromEntries(map) : undefined + +/** + * Convert the Map-based {@link ForkContentRefMaps} to its JSON-serializable form for the + * Trigger.dev payload. Empty maps are dropped (omitted). Single source of truth for the + * Map->Record direction, paired with {@link deserializeContentRefMaps}. + */ +export function serializeContentRefMaps(maps: ForkContentRefMaps): SerializableForkContentRefMaps { + return { + workspaceId: maps.workspaceId, + fileKeys: fromRefMap(maps.fileKeys), + fileIds: fromRefMap(maps.fileIds), + workflows: fromRefMap(maps.workflows), + knowledgeBases: fromRefMap(maps.knowledgeBases), + tables: fromRefMap(maps.tables), + skills: fromRefMap(maps.skills), + folders: fromRefMap(maps.folders), + } +} + +/** Rehydrate the serialized content-ref maps to the Map-based {@link ForkContentRefMaps}. */ +function deserializeContentRefMaps( + serialized?: SerializableForkContentRefMaps +): ForkContentRefMaps | undefined { + if (!serialized) return undefined + return { + workspaceId: serialized.workspaceId, + fileKeys: toRefMap(serialized.fileKeys), + fileIds: toRefMap(serialized.fileIds), + workflows: toRefMap(serialized.workflows), + knowledgeBases: toRefMap(serialized.knowledgeBases), + tables: toRefMap(serialized.tables), + skills: toRefMap(serialized.skills), + folders: toRefMap(serialized.folders), + } +} + /** * Copy the heavy fork content after the fork transaction has committed: table * rows, KB documents + embeddings (keyset-paginated), and file blobs. Best-effort @@ -33,8 +126,23 @@ export interface ForkContentCopyPayload { export async function runForkContentCopy(payload: ForkContentCopyPayload): Promise { const { contentPlan, blobTasks, statusId, requestId } = payload try { - const resourceCounts = await copyForkResourceContent({ contentPlan, requestId }) - const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId) + const contentRefMaps = deserializeContentRefMaps(payload.contentRefMaps) + const resourceCounts = await copyForkResourceContent({ contentPlan, contentRefMaps, requestId }) + const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId, contentRefMaps) + // A resource whose content fill failed leaves a dangling reference: a table/KB/doc placeholder + // its workflows still point at, or a `file-upload` whose copied blob is missing. Clear those + // references (draft + deployed versions) and drop the table/KB/doc placeholder so nothing + // dangles; a failed file leaves its metadata row (re-uploadable) but has its refs cleared. + const fileFailures: ForkFailedResource[] = fileCounts.failedTargetKeys.map((childKey) => ({ + kind: 'file', + childKey, + })) + const { cleared, clearingFailed } = await clearFailedForkResourceReferences({ + childWorkspaceId: contentPlan.childWorkspaceId, + failures: [...resourceCounts.failures, ...fileFailures], + deployedTargetWorkflowIds: payload.deployedTargetWorkflowIds, + requestId, + }) const copied = resourceCounts.copied + fileCounts.copied const failed = resourceCounts.failed + fileCounts.failed if (statusId) { @@ -44,7 +152,12 @@ export async function runForkContentCopy(payload: ForkContentCopyPayload): Promi failed > 0 ? `Copied ${copied} item${copied === 1 ? '' : 's'}; ${failed} could not be copied` : `Copied ${copied} item${copied === 1 ? '' : 's'}`, - metadata: { copied, failed }, + metadata: { + copied, + failed, + clearedReferences: cleared, + ...(clearingFailed ? { clearingFailed: true } : {}), + }, }) } } catch (error) { @@ -57,3 +170,42 @@ export async function runForkContentCopy(payload: ForkContentCopyPayload): Promi throw error } } + +/** + * Schedule the post-commit heavy-content copy off the request path. Uses the Trigger.dev task + * when enabled (so it survives an app deploy), else `runDetached` inline best-effort. Shared by + * both fork and sync - only the `detachedLabel` (and so the inline job's name) differs. Never + * throws: a scheduling failure is logged and, when a status row exists, marks it failed, so a + * committed fork/sync is never turned into a 500 by a background-scheduling hiccup. + */ +export async function scheduleForkContentCopy( + payload: ForkContentCopyPayload, + options: { detachedLabel: string; requestId?: string } +): Promise { + const { detachedLabel, requestId = 'unknown' } = options + try { + if (isTriggerDevEnabled) { + const [{ forkContentCopyTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/fork-content-copy'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('fork-content-copy', payload, { + region: await resolveTriggerRegion(), + }) + } else { + runDetached(detachedLabel, () => runForkContentCopy(payload)) + } + } catch (error) { + logger.error(`[${requestId}] Failed to schedule fork content copy`, { + detachedLabel, + error: getErrorMessage(error), + }) + if (payload.statusId) { + await finishBackgroundWork(db, payload.statusId, { + status: 'failed', + error: getErrorMessage(error, 'Could not start the background copy'), + }).catch(() => {}) + } + } +} diff --git a/apps/sim/lib/workspaces/fork/copy/copy-files.ts b/apps/sim/lib/workspaces/fork/copy/copy-files.ts index 20bf90c59ad..e7e8f5080eb 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-files.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-files.ts @@ -2,15 +2,28 @@ import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull } from 'drizzle-orm' +import { and, eq, inArray, isNull, or } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' import type { StorageContext } from '@/lib/uploads/shared/types' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { + type ForkContentRefMaps, + rewriteForkContentRefs, +} from '@/lib/workspaces/fork/remap/remap-content-refs' const logger = createLogger('WorkspaceForkCopyFiles') +const MARKDOWN_CONTENT_TYPES = new Set(['text/markdown', 'text/x-markdown']) + +/** Whether a copied blob is markdown text whose in-content references should be rewritten. */ +function isMarkdownBlob(task: Pick): boolean { + if (MARKDOWN_CONTENT_TYPES.has(task.contentType)) return true + const name = task.fileName.toLowerCase() + return name.endsWith('.md') || name.endsWith('.mdx') || name.endsWith('.markdown') +} + export interface BlobCopyTask { sourceKey: string targetKey: string @@ -25,10 +38,17 @@ export interface PlanForkFileCopiesResult { /** * source storage key -> child storage key. `file-upload` subblocks reference * files by storage key (not `workspace_files.id`), so the fork remap keys on the - * storage key. File identity is not persisted in the fork resource map - files - * are a fork-copy-only resource (not remapped on promote). + * storage key. At sync time this map is persisted in the fork resource map + * (`resourceType: 'file'`, keyed by storage key) so a re-sync resolves the copy + * instead of re-copying; at create-fork time it is not (the child is brand new). */ keyMap: Map + /** + * source `workspace_files.id` -> child id. Used to rewrite in-content file references + * that key on the file id (`sim:file/`, `/api/files/view/`, the in-app files + * path) inside copied skill/markdown content; not persisted in the fork resource map. + */ + idMap: Map /** Blob duplications to run after the fork transaction commits. */ blobTasks: BlobCopyTask[] } @@ -40,31 +60,44 @@ export interface PlanForkFileCopiesResult { * (its idempotent metadata insert reuses the row), and both must run after the * child workspace row exists (FK). Runs in the fork transaction; blob I/O is * deferred to {@link executeForkFileBlobCopies}. + * + * Files are selected EITHER by `workspace_files.id` (the fork modal's picker lists files + * by id) OR by storage `key` (sync references key files by their storage key, not id). At + * least one of the two must be non-empty; both may be supplied (their matched rows union). */ export async function planForkFileCopies(params: { tx: DbOrTx sourceWorkspaceId: string childWorkspaceId: string userId: string - fileIds: string[] + fileIds?: string[] + fileKeys?: string[] now: Date }): Promise { - const { tx, sourceWorkspaceId, childWorkspaceId, userId, fileIds, now } = params + const { tx, sourceWorkspaceId, childWorkspaceId, userId, now } = params + const fileIds = params.fileIds ?? [] + const fileKeys = params.fileKeys ?? [] const keyMap = new Map() + const idMap = new Map() const blobTasks: BlobCopyTask[] = [] - if (fileIds.length === 0) return { keyMap, blobTasks } + if (fileIds.length === 0 && fileKeys.length === 0) return { keyMap, idMap, blobTasks } - // Batch the metadata read (one query for all selected files) instead of a per-file - // lookup: non-deleted, scoped to the source workspace, and restricted to durable - // `workspace` files. Only workspace files are forkable - chat/copilot/mothership - // uploads are session-scoped and their chat-bound unique index can't be duplicated - - // so any non-workspace id passed here is ignored rather than copied. + // Match by id and/or storage key (OR'd) so either selection shape resolves to the same + // source rows. Batch the metadata read (one query for all selected files): non-deleted, + // scoped to the source workspace, and restricted to durable `workspace` files. Only + // workspace files are forkable - chat/copilot/mothership uploads are session-scoped and + // their chat-bound unique index can't be duplicated - so any non-workspace id/key passed + // here is ignored rather than copied. + const selectors = [ + fileIds.length > 0 ? inArray(workspaceFiles.id, fileIds) : undefined, + fileKeys.length > 0 ? inArray(workspaceFiles.key, fileKeys) : undefined, + ].filter((clause): clause is NonNullable => clause !== undefined) const metas = await tx .select() .from(workspaceFiles) .where( and( - inArray(workspaceFiles.id, fileIds), + selectors.length === 1 ? selectors[0] : or(...selectors), eq(workspaceFiles.workspaceId, sourceWorkspaceId), eq(workspaceFiles.context, 'workspace'), isNull(workspaceFiles.deletedAt) @@ -87,6 +120,7 @@ export async function planForkFileCopies(params: { uploadedAt: now, }) keyMap.set(meta.key, targetKey) + idMap.set(meta.id, childFileId) blobTasks.push({ sourceKey: meta.key, targetKey, @@ -98,21 +132,27 @@ export async function planForkFileCopies(params: { }) } - return { keyMap, blobTasks } + return { keyMap, idMap, blobTasks } } /** * Duplicate each planned file blob to its new key. `uploadFile`'s metadata insert * is idempotent on the key (the row was already created in the transaction), so - * this only copies bytes. Best-effort: a failed blob leaves the metadata row - * pointing at a missing object, which the user can re-upload. + * this only copies bytes. Markdown blobs additionally have their in-content references + * (`sim:` links, embedded file/image URLs) rewritten through `contentRefMaps` so they + * point at the copied resources (unmapped targets are left as graceful broken links). + * Best-effort: a content-rewrite failure falls back to copying the raw bytes. A failed + * blob's child storage key is returned in `failedTargetKeys` so the caller can clear the + * `file-upload` references pointing at the now-missing object (the metadata row is left in + * place, so the user can still re-upload the blob). */ export async function executeForkFileBlobCopies( blobTasks: BlobCopyTask[], - requestId = 'unknown' -): Promise<{ copied: number; failed: number }> { + requestId = 'unknown', + contentRefMaps?: ForkContentRefMaps +): Promise<{ copied: number; failed: number; failedTargetKeys: string[] }> { let copied = 0 - let failed = 0 + const failedTargetKeys: string[] = [] for (const task of blobTasks) { try { const buffer = await downloadFile({ @@ -120,8 +160,21 @@ export async function executeForkFileBlobCopies( context: task.context, maxBytes: MAX_FILE_SIZE, }) + let body: Buffer = buffer + if (contentRefMaps && isMarkdownBlob(task)) { + try { + const text = buffer.toString('utf8') + const rewritten = rewriteForkContentRefs(text, contentRefMaps) + if (rewritten !== text) body = Buffer.from(rewritten, 'utf8') + } catch (error) { + logger.warn(`[${requestId}] Failed to rewrite markdown blob content; copying raw bytes`, { + targetKey: task.targetKey, + error: getErrorMessage(error), + }) + } + } await uploadFile({ - file: buffer, + file: body, fileName: task.fileName, contentType: task.contentType, context: task.context, @@ -135,12 +188,12 @@ export async function executeForkFileBlobCopies( }) copied += 1 } catch (error) { - failed += 1 + failedTargetKeys.push(task.targetKey) logger.warn(`[${requestId}] Failed to copy file blob during fork`, { targetKey: task.targetKey, error: getErrorMessage(error), }) } } - return { copied, failed } + return { copied, failed: failedTargetKeys.length, failedTargetKeys } } diff --git a/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts b/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts new file mode 100644 index 00000000000..a1ff7fb971d --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts @@ -0,0 +1,462 @@ +/** + * @vitest-environment node + */ +import { + dbChainMock, + dbChainMockFns, + resetDbChainMock, + storageServiceMock, + storageServiceMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) + +import type { DbOrTx } from '@/lib/db/types' +import { + copyForkResourceContainers, + copyForkResourceContent, + type ForkContentPlan, + planForkMappedKbDocumentCopies, +} from '@/lib/workspaces/fork/copy/copy-resources' +import type { ForkReferenceResolver } from '@/lib/workspaces/fork/remap/remap-references' + +function basePlan(overrides: Partial = {}): ForkContentPlan { + return { + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + tables: [], + knowledgeBases: [], + skills: [], + documents: [], + ...overrides, + } +} + +const sourceDoc = { + id: 'doc-1', + knowledgeBaseId: 'src-kb', + storageKey: 'kb/source-key', + fileUrl: '/api/files/serve/kb%2Fsource-key', + filename: 'report.pdf', + mimeType: 'application/pdf', +} + +describe('copyForkResourceContent', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('blob-bytes')) + storageServiceMockFns.mockUploadFile.mockResolvedValue({ + key: 'kb/child-key', + path: '/api/files/serve/kb/child-key', + }) + }) + + it('rewrites in-workspace resource URLs nested in copied table cell data', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'r1', + tableId: 'src-tbl', + workspaceId: 'src-ws', + data: { + kb: '/workspace/src-ws/knowledge/kb-1', + nested: { wf: '/workspace/src-ws/w/wf-1' }, + plain: 'no url here', + }, + }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ tables: [{ sourceId: 'src-tbl', childId: 'child-tbl' }] }), + contentRefMaps: { + workspaceId: { from: 'src-ws', to: 'child-ws' }, + knowledgeBases: new Map([['kb-1', 'kb-2']]), + workflows: new Map([['wf-1', 'wf-2']]), + }, + requestId: 'test', + }) + + expect(result.failed).toBe(0) + // The first insert is the table-rows copy (no KBs/docs/skills in this plan). + const inserted = dbChainMockFns.values.mock.calls[0][0] as Array<{ + data: { kb: string; nested: { wf: string }; plain: string } + }> + expect(inserted[0].data.kb).toBe('/workspace/child-ws/knowledge/kb-2') + expect(inserted[0].data.nested.wf).toBe('/workspace/child-ws/w/wf-2') + expect(inserted[0].data.plain).toBe('no url here') + }) + + it('#1 binds a copied KB document blob to the CHILD workspace + initiating user', async () => { + // One live document page, then the embeddings page resolves empty (default). + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + }), + requestId: 'test', + }) + + expect(result.failed).toBe(0) + expect(result.copied).toBe(1) + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1) + const uploadArg = storageServiceMockFns.mockUploadFile.mock.calls[0][0] + expect(uploadArg.context).toBe('knowledge-base') + expect(uploadArg.preserveKey).toBe(true) + // The ownership binding is what verifyKBFileAccess resolves the owning workspace from; + // it must name the CHILD workspace and the initiating user, or the copy is download-denied. + expect(uploadArg.metadata).toEqual({ + userId: 'user-1', + workspaceId: 'child-ws', + originalName: 'report.pdf', + }) + }) + + it('#4 re-reads a copied skill body post-commit and rewrites it via db.update (never from payload)', async () => { + // The body is no longer carried in the plan - the content phase keyset-re-reads the child row. + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'child-skill-1', content: 'see [K](sim:knowledge/src-kb)' }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }), + contentRefMaps: { knowledgeBases: new Map([['src-kb', 'child-kb']]) }, + requestId: 'test', + }) + + expect(result.failed).toBe(0) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + content: 'see [K](sim:knowledge/child-kb)', + }) + }) + + it('#4 leaves a skill untouched when nothing in its re-read body remaps', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { id: 'child-skill-1', content: 'no references here' }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }), + contentRefMaps: { knowledgeBases: new Map([['src-kb', 'child-kb']]) }, + requestId: 'test', + }) + + expect(result.failed).toBe(0) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('#4 skips the skill re-read + rewrite entirely when no content maps are supplied', async () => { + await copyForkResourceContent({ + contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }), + requestId: 'test', + }) + + // No maps -> the body is neither re-read from the DB nor updated. + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('#3 fails the whole KB (all-or-nothing) when one document copy throws', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]) + // The document row insert throws; the blob copy is best-effort (never throws) so the + // failure must come from the persisted copy, marking the entire KB failed for cleanup. + dbChainMockFns.values.mockImplementationOnce(() => { + throw new Error('insert failed') + }) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + }), + requestId: 'test', + }) + + expect(result.copied).toBe(0) + expect(result.failed).toBe(1) + expect(result.failures).toEqual([ + { kind: 'knowledge-base', childId: 'child-kb', documentChildIds: [] }, + ]) + }) + + it('U-docs: fills a document copied into an existing target KB (blob re-key + placeholder update)', async () => { + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + documents: [ + { + sourceDocId: 'doc-1', + childDocId: 'child-doc-1', + childKnowledgeBaseId: 'existing-target-kb', + storageKey: 'kb/source-key', + filename: 'report.pdf', + mimeType: 'application/pdf', + }, + ], + }), + requestId: 'test', + }) + + expect(result.failed).toBe(0) + expect(result.copied).toBe(1) + // The blob is re-keyed and the pre-created placeholder row's blob fields are updated. + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + }) + + it('U-docs: a failed document fill is reported as a knowledge-document failure (for cleanup)', async () => { + // The placeholder blob update throws; the doc fails on its own without touching its KB. + dbChainMockFns.set.mockImplementationOnce(() => { + throw new Error('update failed') + }) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + documents: [ + { + sourceDocId: 'doc-1', + childDocId: 'child-doc-1', + childKnowledgeBaseId: 'existing-target-kb', + storageKey: 'kb/source-key', + filename: 'report.pdf', + mimeType: 'application/pdf', + }, + ], + }), + requestId: 'test', + }) + + expect(result.copied).toBe(0) + expect(result.failed).toBe(1) + expect(result.failures).toEqual([{ kind: 'knowledge-document', childId: 'child-doc-1' }]) + }) +}) + +describe('copyForkResourceContainers custom-tool code env rewrite', () => { + function makeContainerTx(rows: Array>) { + const inserted: Array> = [] + const tx = { + select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }), + insert: () => ({ + values: (values: Array>) => { + inserted.push(...values) + return Promise.resolve() + }, + }), + } + return { tx: tx as unknown as DbOrTx, inserted } + } + + const customToolSelection = { + customTools: ['ct-1'], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: [], + } + + it('rewrites {{ENV}} refs in copied custom-tool code when a sync renames the env var', async () => { + const { tx, inserted } = makeContainerTx([ + { id: 'ct-1', title: 'Tool', code: 'fetch("{{SLACK_API_KEY}}", "{{KEEP}}")' }, + ]) + await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: customToolSelection, + workflowIdMap: new Map(), + resolveEnvName: (key) => (key === 'SLACK_API_KEY' ? 'SLACK_API_KEY_TEST' : key), + }) + expect(inserted).toHaveLength(1) + // The renamed key is rewritten; the same-name key is left verbatim. + expect(inserted[0].code).toBe('fetch("{{SLACK_API_KEY_TEST}}", "{{KEEP}}")') + expect(inserted[0].workspaceId).toBe('child-ws') + }) + + it('preserves custom-tool code verbatim when no env resolver is provided (fork-create)', async () => { + const { tx, inserted } = makeContainerTx([ + { id: 'ct-1', title: 'Tool', code: 'fetch("{{SLACK_API_KEY}}")' }, + ]) + await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: customToolSelection, + workflowIdMap: new Map(), + }) + expect(inserted[0].code).toBe('fetch("{{SLACK_API_KEY}}")') + }) +}) + +describe('copyForkResourceContainers skill copy', () => { + function makeSkillTx(rows: Array>) { + const inserted: Array> = [] + const tx = { + select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }), + insert: () => ({ + values: (values: Array>) => { + inserted.push(...values) + return Promise.resolve() + }, + }), + } + return { tx: tx as unknown as DbOrTx, inserted } + } + + const skillSelection = { + customTools: [], + skills: ['sk-1'], + workflowMcpServers: [], + tables: [], + knowledgeBases: [], + } + + it('copies the skill body IN-DB and carries only the child id in the content plan', async () => { + // The source projection deliberately omits `content` (it is copied server-side), so the row + // fed to the tx mock has none - the body must never be materialized in app memory here. + const { tx, inserted } = makeSkillTx([ + { + id: 'sk-1', + name: 'My Skill', + description: 'desc', + workspaceId: 'src-ws', + userId: 'src-user', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + const result = await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: skillSelection, + workflowIdMap: new Map(), + }) + + expect(inserted).toHaveLength(1) + const childId = inserted[0].id as string + expect(childId).not.toBe('sk-1') + expect(inserted[0].workspaceId).toBe('child-ws') + expect(inserted[0].userId).toBe('user-1') + // The body is deferred to a correlated subquery (in-DB copy), never a materialized string. + expect(typeof inserted[0].content).not.toBe('string') + // The content plan carries ONLY the child id - no skill body text crosses the job payload. + expect(result.contentPlan.skills).toEqual([{ childId }]) + expect(result.names.skills).toEqual(['My Skill']) + }) +}) + +describe('planForkMappedKbDocumentCopies', () => { + const sourceRow = (id: string, knowledgeBaseId: string) => ({ + id, + knowledgeBaseId, + storageKey: `kb/${id}`, + filename: `${id}.pdf`, + mimeType: 'application/pdf', + connectorId: 'connector-1', + deletedAt: null, + archivedAt: null, + }) + + function makeTx(docs: ReturnType[]) { + const inserted: Array> = [] + let selectCalled = false + const tx = { + select: () => { + selectCalled = true + return { from: () => ({ where: () => Promise.resolve(docs) }) } + }, + insert: () => ({ + values: (rows: Array>) => { + inserted.push(...rows) + return Promise.resolve() + }, + }), + } + return { tx: tx as unknown as DbOrTx, inserted, wasSelectCalled: () => selectCalled } + } + + const mappedKbResolver: ForkReferenceResolver = (kind, id) => + kind === 'knowledge-base' && id === 'src-kb' ? 'target-kb' : null + + it('places a referenced doc into its already-mapped existing KB and returns the maps', async () => { + const { tx, inserted } = makeTx([sourceRow('doc-1', 'src-kb')]) + const result = await planForkMappedKbDocumentCopies({ + tx, + resolver: mappedKbResolver, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(), + }) + + const childId = result.docIdMap.get('doc-1') + expect(childId).toBeTruthy() + expect(inserted).toHaveLength(1) + expect(inserted[0]).toMatchObject({ + id: childId, + knowledgeBaseId: 'target-kb', + connectorId: null, + deletedAt: null, + archivedAt: null, + }) + expect(result.mappingEntries).toEqual([ + { resourceType: 'knowledge_document', parentResourceId: 'doc-1', childResourceId: childId }, + ]) + expect(result.documents).toEqual([ + { + sourceDocId: 'doc-1', + childDocId: childId, + childKnowledgeBaseId: 'target-kb', + storageKey: 'kb/doc-1', + filename: 'doc-1.pdf', + mimeType: 'application/pdf', + }, + ]) + }) + + it('skips a referenced doc whose parent KB is not mapped (reference is left to be cleared)', async () => { + const { tx, inserted } = makeTx([sourceRow('doc-1', 'unmapped-kb')]) + const result = await planForkMappedKbDocumentCopies({ + tx, + resolver: mappedKbResolver, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(), + }) + expect(inserted).toHaveLength(0) + expect(result.docIdMap.size).toBe(0) + expect(result.documents).toHaveLength(0) + }) + + it('skips a doc already placed under a copied KB this sync (no duplicate query)', async () => { + const { tx, wasSelectCalled } = makeTx([sourceRow('doc-1', 'src-kb')]) + const result = await planForkMappedKbDocumentCopies({ + tx, + resolver: mappedKbResolver, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(['doc-1']), + }) + expect(result.documents).toHaveLength(0) + expect(wasSelectCalled()).toBe(false) + }) + + it('skips a doc that already resolves (mapped by a prior sync)', async () => { + const { tx, wasSelectCalled } = makeTx([sourceRow('doc-1', 'src-kb')]) + const result = await planForkMappedKbDocumentCopies({ + tx, + resolver: (kind, id) => + kind === 'knowledge-document' && id === 'doc-1' ? 'existing-child-doc' : null, + referencedDocumentIds: ['doc-1'], + alreadyCopiedSourceDocIds: new Set(), + }) + expect(result.documents).toHaveLength(0) + expect(wasSelectCalled()).toBe(false) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/copy-resources.ts b/apps/sim/lib/workspaces/fork/copy/copy-resources.ts index 2fbde548ece..5f78426b2b8 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-resources.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-resources.ts @@ -4,22 +4,35 @@ import { document, embedding, knowledgeBase, - mcpServers, skill, userTableDefinitions, userTableRows, + workflowMcpServer, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, eq, gt, inArray, isNull, type SQL } from 'drizzle-orm' +import { and, asc, eq, gt, inArray, isNull, type SQL, sql } from 'drizzle-orm' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import type { DbOrTx } from '@/lib/db/types' -import { generateMcpServerId } from '@/lib/mcp/utils' import type { TableSchema } from '@/lib/table/types' +import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' +import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { isRecord } from '@/lib/workflows/persistence/remap-internal-ids' import type { ForkMappingUpsert, ForkResourceType, } from '@/lib/workspaces/fork/mapping/mapping-store' +import { + type ForkContentRefMaps, + rewriteForkContentRefs, + rewriteForkResourceUrls, +} from '@/lib/workspaces/fork/remap/remap-content-refs' +import { + type ForkReferenceResolver, + rewriteEnvRefsInText, +} from '@/lib/workspaces/fork/remap/remap-references' import { remapForkTableWorkflowGroups } from '@/lib/workspaces/fork/remap/remap-table-groups' const logger = createLogger('WorkspaceForkCopyResources') @@ -27,6 +40,20 @@ const logger = createLogger('WorkspaceForkCopyResources') /** Page size for the post-transaction bulk content copy (keyset-paginated). */ const CONTENT_PAGE = 500 +/** + * Max documents copied concurrently within one KB page. Bounds fan-out (blob copy + per-doc + * embedding paging) so a large page doesn't issue every request at once; the keyset loop still + * processes one page at a time, so peak concurrency stays at this cap regardless of KB size. + */ +const KB_DOCUMENT_COPY_CONCURRENCY = 5 + +/** + * Max copied skill bodies rewritten concurrently within one keyset page. Bounds the per-skill + * re-read + UPDATE fan-out so a page of copied skills doesn't issue every write at once; the keyset + * loop still processes one page at a time, so peak concurrency stays at this cap. + */ +const SKILL_REWRITE_CONCURRENCY = 5 + export interface CopyResourcesParams { tx: DbOrTx sourceWorkspaceId: string @@ -37,12 +64,25 @@ export interface CopyResourcesParams { selection: { customTools: string[] skills: string[] - mcpServers: string[] + workflowMcpServers: string[] tables: string[] knowledgeBases: string[] } /** source workflow id -> child workflow id, for table workflow-group remap. */ workflowIdMap: Map + /** + * Source KB-document ids referenced by the copied workflows (document-selector values + + * nested `documentId` tool params). Documents in this set whose parent KB is being copied + * get a placeholder row + a persisted `knowledge_document` id map inside the transaction, so + * the reference remaps to the copied document instead of being cleared. Defaults to none. + */ + referencedDocumentIds?: string[] + /** + * Resolve a source env-var name to its target name, so a copied custom tool's `code` (which + * embeds `{{ENV}}` refs) is rewritten when a sync renames an env var. Provided by promote (the + * plan resolver); omitted by fork-create, which preserves env names verbatim (no rewrite). + */ + resolveEnvName?: (key: string) => string | null | undefined } export interface ForkContentPlanEntry { @@ -50,21 +90,83 @@ export interface ForkContentPlanEntry { childId: string } +/** + * A KB to copy post-commit, plus the source-document -> child-document id map for the + * documents that were pre-created as placeholders in the transaction (referenced by copied + * workflows). The content phase fills those exact child ids (and copies the rest fresh) so a + * remapped `document-selector` reference can never dangle. + */ +export interface ForkContentKbEntry extends ForkContentPlanEntry { + documentIdMap: Record +} + +/** + * A copied skill whose body's in-content references (`sim:` links + embedded file URLs) are + * rewritten post-commit. Only the child id is carried: the child row is inserted in the fork tx + * with the SOURCE body copied IN-DB (never materialized in app memory or embedded in the job + * payload), and the content phase RE-READS the body keyset-paginated to rewrite it best-effort so + * it points at the copied child resources (an unmapped target degrades to a graceful broken link, + * never an FK/subblock reference). + */ +export interface ForkContentSkillEntry { + childId: string +} + +/** + * A single source document copied into an EXISTING (already-mapped, not copied this sync) target + * KB - the sync-only "document into mapped KB" path (U-docs). The placeholder row is inserted in + * the promote tx at `childDocId` under `childKnowledgeBaseId`; the content phase fills its + * embeddings (and re-keys its blob) best-effort. Distinct from {@link ForkContentKbEntry}, which + * copies a whole KB's documents - here only the referenced documents are placed, since the KB + * itself already exists in the target with its own documents. + */ +export interface ForkContentDocumentEntry { + sourceDocId: string + childDocId: string + childKnowledgeBaseId: string + /** Source blob fields captured at placeholder time, for the post-commit blob re-key. */ + storageKey: string | null + filename: string + mimeType: string +} + /** Bulk content to copy AFTER the fork transaction commits (best-effort, batched). */ export interface ForkContentPlan { sourceWorkspaceId: string childWorkspaceId: string + /** Initiating user, recorded as the owner of copied KB-document blob bindings in the child. */ + userId: string tables: ForkContentPlanEntry[] - knowledgeBases: ForkContentPlanEntry[] + knowledgeBases: ForkContentKbEntry[] + skills: ForkContentSkillEntry[] + /** Documents copied into an already-existing target KB (sync-only; empty at fork create). */ + documents: ForkContentDocumentEntry[] } +/** + * A resource whose post-commit content fill failed, so every reference to it must be cleared. + * A table carries its child definition id; a KB carries its child id (dropping it cascade-removes + * its documents + embeddings) and the child ids of the document placeholders pre-created in the + * fork tx, so `document-selector` references clear too. A standalone `knowledge-document` (a doc + * copied into an existing target KB) carries just its child id - dropping that one row (its + * embeddings cascade) without touching the existing KB. A `file` carries the COPIED child storage + * key whose blob duplication failed: its `file-upload` references are cleared so no block points at + * a missing object. Unlike the others, a failed file drops no row - the metadata row is left so the + * user can re-upload the blob. + */ +export type ForkFailedResource = + | { kind: 'table'; childId: string } + | { kind: 'knowledge-base'; childId: string; documentChildIds: string[] } + | { kind: 'knowledge-document'; childId: string } + | { kind: 'file'; childKey: string } + /** Display names of the copied resources, by kind, for the fork activity report. */ export interface ForkCopiedResourceNames { tables: string[] knowledgeBases: string[] customTools: string[] skills: string[] - mcpServers: string[] + workflowMcpServers: string[] } export interface CopyResourcesResult { @@ -87,6 +189,13 @@ function setId(idMap: Map>, type: ForkReso return map } +/** + * Child `skill` insert whose `content` is a correlated subquery (copied server-side from the source + * row) rather than a materialized string, so the fork tx never pulls skill bodies into app memory - + * see the skeleton skill copy in {@link copyForkResourceContainers}. + */ +type SkillSkeletonInsert = Omit & { content: SQL } + /** * Copy the selected resources' **container rows** into the child workspace inside * the fork transaction: custom tools, skills, and MCP server configs (each a @@ -102,20 +211,25 @@ export async function copyForkResourceContainers( params: CopyResourcesParams ): Promise { const { tx, sourceWorkspaceId, childWorkspaceId, userId, now, selection, workflowIdMap } = params + const referencedDocumentIds = params.referencedDocumentIds ?? [] + const resolveEnvName = params.resolveEnvName const idMap = new Map>() const mappingEntries: ForkMappingUpsert[] = [] const contentPlan: ForkContentPlan = { sourceWorkspaceId, childWorkspaceId, + userId, tables: [], knowledgeBases: [], + skills: [], + documents: [], } const names: ForkCopiedResourceNames = { tables: [], knowledgeBases: [], customTools: [], skills: [], - mcpServers: [], + workflowMcpServers: [], } const record = (type: ForkResourceType, sourceId: string, childId: string) => { @@ -145,6 +259,13 @@ export async function copyForkResourceContainers( id: childId, workspaceId: childWorkspaceId, userId, + // The code column is copied verbatim, so a `{{ENV}}` ref in it would stay stale when a + // sync renames the env var. Rewrite those refs through the env-name resolver (subblock + // values are already remapped by the reference transform). Fork-create passes no resolver, + // preserving env names by default. + ...(resolveEnvName && typeof row.code === 'string' + ? { code: rewriteEnvRefsInText(row.code, resolveEnvName) } + : {}), createdAt: now, updatedAt: now, }) @@ -155,11 +276,22 @@ export async function copyForkResourceContainers( } if (selection.skills.length > 0) { + // Select every skill column EXCEPT `content`: the body (capped at 50 KB each, up to 2000 skills) + // is copied server-side via the correlated subquery below, so it is never materialized in app + // memory while the fork tx holds its locks - nor carried in the background-job payload. const rows = await tx - .select() + .select({ + id: skill.id, + workspaceId: skill.workspaceId, + userId: skill.userId, + name: skill.name, + description: skill.description, + createdAt: skill.createdAt, + updatedAt: skill.updatedAt, + }) .from(skill) .where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId))) - const inserts: (typeof skill.$inferInsert)[] = [] + const inserts: SkillSkeletonInsert[] = [] for (const row of rows) { const childId = generateId() inserts.push({ @@ -167,59 +299,48 @@ export async function copyForkResourceContainers( id: childId, workspaceId: childWorkspaceId, userId, + // Copy the body straight from the source row in-DB (never through app memory). Re-read and + // rewritten post-commit (see copyForkResourceContent), out of the locked fork tx. + content: sql`(SELECT ${skill.content} FROM ${skill} WHERE ${skill.id} = ${row.id})`, createdAt: now, updatedAt: now, }) record('skill', row.id, childId) + contentPlan.skills.push({ childId }) names.skills.push(row.name) } if (inserts.length > 0) await tx.insert(skill).values(inserts) } - if (selection.mcpServers.length > 0) { + if (selection.workflowMcpServers.length > 0) { const rows = await tx .select() - .from(mcpServers) + .from(workflowMcpServer) .where( and( - inArray(mcpServers.id, selection.mcpServers), - eq(mcpServers.workspaceId, sourceWorkspaceId), - isNull(mcpServers.deletedAt) + inArray(workflowMcpServer.id, selection.workflowMcpServers), + eq(workflowMcpServer.workspaceId, sourceWorkspaceId), + isNull(workflowMcpServer.deletedAt) ) ) - // `generateMcpServerId` is deterministic on (workspace, url), so two selected - // servers with the same normalized URL derive the same child id. Insert once - // and map both source ids to the surviving child rather than aborting the fork. - const insertsByChildId = new Map() + // Copy workflow-publishing MCP servers as config-only shells: the server definition + // (name/description/visibility) with NO `workflow_mcp_tool` rows attached, so the child + // re-registers its own workflows. These are fork-copy-only (not referenced by subblocks), + // so they are not recorded in the fork resource map. + const inserts: (typeof workflowMcpServer.$inferInsert)[] = [] for (const row of rows) { - const childId = row.url ? generateMcpServerId(childWorkspaceId, row.url) : generateId() - record('mcp_server', row.id, childId) - if (insertsByChildId.has(childId)) continue - names.mcpServers.push(row.name) - insertsByChildId.set(childId, { + inserts.push({ ...row, - id: childId, + id: generateId(), workspaceId: childWorkspaceId, createdBy: userId, - // Secrets are never copied across workspaces: drop the registered OAuth - // client + any auth headers so the child re-authenticates from scratch. - oauthClientId: null, - oauthClientSecret: null, - headers: {}, - connectionStatus: 'disconnected', - lastConnected: null, - lastError: null, deletedAt: null, createdAt: now, updatedAt: now, }) + names.workflowMcpServers.push(row.name) } - if (insertsByChildId.size > 0) { - await tx - .insert(mcpServers) - .values([...insertsByChildId.values()]) - .onConflictDoNothing() - } + if (inserts.length > 0) await tx.insert(workflowMcpServer).values(inserts) } if (selection.tables.length > 0) { @@ -273,6 +394,7 @@ export async function copyForkResourceContainers( ) ) const inserts: (typeof knowledgeBase.$inferInsert)[] = [] + const kbEntryBySourceId = new Map() for (const base of bases) { const childKbId = generateId() inserts.push({ @@ -285,32 +407,220 @@ export async function copyForkResourceContainers( updatedAt: now, }) record('knowledge_base', base.id, childKbId) - contentPlan.knowledgeBases.push({ sourceId: base.id, childId: childKbId }) + const entry: ForkContentKbEntry = { sourceId: base.id, childId: childKbId, documentIdMap: {} } + contentPlan.knowledgeBases.push(entry) + kbEntryBySourceId.set(base.id, entry) names.knowledgeBases.push(base.name) } if (inserts.length > 0) await tx.insert(knowledgeBase).values(inserts) + + // Pre-create placeholder document rows for the documents the copied workflows + // reference, at child ids generated inside the transaction, so each + // `document-selector` reference can be remapped to a valid copied document rather + // than cleared. Only documents whose parent KB is in this copy (FK-safe: the KB + // rows were just inserted above) are placed; the heavy content (embeddings + blob) + // is filled at these exact ids by the post-commit content phase. + await createForkDocumentPlaceholders({ + tx, + kbIdMap: idMap.get('knowledge_base') ?? new Map(), + kbEntryBySourceId, + referencedDocumentIds, + record, + }) } return { idMap, mappingEntries, contentPlan, names } } +/** + * Insert placeholder {@link document} rows in the child KBs for the referenced documents + * whose parent KB is being copied, recording the `knowledge_document` source->child id map. + * Each placeholder is the source document's metadata row at a fresh child id; its embeddings + * and (re-keyed) blob are filled by {@link copyForkResourceContent} after commit. Documents + * whose parent KB is not copied are skipped, leaving their references to be cleared. + */ +async function createForkDocumentPlaceholders(params: { + tx: DbOrTx + kbIdMap: Map + kbEntryBySourceId: Map + referencedDocumentIds: string[] + record: (type: ForkResourceType, sourceId: string, childId: string) => void +}): Promise { + const { tx, kbIdMap, kbEntryBySourceId, referencedDocumentIds, record } = params + if (referencedDocumentIds.length === 0 || kbIdMap.size === 0) return + + const docs = await tx + .select() + .from(document) + .where( + and( + inArray(document.id, referencedDocumentIds), + inArray(document.knowledgeBaseId, Array.from(kbIdMap.keys())), + isNull(document.deletedAt), + isNull(document.archivedAt) + ) + ) + + const inserts: (typeof document.$inferInsert)[] = [] + for (const doc of docs) { + const childKbId = kbIdMap.get(doc.knowledgeBaseId) + const kbEntry = kbEntryBySourceId.get(doc.knowledgeBaseId) + if (!childKbId || !kbEntry) continue + const childDocId = generateId() + inserts.push({ + ...doc, + id: childDocId, + knowledgeBaseId: childKbId, + connectorId: null, + deletedAt: null, + archivedAt: null, + }) + record('knowledge_document', doc.id, childDocId) + kbEntry.documentIdMap[doc.id] = childDocId + } + if (inserts.length > 0) await tx.insert(document).values(inserts) +} + +/** + * Plan the copy of documents referenced by the synced workflows whose parent knowledge base is + * ALREADY mapped to an existing target KB (not copied this sync) but the document itself is not + * mapped. For each, insert a placeholder document row into that existing target KB and return: + * - `documents`: content entries for the post-commit embeddings/blob fill, + * - `docIdMap`: source->child document id map (to augment the resolver so the `document-selector` + * reference remaps to the copy instead of being cleared), + * - `mappingEntries`: `knowledge_document` rows to persist (so a re-sync resolves the copy). + * + * FK-safe: the target KB is one the resolver returns (existence-checked via `validTargetIdsByKind`), + * and the placeholder insert is a bounded in-tx write with no object-storage I/O. Documents whose + * parent KB is being copied THIS sync are handled by {@link createForkDocumentPlaceholders} under + * that copied KB and are excluded here via `alreadyCopiedSourceDocIds`. A referenced document whose + * parent KB is not mapped at all is left untouched, so its reference is cleared as before. + */ +export async function planForkMappedKbDocumentCopies(params: { + tx: DbOrTx + resolver: ForkReferenceResolver + referencedDocumentIds: string[] + alreadyCopiedSourceDocIds: Set +}): Promise<{ + documents: ForkContentDocumentEntry[] + docIdMap: Map + mappingEntries: ForkMappingUpsert[] +}> { + const { tx, resolver, referencedDocumentIds, alreadyCopiedSourceDocIds } = params + const documents: ForkContentDocumentEntry[] = [] + const docIdMap = new Map() + const mappingEntries: ForkMappingUpsert[] = [] + + // A doc whose parent KB is being copied this sync is placed under that copied KB (skip via + // alreadyCopiedSourceDocIds); a doc that already resolves was mapped by a prior sync (skip via + // the resolver). Everything else is a candidate to copy into its (existing) mapped target KB. + const candidateIds = referencedDocumentIds.filter( + (id) => !alreadyCopiedSourceDocIds.has(id) && resolver('knowledge-document', id) == null + ) + if (candidateIds.length === 0) return { documents, docIdMap, mappingEntries } + + const docs = await tx + .select() + .from(document) + .where( + and( + inArray(document.id, candidateIds), + isNull(document.deletedAt), + isNull(document.archivedAt) + ) + ) + + const inserts: (typeof document.$inferInsert)[] = [] + for (const doc of docs) { + // The parent KB must already exist in the target. The resolver returns a target KB id only + // for a mapped, still-existing KB (validTargetIdsByKind), so this is FK-safe; a doc whose KB + // isn't mapped resolves null here and is left for its reference to be cleared. + const targetKbId = resolver('knowledge-base', doc.knowledgeBaseId) + if (targetKbId == null) continue + const childDocId = generateId() + inserts.push({ + ...doc, + id: childDocId, + knowledgeBaseId: targetKbId, + connectorId: null, + deletedAt: null, + archivedAt: null, + }) + docIdMap.set(doc.id, childDocId) + mappingEntries.push({ + resourceType: 'knowledge_document', + parentResourceId: doc.id, + childResourceId: childDocId, + }) + documents.push({ + sourceDocId: doc.id, + childDocId, + childKnowledgeBaseId: targetKbId, + storageKey: doc.storageKey, + filename: doc.filename, + mimeType: doc.mimeType, + }) + } + if (inserts.length > 0) await tx.insert(document).values(inserts) + return { documents, docIdMap, mappingEntries } +} + +/** + * Recursively rewrite the in-workspace resource URLs stored in a copied table row's `data` jsonb + * (column -> value), so resource-chip cells keep resolving after a cross-workspace copy. Fast-rejects + * a string without `/workspace/` before any regex, and returns the same reference when nothing + * changed (so only rows with a rewritten cell allocate a new `data` object). + */ +function remapTableRowResourceUrls(value: unknown, maps: ForkContentRefMaps): unknown { + if (typeof value === 'string') { + if (!value.includes('/workspace/')) return value + return rewriteForkResourceUrls(value, maps) + } + if (Array.isArray(value)) { + let changed = false + const next = value.map((item) => { + const remapped = remapTableRowResourceUrls(item, maps) + if (remapped !== item) changed = true + return remapped + }) + return changed ? next : value + } + if (isRecord(value)) { + let changed = false + const next: Record = {} + for (const [key, item] of Object.entries(value)) { + const remapped = remapTableRowResourceUrls(item, maps) + if (remapped !== item) changed = true + next[key] = remapped + } + return changed ? next : value + } + return value +} + /** * Copy the heavy resource content described by a {@link ForkContentPlan} AFTER the * fork transaction has committed: table rows, and KB documents + embeddings. Reads * and writes are keyset-paginated so peak memory is bounded to one page, and each * resource is copied in its own short statements (never one long transaction). * Best-effort: a failure on one resource is logged and the others continue - the - * fork itself (workflows + container rows) already succeeded. + * fork itself (workflows + container rows) already succeeded. Copied skill bodies are + * RE-READ (keyset-paginated) and rewritten here too (`contentRefMaps`), out of the locked + * fork tx; that rewrite is an in-content link fixup, so a failure degrades to a broken link + * and never fails a resource. */ export async function copyForkResourceContent(params: { contentPlan: ForkContentPlan + /** In-content reference maps for rewriting copied skill bodies post-commit (best-effort). */ + contentRefMaps?: ForkContentRefMaps requestId?: string -}): Promise<{ copied: number; failed: number }> { - const { contentPlan, requestId = 'unknown' } = params - const { childWorkspaceId } = contentPlan +}): Promise<{ copied: number; failed: number; failures: ForkFailedResource[] }> { + const { contentPlan, contentRefMaps, requestId = 'unknown' } = params + const { childWorkspaceId, userId } = contentPlan let copiedResources = 0 let failedResources = 0 + const failures: ForkFailedResource[] = [] for (const table of contentPlan.tables) { try { @@ -334,6 +644,8 @@ export async function copyForkResourceContent(params: { id: generateId(), tableId: table.childId, workspaceId: childWorkspaceId, + // Repoint resource-chip URLs in cell data at the child copies (no-op when no maps). + data: contentRefMaps ? remapTableRowResourceUrls(row.data, contentRefMaps) : row.data, })) ) copied += rows.length @@ -347,6 +659,7 @@ export async function copyForkResourceContent(params: { copiedResources += 1 } catch (error) { failedResources += 1 + failures.push({ kind: 'table', childId: table.childId }) logger.warn(`[${requestId}] Failed to copy table rows during fork`, { sourceTableId: table.sourceId, error: getErrorMessage(error), @@ -375,24 +688,60 @@ export async function copyForkResourceContent(params: { .orderBy(asc(document.id)) .limit(CONTENT_PAGE) if (docs.length === 0) break - for (const doc of docs) { - const childDocId = generateId() - await db.insert(document).values({ - ...doc, - id: childDocId, - knowledgeBaseId: kb.childId, - connectorId: null, - deletedAt: null, - archivedAt: null, - }) - await copyDocumentEmbeddings(doc.id, childDocId, kb.childId) - } + // Copy the page's documents with bounded concurrency. The mapper never rejects + // (it captures its error), so all in-flight work settles before this resolves - no + // orphaned writes survive a failure - and a captured error is rethrown after to keep + // the KB ALL-OR-NOTHING (any failed doc fails the whole KB -> cleanup below). + const docErrors = await mapWithConcurrency( + docs, + KB_DOCUMENT_COPY_CONCURRENCY, + async (doc): Promise => { + try { + // Referenced documents were pre-created as placeholders in the fork tx at this + // exact child id, so a remapped `document-selector` reference can't dangle; the + // rest get a fresh id here. Copy the blob to a child-scoped KB key so the copy + // never shares the source's object (best-effort - keeps the source key on failure). + const placeholderId = kb.documentIdMap[doc.id] + const childDocId = placeholderId ?? generateId() + const blob = await copyKbDocumentBlob(doc, childWorkspaceId, userId, requestId) + if (placeholderId) { + if (blob) { + await db + .update(document) + .set({ storageKey: blob.storageKey, fileUrl: blob.fileUrl }) + .where(eq(document.id, childDocId)) + } + } else { + await db.insert(document).values({ + ...doc, + id: childDocId, + knowledgeBaseId: kb.childId, + connectorId: null, + deletedAt: null, + archivedAt: null, + ...(blob ? { storageKey: blob.storageKey, fileUrl: blob.fileUrl } : {}), + }) + } + await copyDocumentEmbeddings(doc.id, childDocId, kb.childId) + return null + } catch (error) { + return error + } + } + ) + const docError = docErrors.find((error) => error != null) + if (docError) throw docError afterDocId = docs[docs.length - 1].id if (docs.length < CONTENT_PAGE) break } copiedResources += 1 } catch (error) { failedResources += 1 + failures.push({ + kind: 'knowledge-base', + childId: kb.childId, + documentChildIds: Object.values(kb.documentIdMap), + }) logger.warn(`[${requestId}] Failed to copy knowledge base content during fork`, { sourceKnowledgeBaseId: kb.sourceId, error: getErrorMessage(error), @@ -400,7 +749,90 @@ export async function copyForkResourceContent(params: { } } - return { copied: copiedResources, failed: failedResources } + // Fill the documents copied into an already-existing target KB (sync-only U-docs path). The + // placeholder rows were inserted in the promote tx; here we re-key each blob and copy its + // embeddings into the existing KB. A per-document failure drops just that placeholder (its + // embeddings cascade) and clears its `document-selector` references - the existing KB and its + // own documents are never touched. + for (const docEntry of contentPlan.documents) { + try { + const blob = await copyKbDocumentBlob( + { + storageKey: docEntry.storageKey, + filename: docEntry.filename, + mimeType: docEntry.mimeType, + }, + childWorkspaceId, + userId, + requestId + ) + if (blob) { + await db + .update(document) + .set({ storageKey: blob.storageKey, fileUrl: blob.fileUrl }) + .where(eq(document.id, docEntry.childDocId)) + } + await copyDocumentEmbeddings( + docEntry.sourceDocId, + docEntry.childDocId, + docEntry.childKnowledgeBaseId + ) + copiedResources += 1 + } catch (error) { + failedResources += 1 + failures.push({ kind: 'knowledge-document', childId: docEntry.childDocId }) + logger.warn(`[${requestId}] Failed to copy document into mapped KB during sync`, { + sourceDocumentId: docEntry.sourceDocId, + error: getErrorMessage(error), + }) + } + } + + // Rewrite copied skill bodies out of the locked fork tx (the rows were inserted with the source + // body via an in-DB copy). RE-READ each child body keyset-paginated - it is never carried in the + // job payload - remap its `sim:` links + embedded file URLs to the child resources, and write it + // back. An in-content link fixup: a per-skill failure degrades to a broken link and is never + // counted as a failed resource (unmapped targets are left as graceful broken links). + if (contentRefMaps && contentPlan.skills.length > 0) { + const childSkillIds = contentPlan.skills.map((entry) => entry.childId) + let afterId: string | null = null + for (;;) { + const where: SQL | undefined = + afterId === null + ? inArray(skill.id, childSkillIds) + : and(inArray(skill.id, childSkillIds), gt(skill.id, afterId)) + const rows = await db + .select({ id: skill.id, content: skill.content }) + .from(skill) + .where(where) + .orderBy(asc(skill.id)) + .limit(CONTENT_PAGE) + if (rows.length === 0) break + // Bounded fan-out: the mapper never rejects (it captures its own error), so mapWithConcurrency + // settles the whole page. A rewrite is a best-effort link fixup, so a per-skill failure is + // logged and the body keeps its source links rather than failing a resource. + await mapWithConcurrency(rows, SKILL_REWRITE_CONCURRENCY, async (row): Promise => { + try { + const rewritten = rewriteForkContentRefs(row.content, contentRefMaps) + if (rewritten !== row.content) { + await db.update(skill).set({ content: rewritten }).where(eq(skill.id, row.id)) + } + } catch (error) { + logger.warn( + `[${requestId}] Failed to rewrite copied skill content; keeping source links`, + { + childSkillId: row.id, + error: getErrorMessage(error), + } + ) + } + }) + afterId = rows[rows.length - 1].id + if (rows.length < CONTENT_PAGE) break + } + } + + return { copied: copiedResources, failed: failedResources, failures } } async function copyDocumentEmbeddings( @@ -433,3 +865,49 @@ async function copyDocumentEmbeddings( if (rows.length < CONTENT_PAGE) break } } + +/** + * Duplicate a KB document's stored blob to a fresh child-scoped KB storage key so the copied + * document never points at the source's object. The child key is written with a `file_metadata` + * ownership binding owned by the CHILD workspace (mirroring the canonical KB upload), so + * `verifyKBFileAccess` grants a child-workspace member - without it the copied object is + * download-denied (no binding = deny). Returns the new `storageKey` + serve `fileUrl`, or null + * when there is no internal blob to copy (external/`data:` docs have a null `storageKey`) or the + * copy fails - best-effort, so a single blob failure never aborts the KB. + */ +async function copyKbDocumentBlob( + doc: { storageKey: string | null; filename: string; mimeType: string }, + childWorkspaceId: string, + userId: string, + requestId: string +): Promise<{ storageKey: string; fileUrl: string } | null> { + if (!doc.storageKey) return null + try { + const buffer = await downloadFile({ + key: doc.storageKey, + context: 'knowledge-base', + maxBytes: MAX_FILE_SIZE, + }) + const targetKey = generateKnowledgeBaseFileKey(doc.filename) + await uploadFile({ + file: buffer, + fileName: doc.filename, + contentType: doc.mimeType, + context: 'knowledge-base', + customKey: targetKey, + preserveKey: true, + metadata: { + userId, + workspaceId: childWorkspaceId, + originalName: doc.filename, + }, + }) + return { storageKey: targetKey, fileUrl: `/api/files/serve/${encodeURIComponent(targetKey)}` } + } catch (error) { + logger.warn(`[${requestId}] Failed to copy KB document blob during fork; keeping source key`, { + sourceStorageKey: doc.storageKey, + error: getErrorMessage(error), + }) + return null + } +} diff --git a/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts b/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts index 19167a0b50e..ecb386d0d09 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts @@ -33,7 +33,11 @@ import type { const logger = createLogger('WorkspaceForkCopyWorkflows') -type SubBlockTransform = (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord +type SubBlockTransform = ( + subBlocks: SubBlockRecord, + blockType: string, + canonicalModes?: Record +) => SubBlockRecord interface ResolveForkFolderMappingParams { tx: DbOrTx @@ -390,7 +394,12 @@ export async function copyWorkflowStateIntoTarget( const sanitizedSource = sanitizeSubBlocksForDuplicate(sourceSubBlocks) let subBlocks: SubBlockRecord = sanitizedSource if (transformSubBlocks) { - subBlocks = transformSubBlocks(subBlocks, block.type) + subBlocks = transformSubBlocks( + subBlocks, + block.type, + (block.data as { canonicalModes?: Record } | undefined) + ?.canonicalModes + ) } if (varIdMapping.size > 0) { subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping) @@ -399,6 +408,9 @@ export async function copyWorkflowStateIntoTarget( // rather than leave them pointing at the source workspace. subBlocks = remapWorkflowReferencesInSubBlocks(subBlocks, workflowIdMap, { clearUnmapped: true, + canonicalModes: ( + block.data as { canonicalModes?: Record } | undefined + )?.canonicalModes, }) subBlocks = remapConditionIdsInSubBlocks(subBlocks, oldBlockId, newBlockId) as SubBlockRecord diff --git a/apps/sim/lib/workspaces/fork/copy/workflow-id-map.test.ts b/apps/sim/lib/workspaces/fork/copy/workflow-id-map.test.ts new file mode 100644 index 00000000000..b0fb5f7a963 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/workflow-id-map.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildForkWorkflowIdMap } from '@/lib/workspaces/fork/copy/workflow-id-map' + +describe('buildForkWorkflowIdMap', () => { + const sequentialIds = () => { + let n = 0 + return () => `child-${++n}` + } + + it('excludes a deployed source whose state failed to load from the map (and so the identity seed)', () => { + const deployed = [{ id: 'wf-a' }, { id: 'wf-b' }, { id: 'wf-c' }] + // wf-b's deployed state failed to load - the copy loop skips it. + const map = buildForkWorkflowIdMap(deployed, new Set(['wf-a', 'wf-c']), sequentialIds()) + // wf-b is absent, so a copied workflow's ref to it clears (not dangle) and the identity seed + // (derived from this map's entries) never gets an orphan row pointing at a never-created child. + expect([...map.keys()]).toEqual(['wf-a', 'wf-c']) + expect(map.has('wf-b')).toBe(false) + expect(map.get('wf-a')).toBe('child-1') + expect(map.get('wf-c')).toBe('child-2') + }) + + it('maps a both-deployed pair when both states loaded (refs remap, not clear)', () => { + const deployed = [{ id: 'parent-wf' }, { id: 'child-wf' }] + const map = buildForkWorkflowIdMap( + deployed, + new Set(['parent-wf', 'child-wf']), + sequentialIds() + ) + expect([...map.keys()]).toEqual(['parent-wf', 'child-wf']) + expect(map.get('parent-wf')).toBe('child-1') + expect(map.get('child-wf')).toBe('child-2') + }) + + it('returns an empty map when no states loaded', () => { + const map = buildForkWorkflowIdMap([{ id: 'wf-a' }], new Set(), () => 'x') + expect(map.size).toBe(0) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/workflow-id-map.ts b/apps/sim/lib/workspaces/fork/copy/workflow-id-map.ts new file mode 100644 index 00000000000..e85a4f1d753 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/workflow-id-map.ts @@ -0,0 +1,21 @@ +import { generateId } from '@sim/utils/id' + +/** + * Build the source->child workflow id map for a fork create, scoped to the deployed workflows whose + * state actually LOADED (the set the copy loop writes). A deployed source whose state failed to load + * is EXCLUDED, so a copied workflow's reference to it clears (clearUnmapped) instead of pointing at a + * never-created child, and no orphan `workspace_fork_resource_map` identity row is seeded for it (the + * identity seed is derived from this map). Mirrors promote's writtenItems-only scoping + * (`buildPromoteWorkflowIdMap`). `generateChildId` is injectable for deterministic tests. + */ +export function buildForkWorkflowIdMap( + deployedWorkflows: ReadonlyArray<{ id: string }>, + loadedStateWorkflowIds: ReadonlySet, + generateChildId: () => string = generateId +): Map { + const map = new Map() + for (const wf of deployedWorkflows) { + if (loadedStateWorkflowIds.has(wf.id)) map.set(wf.id, generateChildId()) + } + return map +} diff --git a/apps/sim/lib/workspaces/fork/create-fork.ts b/apps/sim/lib/workspaces/fork/create-fork.ts index 6cdf43dace7..0e4fce8e5de 100644 --- a/apps/sim/lib/workspaces/fork/create-fork.ts +++ b/apps/sim/lib/workspaces/fork/create-fork.ts @@ -6,8 +6,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import type { Workspace } from '@/lib/api/contracts/workspaces' -import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' -import { runDetached } from '@/lib/core/utils/background' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { @@ -16,7 +14,9 @@ import { } from '@/lib/workspaces/fork/background-work/store' import { type ForkContentCopyPayload, - runForkContentCopy, + hasForkContentToCopy, + scheduleForkContentCopy, + serializeContentRefMaps, } from '@/lib/workspaces/fork/copy/content-copy-runner' import { planForkFileCopies } from '@/lib/workspaces/fork/copy/copy-files' import { @@ -29,6 +29,7 @@ import { resolveForkFolderMapping, } from '@/lib/workspaces/fork/copy/copy-workflows' import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge' +import { buildForkWorkflowIdMap } from '@/lib/workspaces/fork/copy/workflow-id-map' import { setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage' import { type ForkBlockPair, @@ -41,6 +42,7 @@ import { seedEdgeMappings, } from '@/lib/workspaces/fork/mapping/mapping-store' import { createForkBootstrapTransform } from '@/lib/workspaces/fork/remap/fork-bootstrap' +import { collectReferencedDocumentIds } from '@/lib/workspaces/fork/remap/reference-scan' import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' import type { WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import type { WorkspaceCreationPolicy } from '@/lib/workspaces/policy' @@ -55,7 +57,8 @@ export interface ForkResourceSelection { knowledgeBases: string[] customTools: string[] skills: string[] - mcpServers: string[] + /** Workflow-publishing MCP servers (copied as config-only shells); external MCP is never copied. */ + workflowMcpServers: string[] } const EMPTY_SELECTION: ForkResourceSelection = { @@ -64,7 +67,7 @@ const EMPTY_SELECTION: ForkResourceSelection = { knowledgeBases: [], customTools: [], skills: [], - mcpServers: [], + workflowMcpServers: [], } export interface CreateForkParams { @@ -84,12 +87,14 @@ export interface CreateForkResult { workflowsCopied: number } +// External MCP servers are intentionally absent: a fork never copies them, so their +// references resolve to null here and are cleared on remap (re-add + re-auth in the child). const FORK_KIND_TO_RESOURCE_TYPE: Partial> = { 'custom-tool': 'custom_tool', skill: 'skill', - 'mcp-server': 'mcp_server', table: 'table', 'knowledge-base': 'knowledge_base', + 'knowledge-document': 'knowledge_document', } /** @@ -111,15 +116,25 @@ export async function createFork(params: CreateForkParams): Promise { + const sourceState = sourceStates.get(wf.id) + return sourceState ? [sourceState] : [] + }) + ) + const forkedWorkflowNames: string[] = [] let forkedResourceNames: ForkCopiedResourceNames = { tables: [], knowledgeBases: [], customTools: [], skills: [], - mcpServers: [], + workflowMcpServers: [], } - const { result, blobTasks, contentPlan } = await db.transaction(async (tx) => { + const { result, blobTasks, contentPlan, contentRefMaps } = await db.transaction(async (tx) => { await setForkLockTimeout(tx) const now = new Date() const childWorkspaceId = generateId() @@ -167,8 +182,14 @@ export async function createFork(params: CreateForkParams): Promise() - for (const wf of deployedWorkflows) workflowIdMap.set(wf.id, generateId()) + // The id map (and the identity seed below) covers only the workflows ACTUALLY copied - + // those whose deployed state loaded. A deployed source whose state failed to load is + // skipped by the copy loop, so it must be excluded here too: keeping it would (1) remap a + // copied workflow's reference to a child id that is never created (a dangling ref) instead + // of clearing it, and (2) seed a `workspace_fork_resource_map` workflow row pointing at + // that never-created target, which a later push would treat as an orphan and archive the + // parent's real workflow. Mirrors promote's writtenItems-only identity seed. + const workflowIdMap = buildForkWorkflowIdMap(deployedWorkflows, new Set(sourceStates.keys())) const fileResult = await planForkFileCopies({ tx, @@ -179,6 +200,16 @@ export async function createFork(params: CreateForkParams): Promise child folder id map: remaps folder references in the copied workflows below and + // feeds the post-commit content-ref rewrite (`sim:folder/` mentions in skill/file bodies). + const folderIdMap = await resolveForkFolderMapping({ + tx, + sourceWorkspaceId: source.id, + targetWorkspaceId: childWorkspaceId, + userId, + now, + }) + const resourceResult = await copyForkResourceContainers({ tx, sourceWorkspaceId: source.id, @@ -188,11 +219,12 @@ export async function createFork(params: CreateForkParams): Promise 0 || contentPlan.knowledgeBases.length > 0 || blobTasks.length > 0 + const hasContent = hasForkContentToCopy(contentPlan, blobTasks) // Record a durable job for EVERY fork (the fork already committed), scoped to the // SOURCE workspace - that's where the fork was initiated and where its Activity tab @@ -352,7 +390,7 @@ export async function createFork(params: CreateForkParams): Promise task.fileName), customToolNames: forkedResourceNames.customTools, skillNames: forkedResourceNames.skills, - mcpServerNames: forkedResourceNames.mcpServers, + workflowMcpServerNames: forkedResourceNames.workflowMcpServers, }, }) } catch (error) { @@ -373,34 +411,14 @@ export async function createFork(params: CreateForkParams): Promise('fork-content-copy', payload, { - region: await resolveTriggerRegion(), - }) - } else { - runDetached('fork-content-copy', () => runForkContentCopy(payload)) - } - } catch (error) { - // The fork itself succeeded; only scheduling the background copy failed. Surface - // it on the status row instead of failing the (committed) fork response. - logger.error(`[${requestId}] Failed to schedule fork content copy`, { - childWorkspaceId: result.workspace.id, - error: getErrorMessage(error), - }) - if (statusId) { - await finishBackgroundWork(db, statusId, { - status: 'failed', - error: getErrorMessage(error, 'Could not start the background copy'), - }).catch(() => {}) - } + const payload: ForkContentCopyPayload = { + contentPlan, + blobTasks, + contentRefMaps, + statusId, + requestId, } + await scheduleForkContentCopy(payload, { detachedLabel: 'fork-content-copy', requestId }) return result } diff --git a/apps/sim/lib/workspaces/fork/mapping/cascade.test.ts b/apps/sim/lib/workspaces/fork/mapping/cascade.test.ts index f8f97f09c0d..9ed938ad3d9 100644 --- a/apps/sim/lib/workspaces/fork/mapping/cascade.test.ts +++ b/apps/sim/lib/workspaces/fork/mapping/cascade.test.ts @@ -14,6 +14,7 @@ function queuedExecutor(results: unknown[][]): DbOrTx { let index = 0 const builder = { from: () => builder, + innerJoin: () => builder, where: () => Promise.resolve(results[index++] ?? []), } return { select: () => builder } as unknown as DbOrTx diff --git a/apps/sim/lib/workspaces/fork/mapping/cascade.ts b/apps/sim/lib/workspaces/fork/mapping/cascade.ts index 27d15f9ac48..917760a081d 100644 --- a/apps/sim/lib/workspaces/fork/mapping/cascade.ts +++ b/apps/sim/lib/workspaces/fork/mapping/cascade.ts @@ -1,4 +1,4 @@ -import { customTools, knowledgeConnector, mcpServers } from '@sim/db/schema' +import { customTools, knowledgeBase, knowledgeConnector, mcpServers } from '@sim/db/schema' import { and, eq, inArray, isNull } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { @@ -148,6 +148,9 @@ export async function detectForkCascadeReferences(params: { } if (knowledgeBaseIds.size > 0) { + // Join to knowledgeBase + filter by the source workspace (defense-in-depth, matching the + // customTools/mcpServers reads above): a connector is only cascaded when its KB actually + // belongs to the source workspace, so a stale/crafted KB id can't pull in a foreign connector. const connectors = await executor .select({ id: knowledgeConnector.id, @@ -156,9 +159,12 @@ export async function detectForkCascadeReferences(params: { encryptedApiKey: knowledgeConnector.encryptedApiKey, }) .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) .where( and( inArray(knowledgeConnector.knowledgeBaseId, Array.from(knowledgeBaseIds)), + eq(knowledgeBase.workspaceId, sourceWorkspaceId), + isNull(knowledgeBase.deletedAt), isNull(knowledgeConnector.deletedAt) ) ) diff --git a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts index 7ed51e03b19..683d9a34511 100644 --- a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts +++ b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts @@ -96,6 +96,71 @@ describe('collectForkDependentReconfigs', () => { ]) }) + it('anchors a dependent on the ACTIVE advanced parent member (not the dormant basic selector)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'knowledgeBaseSelector', + title: 'Knowledge Base', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + mode: 'basic', + }, + { + id: 'manualKnowledgeBaseId', + title: 'KB ID', + type: 'short-input', + canonicalParamId: 'knowledgeBaseId', + mode: 'advanced', + }, + { + id: 'documentSelector', + title: 'Document', + type: 'document-selector', + selectorKey: 'knowledge.documents', + dependsOn: ['knowledgeBaseSelector'], + required: true, + }, + ]) + ) + // Advanced mode active: the dormant basic selector is empty; the active manual id holds the KB. + const advancedState = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'knowledge', + name: 'Block', + data: { canonicalModes: { knowledgeBaseId: 'advanced' } }, + subBlocks: { + knowledgeBaseSelector: { value: '' }, + manualKnowledgeBaseId: { value: 'kb-active' }, + documentSelector: { value: 'doc-1' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState + const result = collectForkDependentReconfigs( + [replaceItem], + new Map([['wf-src', advancedState]]), + resolve + ) + // Today (raw basic read) this is skipped because the basic selector is empty; the active-member + // resolution anchors the document on the advanced KB id so the re-pick is offered. + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + parentKind: 'knowledge-base', + parentSourceId: 'kb-active', + parentContextKey: 'knowledgeBaseId', + subBlockKey: 'documentSelector', + selectorKey: 'knowledge.documents', + currentValue: 'doc-1', + }) + }) + it('emits a knowledge-base-dependent document selector', () => { vi.mocked(getBlock).mockReturnValue( blockWith([ @@ -329,6 +394,95 @@ describe('collectForkDependentReconfigs', () => { ]) }) + it('honors a nested tool-scoped advanced override (anchors on the active member, not the dormant basic)', () => { + vi.mocked(getBlock).mockImplementation((type) => { + if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + if (type === 'gmail') + return 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'], + selectorKey: 'gmail.labels', + required: true, + }, + ]) + return undefined as unknown as BlockConfig + }) + // Agent block with a nested gmail tool; the dormant basic credential holds a stale id while the + // tool-scoped `gmail:credential` override (when present) marks advanced as active. + const agentState = (canonicalModes?: Record) => + ({ + blocks: { + 'block-1': { + id: 'block-1', + type: 'agent', + name: 'Block', + data: canonicalModes ? { canonicalModes } : {}, + subBlocks: { + tools: { + value: [ + { + type: 'gmail', + title: 'Gmail 1', + params: { + credential: 'cred-stale', + manualCredential: 'cred-active', + folder: 'INBOX', + }, + }, + ], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) as unknown as WorkflowState + + // Scoped override present -> anchors on the ACTIVE advanced member (today's heuristic missed it). + const withOverride = collectForkDependentReconfigs( + [replaceItem], + new Map([['wf-src', agentState({ 'gmail:credential': 'advanced' })]]), + resolve + ) + expect(withOverride).toHaveLength(1) + expect(withOverride[0]).toMatchObject({ + parentKind: 'credential', + parentSourceId: 'cred-active', + subBlockKey: 'tools[0].folder', + }) + + // Control: no override -> the value heuristic keeps the non-empty basic (unchanged behavior). + const heuristic = collectForkDependentReconfigs( + [replaceItem], + new Map([['wf-src', agentState()]]), + resolve + ) + expect(heuristic).toHaveLength(1) + expect(heuristic[0]).toMatchObject({ + parentSourceId: 'cred-stale', + subBlockKey: 'tools[0].folder', + }) + }) + it('offers a nested tool selector even when the source left it empty', () => { vi.mocked(getBlock).mockImplementation((type) => { if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) diff --git a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts index fa54d294e2d..9ffc55d9eb1 100644 --- a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts +++ b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts @@ -8,9 +8,13 @@ import { import { buildCanonicalIndex, buildSubBlockValues, + type CanonicalModeOverrides, evaluateSubBlockCondition, + resolveActiveCanonicalValue, + scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' +import { toScannerBlocks } from '@/lib/workspaces/fork/remap/reference-scan' import { isSubBlockRequired, scanWorkflowReferences, @@ -62,6 +66,8 @@ interface EmitAnchoredParams { contextSubBlocks: Record blockName: string targetWorkflowId: string + /** Canonical-mode overrides for resolving the active parent member (undefined -> value heuristic). */ + canonicalModes?: CanonicalModeOverrides /** Memoized so the deterministic target block id is derived at most once per block. */ resolveTargetBlockId: () => string /** Map a dependent's config id to its wire `subBlockKey` (identity, or nested `tools[i].id`). */ @@ -91,6 +97,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { contextSubBlocks, blockName, targetWorkflowId, + canonicalModes, resolveTargetBlockId, makeSubBlockKey, makeTitle, @@ -106,7 +113,14 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { for (const anchor of PARENT_ANCHORS) { for (const anchorCfg of config.subBlocks) { if (anchorCfg.type !== anchor.subBlockType || !anchorCfg.id) continue - const rawValue = values[anchorCfg.id] + // Resolve the parent's ACTIVE canonical value: an advanced override (or an advanced-only + // value) beats a stale dormant basic selector, so a dependent re-pick is offered when + // advanced mode is active (today's raw basic read skips it). + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[anchorCfg.id] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + const rawValue = group + ? resolveActiveCanonicalValue(group, values, canonicalModes) + : values[anchorCfg.id] const parentSourceId = typeof rawValue === 'string' ? rawValue : '' // Skip empty and org-scoped credential sets (those carry over unchanged). if (!parentSourceId || parentSourceId.startsWith('credentialSet:')) continue @@ -201,11 +215,17 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { export function collectForkDependentReconfigs( items: ReconfigItem[], sourceStates: Map, - resolveTargetBlockId: ForkBlockIdResolver + resolveTargetBlockId: ForkBlockIdResolver, + /** + * Which target mode to scan. Defaults to `replace` (the reconfigure UI, where the user re-picks + * a dependent against a swapped parent). The pre-sync cleared-ref list passes `create` to surface + * dependents a new target inherits that a remapped parent will clear (it can't be re-picked yet). + */ + mode: 'create' | 'replace' = 'replace' ): ForkDependentReconfig[] { const out: ForkDependentReconfig[] = [] for (const item of items) { - if (item.mode !== 'replace') continue + if (item.mode !== mode) continue const state = sourceStates.get(item.sourceWorkflowId) if (!state) continue for (const [sourceBlockId, block] of Object.entries(state.blocks)) { @@ -217,7 +237,9 @@ export function collectForkDependentReconfigs( const resolveBlockId = () => (cachedTargetBlockId ??= resolveTargetBlockId(item.targetWorkflowId, sourceBlockId)) - // Top-level credential/KB/table-anchored selectors. + // Top-level credential/KB/table-anchored selectors. Block-level canonicalModes pick the + // active parent member; nested tools below pass their tool-scoped overrides (via + // scopeCanonicalModesForTool), falling back to the value heuristic only when none is set. emitAnchoredDependents({ config, values: sourceValues, @@ -225,6 +247,7 @@ export function collectForkDependentReconfigs( contextSubBlocks: subBlocks, blockName: block.name, targetWorkflowId: item.targetWorkflowId, + canonicalModes: block.data?.canonicalModes, resolveTargetBlockId: resolveBlockId, makeSubBlockKey: (id) => id, makeTitle: (dependent) => dependent.title ?? dependent.id ?? '', @@ -267,6 +290,7 @@ export function collectForkDependentReconfigs( contextSubBlocks: toolContextSubBlocks, blockName: block.name, targetWorkflowId: item.targetWorkflowId, + canonicalModes: scopeCanonicalModesForTool(block.data?.canonicalModes, tool.type), resolveTargetBlockId: resolveBlockId, makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`, makeTitle: (dependent) => `${toolLabel}: ${dependent.title ?? dependent.id ?? ''}`, @@ -307,14 +331,9 @@ export function collectForkResourceUsages( if (item.mode !== 'replace') continue const state = sourceStates.get(item.sourceWorkflowId) if (!state) continue - const blocks = Object.values(state.blocks).map((block) => ({ - id: block.id, - name: block.name, - subBlocks: block.subBlocks as unknown, - })) // scanWorkflowReferences already dedups by `${kind}:${sourceId}` across the workflow, // so each resource appears once per workflow here. - for (const reference of scanWorkflowReferences(blocks, () => null).references) { + for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) { const key = `${reference.kind}\u0000${reference.sourceId}` let usage = byResource.get(key) if (!usage) { diff --git a/apps/sim/lib/workspaces/fork/mapping/mapping-service.test.ts b/apps/sim/lib/workspaces/fork/mapping/mapping-service.test.ts index f349f3ea2c0..86570341867 100644 --- a/apps/sim/lib/workspaces/fork/mapping/mapping-service.test.ts +++ b/apps/sim/lib/workspaces/fork/mapping/mapping-service.test.ts @@ -22,8 +22,10 @@ vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({ import { ForkError } from '@/lib/workspaces/fork/lineage/authz' import { findDuplicateTargetEntry, + suggestTarget, validateForkMappingTargets, } from '@/lib/workspaces/fork/mapping/mapping-service' +import type { ForkResourceCandidate } from '@/lib/workspaces/fork/mapping/resources' type ExistingByKind = Partial>> @@ -91,6 +93,34 @@ describe('validateForkMappingTargets', () => { ).rejects.toBeInstanceOf(ForkError) }) + it('accepts a file target whose storage key exists in the target workspace', async () => { + // Files are mappable like any other content kind; the target is the storage key, and + // filterExistingForkTargets resolves file existence by key in the target workspace. + mockFilterExisting.mockResolvedValue({ file: new Set(['workspace/DST/report.pdf']) }) + await expect( + validateForkMappingTargets('ws-source', 'ws-target', [ + { + resourceType: 'file', + sourceId: 'workspace/SRC/report.pdf', + targetId: 'workspace/DST/report.pdf', + }, + ]) + ).resolves.toBeUndefined() + }) + + it('rejects a file target whose storage key is missing in the target workspace', async () => { + mockFilterExisting.mockResolvedValue({ file: new Set() }) + await expect( + validateForkMappingTargets('ws-source', 'ws-target', [ + { + resourceType: 'file', + sourceId: 'workspace/SRC/report.pdf', + targetId: 'workspace/DST/gone.pdf', + }, + ]) + ).rejects.toBeInstanceOf(ForkError) + }) + it('rejects a credential whose target provider differs from the source provider', async () => { mockFilterExisting.mockResolvedValue({ credential: new Set(['cred-tgt']) }) mockGetCredentialProviders.mockImplementation(async (_db: unknown, workspaceId: string) => @@ -180,3 +210,39 @@ describe('findDuplicateTargetEntry', () => { ).toBeNull() }) }) + +describe('suggestTarget', () => { + const cand = (id: string, label: string, providerId?: string): ForkResourceCandidate => ({ + id, + label, + providerId, + }) + + it('disambiguates same-name credentials by matching the source provider', () => { + const target = suggestTarget('credential', 'Work', 'google-email', [ + cand('c1', 'Work', 'google-calendar'), + cand('c2', 'Work', 'google-email'), + ]) + expect(target).toBe('c2') + }) + + it('suggests a unique name match for a non-credential kind', () => { + expect( + suggestTarget('table', 'Orders', undefined, [cand('t1', 'Orders'), cand('t2', 'Other')]) + ).toBe('t1') + }) + + it('returns null when the name is ambiguous (two same-name candidates)', () => { + expect( + suggestTarget('table', 'Dup', undefined, [cand('t1', 'Dup'), cand('t2', 'Dup')]) + ).toBeNull() + }) + + it('returns null when no candidate name matches', () => { + expect(suggestTarget('table', 'Orders', undefined, [cand('t1', 'Other')])).toBeNull() + }) + + it('matches the name case- and whitespace-insensitively', () => { + expect(suggestTarget('table', ' Orders ', undefined, [cand('t1', 'orders')])).toBe('t1') + }) +}) diff --git a/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts b/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts index a7700aea2d5..7a9f4ad3115 100644 --- a/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts +++ b/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import type { ForkMappingEntry } from '@/lib/api/contracts/workspace-fork' +import type { ForkMappableResourceType, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import { listDeployedWorkflows, readDeployedState } from '@/lib/workspaces/fork/copy/deploy-bridge' import { ForkError } from '@/lib/workspaces/fork/lineage/authz' @@ -23,6 +23,7 @@ import { getWorkspaceEnvKeys, listForkResourceCandidates, } from '@/lib/workspaces/fork/mapping/resources' +import { toScannerBlocks } from '@/lib/workspaces/fork/remap/reference-scan' import { type ForkReference, type ForkRemapKind, @@ -35,7 +36,7 @@ interface ForkMappingViewParams { targetWorkspaceId: string } -function suggestTarget( +export function suggestTarget( kind: ForkRemapKind, sourceLabel: string, sourceProviderId: string | undefined, @@ -73,12 +74,12 @@ export async function getForkMappingView( const resolver = buildForkResolver(mappingRows, { sourceIsParent, targetEnvKeys, sourceEnvKeys }) - const resourceTypeBySourceId = new Map>() + const resourceTypeBySourceId = new Map() for (const row of mappingRows) { - // Workflow identity rows are system-managed, not user-mappable; skip them so a - // scanned reference can never be labeled `workflow` and the view stays within - // the mappable-type contract. - if (row.resourceType === 'workflow') continue + // Workflow identity rows are system-managed and document rows ride their parent KB - neither is + // user-mappable. Skip both so a scanned reference can never be labeled with a non-mappable type + // and the view stays within the mappable-type contract. + if (row.resourceType === 'workflow' || row.resourceType === 'knowledge_document') continue const key = sourceIsParent ? row.parentResourceId : row.childResourceId if (key) resourceTypeBySourceId.set(key, row.resourceType) } @@ -90,12 +91,7 @@ export async function getForkMappingView( for (const wf of deployedWorkflows) { const state = await readDeployedState(wf.id, sourceWorkspaceId) if (!state) continue - const blocks = Object.values(state.blocks).map((block) => ({ - id: block.id, - name: block.name, - subBlocks: block.subBlocks as unknown, - })) - for (const reference of scanWorkflowReferences(blocks, () => null).references) { + for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) { referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference) } } @@ -116,7 +112,7 @@ export async function getForkMappingView( // valid mapping to a target past the display cap must be RETAINED, not shown unmapped. interface PendingEntry { reference: ForkReference - resourceType: Exclude + resourceType: ForkMappableResourceType sourceLabel: string sourceProviderId: string | undefined candidates: ForkResourceCandidate[] @@ -129,6 +125,11 @@ export async function getForkMappingView( // Only SOURCE workspace secrets are mappable; a `{{KEY}}` that isn't a source // workspace env var is a personal (user-scoped) secret - leave it as-is. if (reference.kind === 'env-var' && !sourceEnvKeys.has(reference.sourceId)) continue + // Knowledge documents are not a standalone mappable kind: a document is a dependent field + // of its knowledge base (the `document-selector` dependsOn the KB selector), re-picked in + // that KB's reconfigure flow and auto-remapped when the KB is copied. So a document never + // gets its own mapping entry - it follows its parent KB's target. + if (reference.kind === 'knowledge-document') continue let resourceType = resourceTypeBySourceId.get(reference.sourceId) if (!resourceType) { resourceType = diff --git a/apps/sim/lib/workspaces/fork/mapping/mapping-store.ts b/apps/sim/lib/workspaces/fork/mapping/mapping-store.ts index c37016584b0..fffff6a7a23 100644 --- a/apps/sim/lib/workspaces/fork/mapping/mapping-store.ts +++ b/apps/sim/lib/workspaces/fork/mapping/mapping-store.ts @@ -48,10 +48,10 @@ export function resourceTypeToForkKind(resourceType: ForkResourceType): ForkRema return RESOURCE_TYPE_TO_FORK_KIND[resourceType] } -const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE: Record< - Exclude, - Exclude -> = { +// `as const satisfies` (not a `Record` annotation) so each key keeps its precise literal +// value type - the generic accessor below then narrows its return per input kind (a uniform +// Record value type would collapse every key to the full value union). +const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE = { 'env-var': 'env_var', table: 'table', 'knowledge-base': 'knowledge_base', @@ -60,16 +60,19 @@ const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE: Record< 'mcp-server': 'mcp_server', 'custom-tool': 'custom_tool', skill: 'skill', -} +} as const satisfies Record< + Exclude, + Exclude +> /** * Stored resource type for a non-credential remap kind. Credentials are resolved * separately via `classifyCredentialResourceType` since the type (oauth vs * service account) depends on the credential row. */ -export function nonCredentialForkKindToResourceType( - kind: Exclude -): Exclude { +export function nonCredentialForkKindToResourceType>( + kind: K +): (typeof NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE)[K] { return NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE[kind] } diff --git a/apps/sim/lib/workspaces/fork/mapping/resources.test.ts b/apps/sim/lib/workspaces/fork/mapping/resources.test.ts new file mode 100644 index 00000000000..b67ce351f22 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/mapping/resources.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import type { DbOrTx } from '@/lib/db/types' +import { + listForkResourceCandidates, + loadForkCopyableResourceLabels, +} from '@/lib/workspaces/fork/mapping/resources' + +const executor = dbChainMock.db as unknown as DbOrTx + +describe('listForkResourceCandidates', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('populates file candidates keyed by storage key and leaves knowledge-document empty', async () => { + // The grouped queries resolve in Promise.all array order, each ending in `.limit()`: + // credentials, workspace env, tables, knowledge bases, MCP servers, custom tools, skills, + // files. Queue the eight results in that exact order. + dbChainMockFns.limit + .mockResolvedValueOnce([ + { id: 'cred-1', displayName: 'Cred One', providerId: 'google-email' }, + ]) + .mockResolvedValueOnce([{ variables: { API_KEY: 'secret' } }]) + .mockResolvedValueOnce([{ id: 'tbl-1', label: 'Table One' }]) + .mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }]) + .mockResolvedValueOnce([{ id: 'mcp-1', label: 'MCP One' }]) + .mockResolvedValueOnce([{ id: 'ct-1', label: 'Tool One' }]) + .mockResolvedValueOnce([{ id: 'sk-1', label: 'Skill One' }]) + .mockResolvedValueOnce([ + { id: 'workspace/WS/report.pdf', label: 'report.pdf' }, + { id: 'workspace/WS/notes.md', label: 'notes.md' }, + ]) + + const result = await listForkResourceCandidates(executor, 'ws-1') + + // Files are mapping targets keyed by storage key (matching how `file-upload` references store + // them) - never a `workspace_files.id`. + expect(result.file).toEqual([ + { id: 'workspace/WS/report.pdf', label: 'report.pdf' }, + { id: 'workspace/WS/notes.md', label: 'notes.md' }, + ]) + // Documents are not a standalone mappable kind - they ride their KB via the reconfigure flow. + expect(result['knowledge-document']).toEqual([]) + expect(result['env-var']).toEqual([{ id: 'API_KEY', label: 'API_KEY' }]) + }) +}) + +describe('loadForkCopyableResourceLabels', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('carries the folder grouping for file entries (id + name, null at the root)', async () => { + // Only the file branch queries (no other kind has ids), so its terminal `.where()` is the + // single chain call. + dbChainMockFns.where.mockResolvedValueOnce([ + { key: 'workspace/SRC/a.png', label: 'a.png', folderId: 'fld-1', folderName: 'Images' }, + { key: 'workspace/SRC/root.txt', label: 'root.txt', folderId: null, folderName: null }, + ]) + + const labels = await loadForkCopyableResourceLabels(executor, 'ws-src', { + file: ['workspace/SRC/a.png', 'workspace/SRC/root.txt'], + }) + + expect(labels.get('file:workspace/SRC/a.png')).toEqual({ + label: 'a.png', + parentId: 'fld-1', + parentLabel: 'Images', + }) + // A file at the workspace root (or whose folder was deleted) carries null folder grouping. + expect(labels.get('file:workspace/SRC/root.txt')).toEqual({ + label: 'root.txt', + parentId: null, + parentLabel: null, + }) + }) + + it('returns null folder grouping for non-file kinds (they render flat)', async () => { + dbChainMockFns.where.mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }]) + + const labels = await loadForkCopyableResourceLabels(executor, 'ws-src', { + 'knowledge-base': ['kb-1'], + }) + + expect(labels.get('knowledge-base:kb-1')).toEqual({ + label: 'KB One', + parentId: null, + parentLabel: null, + }) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/mapping/resources.ts b/apps/sim/lib/workspaces/fork/mapping/resources.ts index 0fa56338d95..23b0bf1cd38 100644 --- a/apps/sim/lib/workspaces/fork/mapping/resources.ts +++ b/apps/sim/lib/workspaces/fork/mapping/resources.ts @@ -1,16 +1,20 @@ import { credential, customTools, + document, knowledgeBase, mcpServers, skill, userTableDefinitions, workflow, workflowDeploymentVersion, + workflowMcpServer, workspaceEnvironment, + workspaceFileFolder, workspaceFiles, } from '@sim/db/schema' import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm' +import type { ForkCopyableKind } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import type { ForkResourceType } from '@/lib/workspaces/fork/mapping/mapping-store' import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' @@ -40,59 +44,145 @@ export async function getWorkspaceEnvKeys( // Shared `{ id, label }` candidate queries for the content resource kinds that BOTH the // mapping-target picker and the fork-copy picker list - one source of the archived/deleted -// filters so the two pickers can never drift apart. Credentials, env vars (mapping-only), -// and files (copy-only) stay inline in their respective functions. -const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string) => - executor +// filters so the two pickers can never drift apart, and one optional `ids` filter so the picker +// path (unfiltered, capped) and the existence/label path (exact ids) share a single definition +// per kind. When `ids` is given the query is filtered to those exact ids and is NOT capped, so a +// valid target sitting past the candidate cap is never wrongly dropped. Credentials, env vars +// (mapping-only), and files-with-folder (copy-only) keep their own helpers below. +const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { + const query = executor .select({ id: userTableDefinitions.id, label: userTableDefinitions.name }) .from(userTableDefinitions) .where( and( eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) + isNull(userTableDefinitions.archivedAt), + ids ? inArray(userTableDefinitions.id, ids) : undefined ) ) - .limit(CANDIDATE_LIMIT) + return ids ? query : query.limit(CANDIDATE_LIMIT) +} -const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string) => - executor +const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { + const query = executor .select({ id: knowledgeBase.id, label: knowledgeBase.name }) .from(knowledgeBase) - .where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))) - .limit(CANDIDATE_LIMIT) + .where( + and( + eq(knowledgeBase.workspaceId, workspaceId), + isNull(knowledgeBase.deletedAt), + ids ? inArray(knowledgeBase.id, ids) : undefined + ) + ) + return ids ? query : query.limit(CANDIDATE_LIMIT) +} -const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string) => - executor +const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { + const query = executor .select({ id: customTools.id, label: customTools.title }) .from(customTools) - .where(eq(customTools.workspaceId, workspaceId)) - .limit(CANDIDATE_LIMIT) + .where( + and(eq(customTools.workspaceId, workspaceId), ids ? inArray(customTools.id, ids) : undefined) + ) + return ids ? query : query.limit(CANDIDATE_LIMIT) +} -const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string) => - executor +const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { + const query = executor .select({ id: skill.id, label: skill.name }) .from(skill) - .where(eq(skill.workspaceId, workspaceId)) - .limit(CANDIDATE_LIMIT) + .where(and(eq(skill.workspaceId, workspaceId), ids ? inArray(skill.id, ids) : undefined)) + return ids ? query : query.limit(CANDIDATE_LIMIT) +} -const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string) => - executor +const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { + const query = executor .select({ id: mcpServers.id, label: mcpServers.name }) .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - .limit(CANDIDATE_LIMIT) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt), + ids ? inArray(mcpServers.id, ids) : undefined + ) + ) + return ids ? query : query.limit(CANDIDATE_LIMIT) +} + +// Workspace-file mapping candidates are keyed by STORAGE KEY (not `workspace_files.id`): a +// `file-upload` reference stores the storage key, so a mapping target must be a key too. Only +// durable, non-deleted `workspace` files are mappable (chat/copilot uploads are session-scoped). +// An optional `keys` filter shares this definition between the mapping picker (unfiltered, capped) +// and the cap-free existence check. +const fileCandidatesQuery = (executor: DbOrTx, workspaceId: string, keys?: string[]) => { + const query = executor + .select({ + id: workspaceFiles.key, + label: sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`, + }) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt), + keys ? inArray(workspaceFiles.key, keys) : undefined + ) + ) + return keys ? query : query.limit(CANDIDATE_LIMIT) +} + +// Copyable workspace files WITH their folder grouping (LEFT JOIN gated on a live folder, so a file +// whose folder was deleted shows ungrouped). Shared by the fork-copy picker (unfiltered, capped) +// and the promote copyable-label lookup (filtered by exact storage keys, never capped), so the +// file+folder shape and its filters live in one place. Selects both the row id and the storage +// key; the copy picker reads `id`, the key-addressed label lookup reads `key`. +const fileCandidatesWithFolderQuery = ( + executor: DbOrTx, + workspaceId: string, + options: { keys?: string[] } = {} +) => { + const { keys } = options + const query = executor + .select({ + id: workspaceFiles.id, + key: workspaceFiles.key, + label: sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`, + folderId: workspaceFiles.folderId, + folderName: workspaceFileFolder.name, + }) + .from(workspaceFiles) + .leftJoin( + workspaceFileFolder, + and( + eq(workspaceFiles.folderId, workspaceFileFolder.id), + isNull(workspaceFileFolder.deletedAt) + ) + ) + .where( + and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt), + keys ? inArray(workspaceFiles.key, keys) : undefined + ) + ) + return keys ? query : query.limit(CANDIDATE_LIMIT) +} /** * List the resources in a workspace that can serve as mapping targets, grouped by * remap kind. Used to populate the mapping UI's target pickers and to label the - * source resources being mapped. `knowledge-document` and `file` are intentionally - * left empty for v1 (optional kinds resolved manually). + * source resources being mapped. `knowledge-document` is intentionally left empty: + * documents are not a standalone mappable kind - they are dependent fields of their + * knowledge base, re-picked in the per-KB reconfigure flow (and auto-remapped when + * their KB is copied). `file` candidates are keyed by storage key. */ export async function listForkResourceCandidates( executor: DbOrTx, workspaceId: string ): Promise> { - const [creds, wsEnvRows, tables, kbs, servers, tools, skills] = await Promise.all([ + const [creds, wsEnvRows, tables, kbs, servers, tools, skills, files] = await Promise.all([ executor .select({ id: credential.id, @@ -120,6 +210,7 @@ export async function listForkResourceCandidates( mcpServerCandidatesQuery(executor, workspaceId), customToolCandidatesQuery(executor, workspaceId), skillCandidatesQuery(executor, workspaceId), + fileCandidatesQuery(executor, workspaceId), ]) const envVariables = wsEnvRows[0]?.variables @@ -141,7 +232,7 @@ export async function listForkResourceCandidates( 'custom-tool': tools, skill: skills, 'knowledge-document': [], - file: [], + file: files, } } @@ -167,11 +258,15 @@ export async function filterExistingForkTargets( const credIds = ids('credential') const tableIds = ids('table') const kbIds = ids('knowledge-base') + const docIds = ids('knowledge-document') const mcpIds = ids('mcp-server') const toolIds = ids('custom-tool') const skillIds = ids('skill') + // Files are identified by storage key (not `workspace_files.id`); a copied file's mapping + // target is its child storage key, so existence is checked by key in the target workspace. + const fileKeys = ids('file') - const [creds, tables, kbs, servers, tools, skills] = await Promise.all([ + const [creds, tables, kbs, docs, servers, tools, skills, files] = await Promise.all([ credIds.length === 0 ? Promise.resolve([] as Array<{ id: string }>) : executor @@ -186,61 +281,51 @@ export async function filterExistingForkTargets( ), tableIds.length === 0 ? Promise.resolve([] as Array<{ id: string }>) - : executor - .select({ id: userTableDefinitions.id }) - .from(userTableDefinitions) - .where( - and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt), - inArray(userTableDefinitions.id, tableIds) - ) - ), + : tableCandidatesQuery(executor, workspaceId, tableIds), kbIds.length === 0 + ? Promise.resolve([] as Array<{ id: string }>) + : knowledgeBaseCandidatesQuery(executor, workspaceId, kbIds), + // Documents are validated through a KB join (they are not a standalone candidate kind), so + // this existence check stays inline rather than sharing a per-kind candidate query. + docIds.length === 0 ? Promise.resolve([] as Array<{ id: string }>) : executor - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) + .select({ id: document.id }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) .where( and( eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt), - inArray(knowledgeBase.id, kbIds) + isNull(document.deletedAt), + isNull(document.archivedAt), + inArray(document.id, docIds) ) ), mcpIds.length === 0 ? Promise.resolve([] as Array<{ id: string }>) - : executor - .select({ id: mcpServers.id }) - .from(mcpServers) - .where( - and( - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt), - inArray(mcpServers.id, mcpIds) - ) - ), + : mcpServerCandidatesQuery(executor, workspaceId, mcpIds), toolIds.length === 0 ? Promise.resolve([] as Array<{ id: string }>) - : executor - .select({ id: customTools.id }) - .from(customTools) - .where(and(eq(customTools.workspaceId, workspaceId), inArray(customTools.id, toolIds))), + : customToolCandidatesQuery(executor, workspaceId, toolIds), skillIds.length === 0 ? Promise.resolve([] as Array<{ id: string }>) - : executor - .select({ id: skill.id }) - .from(skill) - .where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds))), + : skillCandidatesQuery(executor, workspaceId, skillIds), + fileKeys.length === 0 + ? Promise.resolve([] as Array<{ id: string }>) + : fileCandidatesQuery(executor, workspaceId, fileKeys), ]) const result: Partial>> = {} if (credIds.length > 0) result.credential = new Set(creds.map((r) => r.id)) if (tableIds.length > 0) result.table = new Set(tables.map((r) => r.id)) if (kbIds.length > 0) result['knowledge-base'] = new Set(kbs.map((r) => r.id)) + if (docIds.length > 0) result['knowledge-document'] = new Set(docs.map((r) => r.id)) if (mcpIds.length > 0) result['mcp-server'] = new Set(servers.map((r) => r.id)) if (toolIds.length > 0) result['custom-tool'] = new Set(tools.map((r) => r.id)) if (skillIds.length > 0) result.skill = new Set(skills.map((r) => r.id)) + // `fileCandidatesQuery` exposes the storage key under `id`, so file existence keys by `r.id`. + if (fileKeys.length > 0) result.file = new Set(files.map((r) => r.id)) return result } @@ -268,13 +353,20 @@ export async function getCredentialProvidersByIds( return new Map(rows.map((row) => [row.id, row.providerId ?? null])) } +/** A copyable workspace file plus its folder grouping (null folder = workspace root). */ +export interface ForkCopyableFileResource extends ForkResourceCandidate { + folderId: string | null + folderName: string | null +} + export interface ForkCopyableResources { - files: ForkResourceCandidate[] + files: ForkCopyableFileResource[] tables: ForkResourceCandidate[] knowledgeBases: ForkResourceCandidate[] customTools: ForkResourceCandidate[] skills: ForkResourceCandidate[] - mcpServers: ForkResourceCandidate[] + /** Workflow-publishing MCP servers, copied as config-only shells (external MCP is not copied). */ + workflowMcpServers: ForkResourceCandidate[] /** * Count of deployed workflows that the fork would copy. When 0, the fork modal shows an * informational note (forking is never blocked) - create-fork seeds a blank starter @@ -293,28 +385,18 @@ export async function listForkCopyableResources( workspaceId: string ): Promise { const [files, tables, kbs, tools, skills, servers, deployed] = await Promise.all([ - executor - .select({ - id: workspaceFiles.id, - // displayName is nullable; fall back to the (non-null) original name. - label: sql`coalesce(${workspaceFiles.displayName}, ${workspaceFiles.originalName})`, - }) - .from(workspaceFiles) - // Only durable workspace files are forkable - chat/copilot/mothership uploads are - // session-scoped attachments (and their chat-bound unique index can't be copied). - .where( - and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) - ) - .limit(CANDIDATE_LIMIT), + fileCandidatesWithFolderQuery(executor, workspaceId), tableCandidatesQuery(executor, workspaceId), knowledgeBaseCandidatesQuery(executor, workspaceId), customToolCandidatesQuery(executor, workspaceId), skillCandidatesQuery(executor, workspaceId), - mcpServerCandidatesQuery(executor, workspaceId), + executor + .select({ id: workflowMcpServer.id, label: workflowMcpServer.name }) + .from(workflowMcpServer) + .where( + and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt)) + ) + .limit(CANDIDATE_LIMIT), executor .select({ value: count() }) .from(workflow) @@ -341,16 +423,98 @@ export async function listForkCopyableResources( ), ]) return { - files, + // The shared folder query also selects the storage key (for the label lookup); the copy + // picker addresses files by `workspace_files.id`, so drop the key here. + files: files.map((row) => ({ + id: row.id, + label: row.label, + folderId: row.folderId, + folderName: row.folderName, + })), tables, knowledgeBases: kbs, customTools: tools, skills, - mcpServers: servers, + workflowMcpServers: servers, deployedWorkflowCount: deployed[0]?.value ?? 0, } } +/** + * A copyable reference's display label plus its folder grouping. `parentId`/`parentLabel` are + * populated only for files (their folder id + name; null at the workspace root) and are null for + * every other copyable kind, which the picker renders flat. + */ +export interface ForkCopyableLabel { + label: string + parentId: string | null + parentLabel: string | null +} + +/** + * Labels (by exact id) for the copyable resource kinds referenced-but-unmapped at promote time, + * scoped to the source workspace and the same archived/deleted filters as the copy picker. A + * resource absent from the result no longer exists in the source, so it can't be copied and is + * dropped from the sync copy candidates. Keyed `${kind}:${id}` so callers can look a reference up + * directly; file entries additionally carry their folder grouping. Only kinds with ids are queried. + */ +export async function loadForkCopyableResourceLabels( + executor: DbOrTx, + sourceWorkspaceId: string, + idsByKind: Partial> +): Promise> { + const labels = new Map() + const ids = (kind: ForkCopyableKind): string[] => { + const list = idsByKind[kind] + return list && list.length > 0 ? list : [] + } + const kbIds = ids('knowledge-base') + const tableIds = ids('table') + const toolIds = ids('custom-tool') + const skillIds = ids('skill') + // Files are keyed by storage key (not `workspace_files.id`), so they label by key. + const fileKeys = ids('file') + + const [kbs, tables, tools, skills, files] = await Promise.all([ + kbIds.length === 0 + ? Promise.resolve([] as Array<{ id: string; label: string }>) + : knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId, kbIds), + tableIds.length === 0 + ? Promise.resolve([] as Array<{ id: string; label: string }>) + : tableCandidatesQuery(executor, sourceWorkspaceId, tableIds), + toolIds.length === 0 + ? Promise.resolve([] as Array<{ id: string; label: string }>) + : customToolCandidatesQuery(executor, sourceWorkspaceId, toolIds), + skillIds.length === 0 + ? Promise.resolve([] as Array<{ id: string; label: string }>) + : skillCandidatesQuery(executor, sourceWorkspaceId, skillIds), + fileKeys.length === 0 + ? Promise.resolve( + [] as Array<{ + key: string + label: string + folderId: string | null + folderName: string | null + }> + ) + : fileCandidatesWithFolderQuery(executor, sourceWorkspaceId, { keys: fileKeys }), + ]) + + const flat = (label: string): ForkCopyableLabel => ({ label, parentId: null, parentLabel: null }) + for (const row of kbs) labels.set(`knowledge-base:${row.id}`, flat(row.label)) + for (const row of tables) labels.set(`table:${row.id}`, flat(row.label)) + for (const row of tools) labels.set(`custom-tool:${row.id}`, flat(row.label)) + for (const row of skills) labels.set(`skill:${row.id}`, flat(row.label)) + for (const row of files) { + labels.set(`file:${row.key}`, { + label: row.label, + parentId: row.folderId, + parentLabel: row.folderName, + }) + } + return labels +} + /** Resolve a credential id to its stored mapping resource type. */ export async function classifyCredentialResourceType( executor: DbOrTx, diff --git a/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts b/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts new file mode 100644 index 00000000000..69f3adb8d01 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts @@ -0,0 +1,528 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { SubBlockConfig } from '@/blocks/types' + +// The reference indexer resolves a tool's params via the tool registry; stub it so loading the +// remap module never pulls the full registry (these cases use top-level selectors / dependents). +vi.mock('@/tools/params', () => ({ + getToolIdForOperation: () => undefined, + getToolParametersConfig: () => null, + getSubBlocksForToolInput: ( + _toolId: string, + _type: string, + _values: unknown, + _modes: unknown, + provided?: { subBlocks?: SubBlockConfig[] } + ) => ({ subBlocks: provided?.subBlocks ?? [] }), + formatParameterLabel: (label: string) => label, +})) + +import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs' +import { + buildForkBlockIdResolver, + deriveForkBlockId, + EMPTY_FORK_BLOCK_MAP, +} from '@/lib/workspaces/fork/remap/block-identity' +import type { ForkReferenceResolver } from '@/lib/workspaces/fork/remap/remap-references' +import { getBlock } from '@/blocks/registry' +import type { BlockConfig } from '@/blocks/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig => + ({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig + +// No persisted block map, so the resolver derives - matching the deriveForkBlockId expectations. +const resolveBlockId = buildForkBlockIdResolver(true, EMPTY_FORK_BLOCK_MAP) + +const stateWith = ( + blockType: string, + blockName: string, + subBlocks: Record +): WorkflowState => + ({ + blocks: { 'block-1': { id: 'block-1', type: blockType, name: blockName, subBlocks } }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) as unknown as WorkflowState + +interface ParamOverrides { + items?: Array<{ + sourceWorkflowId: string + targetWorkflowId: string + mode: 'create' | 'replace' + sourceMeta: { name: string } + }> + sourceStates?: Map + resolver?: ForkReferenceResolver + workflowIdMap?: Map + sourceLabels?: Map + sourceWorkflowNames?: Map +} + +const params = (overrides: ParamOverrides) => ({ + items: [], + sourceStates: new Map(), + resolver: (() => null) as ForkReferenceResolver, + workflowIdMap: new Map(), + resolveBlockId, + sourceLabels: new Map(), + sourceWorkflowNames: new Map(), + ...overrides, +}) + +const targetBlockId = deriveForkBlockId('wf-tgt', 'block-1') + +describe('collectForkClearedRefCandidates', () => { + it('emits an unmapped knowledge-base reference (cause reference) with block + field labels', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Search' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('knowledge', 'KB Block', { + kb: { type: 'knowledge-base-selector', value: 'kb-src' }, + }), + ], + ]), + resolver: () => null, + sourceLabels: new Map([['knowledge-base:kb-src', 'Docs KB']]), + }) + ) + expect(result).toEqual([ + { + targetWorkflowId: 'wf-tgt', + workflowName: 'Search', + blockId: targetBlockId, + blockLabel: 'KB Block', + fieldLabel: 'Knowledge Base', + kind: 'knowledge-base', + sourceId: 'kb-src', + sourceLabel: 'Docs KB', + cause: 'reference', + }, + ]) + }) + + it('drops a reference once the resolver maps it (so a mapped resource is not "cleared")', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Search' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('knowledge', 'KB Block', { + kb: { type: 'knowledge-base-selector', value: 'kb-src' }, + }), + ], + ]), + resolver: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-tgt' : null), + }) + ) + expect(result).toEqual([]) + }) + + it('excludes a required credential reference (a blocker resolved by mapping, never cleared)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'credential', title: 'Account', type: 'oauth-input' }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Send Email' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('gmail', 'Gmail 1', { + credential: { type: 'oauth-input', value: 'cred-src' }, + }), + ], + ]), + // An unmapped required credential gates Sync; it must not appear as a "will be cleared" item. + resolver: () => null, + sourceLabels: new Map([['credential:cred-src', 'Work Gmail']]), + }) + ) + expect(result).toEqual([]) + }) + + it('never emits an env-var reference (env vars are preserved by name, not cleared)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'url', title: 'URL', type: 'short-input' }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'API' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('api', 'API', { url: { type: 'short-input', value: '{{API_KEY}}' } }), + ], + ]), + resolver: () => null, + }) + ) + expect(result).toEqual([]) + }) + + it('emits a workflow reference to a workflow not carried into the target (cause workflow)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Caller' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('workflow_caller', 'Run Subflow', { + target: { type: 'workflow-selector', value: 'wf-other' }, + }), + ], + ]), + workflowIdMap: new Map(), + sourceWorkflowNames: new Map([['wf-other', 'Other Workflow']]), + }) + ) + expect(result).toEqual([ + { + targetWorkflowId: 'wf-tgt', + workflowName: 'Caller', + blockId: targetBlockId, + blockLabel: 'Run Subflow', + fieldLabel: 'Workflow', + kind: 'workflow', + sourceId: 'wf-other', + sourceLabel: 'Other Workflow', + cause: 'workflow', + }, + ]) + }) + + it('does not emit a workflow reference when the workflow is carried into the target', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Caller' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('workflow_caller', 'Run Subflow', { + target: { type: 'workflow-selector', value: 'wf-other' }, + }), + ], + ]), + workflowIdMap: new Map([['wf-other', 'wf-other-child']]), + }) + ) + expect(result).toEqual([]) + }) + + it('collapses the workflowId pair to the active member: a dormant basic selector is not a false cleared-ref', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'workflowId', + title: 'Workflow', + type: 'workflow-selector', + canonicalParamId: 'workflowId', + mode: 'basic', + }, + { + id: 'manualWorkflowId', + title: 'Workflow ID', + type: 'short-input', + canonicalParamId: 'workflowId', + mode: 'advanced', + }, + ]) + ) + // Advanced mode active; the dormant basic selector holds a stale, uncopied id. + const advancedState = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'workflow', + name: 'Caller', + data: { canonicalModes: { workflowId: 'advanced' } }, + subBlocks: { + workflowId: { type: 'workflow-selector', value: 'wf-old' }, + manualWorkflowId: { type: 'short-input', value: 'wf-active' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState + const item = { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace' as const, + sourceMeta: { name: 'Caller' }, + } + + // Active advanced workflow carried into the target: the dormant basic must NOT produce a row. + const carried = collectForkClearedRefCandidates( + params({ + items: [item], + sourceStates: new Map([['wf-src', advancedState]]), + workflowIdMap: new Map([['wf-active', 'wf-active-child']]), + }) + ) + expect(carried.filter((ref) => ref.cause === 'workflow')).toEqual([]) + + // The ACTIVE member still produces a row when it is not carried (active path unbroken). + const cleared = collectForkClearedRefCandidates( + params({ + items: [item], + sourceStates: new Map([['wf-src', advancedState]]), + sourceWorkflowNames: new Map([['wf-active', 'Active Workflow']]), + }) + ) + const workflowRows = cleared.filter((ref) => ref.cause === 'workflow') + expect(workflowRows).toHaveLength(1) + expect(workflowRows[0].sourceId).toBe('wf-active') + }) + + it('emits a configured create-target dependent a remapped parent will clear (cause dependent)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'folder', + title: 'Label', + type: 'folder-selector', + dependsOn: ['credential'], + selectorKey: 'gmail.labels', + }, + ]) + ) + // Entries carry no `type`, so the credential is not a direct (category-A) reference here - + // isolating the create-dependent (category-C) path: the label hangs off the credential. + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'create', + sourceMeta: { name: 'New Workflow' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('gmail', 'Gmail', { + credential: { value: 'cred-src' }, + folder: { value: 'INBOX' }, + }), + ], + ]), + sourceLabels: new Map([['credential:cred-src', 'Work Gmail']]), + }) + ) + expect(result).toEqual([ + { + targetWorkflowId: 'wf-tgt', + workflowName: 'New Workflow', + blockId: targetBlockId, + blockLabel: 'Gmail', + fieldLabel: 'Label', + kind: 'credential', + sourceId: 'cred-src', + sourceLabel: 'Work Gmail', + cause: 'dependent', + parentKind: 'credential', + parentSourceId: 'cred-src', + }, + ]) + }) + + it('carries the knowledge-base parent on a document-selector dependent (so it can drop off)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'knowledgeBaseSelector', + title: 'Knowledge Base', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + }, + { + id: 'documentSelector', + title: 'Document', + type: 'document-selector', + canonicalParamId: 'documentId', + selectorKey: 'knowledge.documents', + dependsOn: ['knowledgeBaseSelector'], + }, + ]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'create', + sourceMeta: { name: 'New Workflow' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('knowledge', 'Knowledge', { + knowledgeBaseSelector: { value: 'kb-src' }, + documentSelector: { value: 'doc-src' }, + }), + ], + ]), + sourceLabels: new Map([['knowledge-base:kb-src', 'Docs KB']]), + }) + ) + expect(result).toEqual([ + { + targetWorkflowId: 'wf-tgt', + workflowName: 'New Workflow', + blockId: targetBlockId, + blockLabel: 'Knowledge', + fieldLabel: 'Document', + kind: 'knowledge-base', + sourceId: 'kb-src', + sourceLabel: 'Docs KB', + cause: 'dependent', + parentKind: 'knowledge-base', + parentSourceId: 'kb-src', + }, + ]) + }) + + it('does not emit a create-target dependent the source left unset (nothing is lost)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'folder', + title: 'Label', + type: 'folder-selector', + dependsOn: ['credential'], + selectorKey: 'gmail.labels', + }, + ]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'create', + sourceMeta: { name: 'New Workflow' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('gmail', 'Gmail', { + credential: { value: 'cred-src' }, + folder: { value: '' }, + }), + ], + ]), + }) + ) + expect(result).toEqual([]) + }) + + it('does not emit create-dependents for a replace target (handled by the reconfigure flow)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'folder', + title: 'Label', + type: 'folder-selector', + dependsOn: ['credential'], + selectorKey: 'gmail.labels', + }, + ]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Existing' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('gmail', 'Gmail', { + credential: { value: 'cred-src' }, + folder: { value: 'INBOX' }, + }), + ], + ]), + }) + ) + // No direct refs (untyped entries) and no create-dependents (replace mode) -> empty. + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts b/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts new file mode 100644 index 00000000000..e6922731275 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts @@ -0,0 +1,233 @@ +import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' +import { + coerceObjectArray, + isRecord, + type SubBlockRecord, +} from '@/lib/workflows/persistence/remap-internal-ids' +import { + buildCanonicalIndex, + buildSubBlockValues, + type CanonicalModeOverrides, + isCanonicalPair, + resolveCanonicalMode, +} from '@/lib/workflows/subblocks/visibility' +import { collectForkDependentReconfigs } from '@/lib/workspaces/fork/mapping/dependent-reconfigs' +import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' +import { + type ForkReferenceResolver, + type ForkRemapKind, + REQUIRED_KINDS, + remapForkSubBlocks, +} from '@/lib/workspaces/fork/remap/remap-references' +import { getBlock } from '@/blocks/registry' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** + * Remappable kinds excluded from the `reference` cleared-ref list. REQUIRED kinds (credential, + * env-var) are BLOCKERS - they gate Sync and are resolved by mapping, never silently cleared - so + * they must not read as "will be cleared" (a credential is also preserved by name once mapped, an + * env-var always). `knowledge-document` follows its parent KB - a document under an unmapped KB is + * implied by the KB's own cleared-ref entry, and under a mapped/copied KB it is auto-copied. + */ +const CLEARED_REF_EXCLUDED_KINDS = new Set([...REQUIRED_KINDS, 'knowledge-document']) + +interface ClearedRefItem { + sourceWorkflowId: string + targetWorkflowId: string + mode: 'create' | 'replace' + sourceMeta: { name: string } +} + +export interface CollectForkClearedRefsParams { + items: ClearedRefItem[] + sourceStates: Map + /** Plan resolver (persisted mappings + env identity), to detect which refs are currently unmapped. */ + resolver: ForkReferenceResolver + /** Source workflow id -> target id for THIS sync; a ref to a workflow absent here is cleared. */ + workflowIdMap: Map + /** Same block-id resolver the sync uses, so a candidate's blockId matches the written block. */ + resolveBlockId: ForkBlockIdResolver + /** `${kind}:${sourceId}` -> source resource label, for the `sourceLabel` display. */ + sourceLabels: Map + /** Source workflow id -> name, for `workflow`-kind candidate labels. */ + sourceWorkflowNames: Map +} + +/** Strip an advanced-mode `_N` suffix so a subblock key matches its config id. */ +function baseSubBlockId(key: string): string { + return key.replace(/_\d+$/, '') +} + +/** + * Cross-workflow references (`workflow-selector`, advanced `manualWorkflowId(s)`, multi-select + * `workflowSelector`, nested `workflow_input` tools) in a block's subBlocks. Mirrors the detection + * in {@link remapWorkflowReferencesInSubBlocks} so the cleared-ref list flags exactly the refs that + * remap would clear. Returns one entry per referenced workflow id with its owning subblock key. + */ +function collectForkWorkflowReferences( + subBlocks: SubBlockRecord, + config: ReturnType, + canonicalModes: CanonicalModeOverrides | undefined +): Array<{ workflowId: string; subBlockKey: string }> { + const out: Array<{ workflowId: string; subBlockKey: string }> = [] + // Collapse the `workflowId` canonical pair (basic `workflow-selector` + advanced `manualWorkflowId`) + // to its ACTIVE member: only the active mode is serialized, so a dormant stale member is not a ref + // that would be cleared (mirrors remap-internal-ids.ts). Undefined mode -> emit both (legacy/no-pair). + const workflowGroup = config + ? buildCanonicalIndex(config.subBlocks).groupsById.workflowId + : undefined + const workflowMode = + workflowGroup && isCanonicalPair(workflowGroup) + ? resolveCanonicalMode(workflowGroup, buildSubBlockValues(subBlocks), canonicalModes) + : undefined + for (const [key, subBlock] of Object.entries(subBlocks)) { + if (!subBlock || typeof subBlock !== 'object') continue + const baseKey = baseSubBlockId(key) + if ( + (subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') && + typeof subBlock.value === 'string' && + subBlock.value + ) { + // Skip the dormant member of the pair (the active mode owns the reference). + const isAdvancedMember = baseKey === 'manualWorkflowId' + if (workflowMode && (workflowMode === 'advanced') !== isAdvancedMember) continue + out.push({ workflowId: subBlock.value, subBlockKey: key }) + } else if (baseKey === 'manualWorkflowIds' || baseKey === 'workflowSelector') { + const ids = Array.isArray(subBlock.value) + ? subBlock.value + : typeof subBlock.value === 'string' + ? subBlock.value.split(',').map((entry) => entry.trim()) + : [] + for (const id of ids) { + if (typeof id === 'string' && id) out.push({ workflowId: id, subBlockKey: key }) + } + } else if (subBlock.type === 'tool-input') { + const { array } = coerceObjectArray(subBlock.value) + if (!array) continue + for (const tool of array) { + if ( + isRecord(tool) && + tool.type === 'workflow_input' && + isRecord(tool.params) && + typeof tool.params.workflowId === 'string' && + tool.params.workflowId + ) { + out.push({ workflowId: tool.params.workflowId, subBlockKey: key }) + } + } + } + } + return out +} + +/** + * Compute the per-block/field references this sync WILL blank in the target, for the pre-sync + * "what will be cleared" list. Three causes (see {@link ForkClearedRef}): + * - `reference`: an unmapped remappable resource (credential / KB / table / file / MCP server / + * custom tool / skill). The client filters these against the live mapping + copy selection, so an + * item disappears once mapped or selected for copy. Env vars (preserved) and documents (follow + * their KB) are excluded. + * - `workflow`: a cross-workflow reference to a workflow not carried into the target - always cleared. + * - `dependent`: a create-target dependent selector the source configured that a remapped parent + * clears. Carries `parentKind`/`parentSourceId` so the client can drop it once a KB parent is + * mapped or copied (the document follows its KB); a credential's label or a table's column is + * cleared on any parent remap, so it stays. + * + * Pure (no DB): the caller supplies the plan, source states, resolver, block-id resolver, and the + * source label maps. Block + field labels come from the block registry / block state. + */ +export function collectForkClearedRefCandidates( + params: CollectForkClearedRefsParams +): ForkClearedRef[] { + const { items, sourceStates, resolver, workflowIdMap, resolveBlockId, sourceLabels } = params + const out: ForkClearedRef[] = [] + const labelFor = (kind: string, sourceId: string) => + sourceLabels.get(`${kind}:${sourceId}`) ?? sourceId + + for (const item of items) { + const state = sourceStates.get(item.sourceWorkflowId) + if (!state) continue + for (const [sourceBlockId, block] of Object.entries(state.blocks)) { + const config = getBlock(block.type) + const blockLabel = block.name + const targetBlockId = resolveBlockId(item.targetWorkflowId, sourceBlockId) + // double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally + // SubBlockRecord entries but lack the open index signature SubBlockRecord declares + const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord + const fieldLabel = (subBlockKey: string) => + config?.subBlocks.find((cfg) => cfg.id === baseSubBlockId(subBlockKey))?.title ?? + subBlockKey + + // Cause `reference`: unmapped remappable resource refs (per block/field). + const scan = remapForkSubBlocks(subBlocks, resolver, 'promote', { + blockId: targetBlockId, + blockName: blockLabel, + }) + for (const ref of scan.unmapped) { + if (CLEARED_REF_EXCLUDED_KINDS.has(ref.kind)) continue + out.push({ + targetWorkflowId: item.targetWorkflowId, + workflowName: item.sourceMeta.name, + blockId: targetBlockId, + blockLabel, + fieldLabel: fieldLabel(ref.subBlockKey), + kind: ref.kind, + sourceId: ref.sourceId, + sourceLabel: labelFor(ref.kind, ref.sourceId), + cause: 'reference', + }) + } + + // Cause `workflow`: refs to a workflow not carried into the target. + for (const wfRef of collectForkWorkflowReferences( + subBlocks, + config, + block.data?.canonicalModes + )) { + if (workflowIdMap.has(wfRef.workflowId)) continue + out.push({ + targetWorkflowId: item.targetWorkflowId, + workflowName: item.sourceMeta.name, + blockId: targetBlockId, + blockLabel, + fieldLabel: fieldLabel(wfRef.subBlockKey), + kind: 'workflow', + sourceId: wfRef.workflowId, + sourceLabel: params.sourceWorkflowNames.get(wfRef.workflowId) ?? wfRef.workflowId, + cause: 'workflow', + }) + } + } + } + + // Cause `dependent`: create-target dependent selectors the source configured that a remapped + // parent clears. Only `replace` targets get the in-place reconfigure flow; a created target has + // no draft to re-pick against, so these would clear silently - surface them here. + const workflowNameByTarget = new Map(items.map((i) => [i.targetWorkflowId, i.sourceMeta.name])) + for (const dependent of collectForkDependentReconfigs( + items, + sourceStates, + resolveBlockId, + 'create' + )) { + if (dependent.currentValue === '') continue + out.push({ + targetWorkflowId: dependent.targetWorkflowId, + workflowName: workflowNameByTarget.get(dependent.targetWorkflowId) ?? '', + blockId: dependent.targetBlockId, + blockLabel: dependent.blockName, + fieldLabel: dependent.title, + kind: dependent.parentKind, + sourceId: dependent.parentSourceId, + sourceLabel: labelFor(dependent.parentKind, dependent.parentSourceId), + cause: 'dependent', + // The dependsOn parent (its KB/credential/table). The client drops this entry once the parent + // is mapped or copied ONLY when the child follows it (a document under a KB); a credential's + // label or a table's column is cleared on any parent remap, so it stays. + parentKind: dependent.parentKind, + parentSourceId: dependent.parentSourceId, + }) + } + + return out +} diff --git a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts new file mode 100644 index 00000000000..91e0e74341c --- /dev/null +++ b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts @@ -0,0 +1,381 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + type ForkCopyableUnmapped, + forkCopyableKindSchema, +} from '@/lib/api/contracts/workspace-fork' +import type { DbOrTx } from '@/lib/db/types' + +const { + mockUpsertEdgeMappings, + mockDeleteEdgeMappingsByChildResources, + mockCopyForkResourceContainers, + mockPlanForkMappedKbDocumentCopies, + mockPlanForkFileCopies, +} = vi.hoisted(() => ({ + mockUpsertEdgeMappings: vi.fn(), + mockDeleteEdgeMappingsByChildResources: vi.fn(), + mockCopyForkResourceContainers: vi.fn(), + mockPlanForkMappedKbDocumentCopies: vi.fn(), + mockPlanForkFileCopies: vi.fn(), +})) + +vi.mock('@/lib/workspaces/fork/mapping/mapping-store', () => ({ + upsertEdgeMappings: mockUpsertEdgeMappings, + deleteEdgeMappingsByChildResources: mockDeleteEdgeMappingsByChildResources, + resourceTypeToForkKind: vi.fn(), +})) + +vi.mock('@/lib/workspaces/fork/copy/copy-resources', () => ({ + copyForkResourceContainers: mockCopyForkResourceContainers, + planForkMappedKbDocumentCopies: mockPlanForkMappedKbDocumentCopies, + copyForkResourceContent: vi.fn(), +})) + +vi.mock('@/lib/workspaces/fork/copy/copy-files', () => ({ + planForkFileCopies: mockPlanForkFileCopies, + executeForkFileBlobCopies: vi.fn(), +})) + +import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage' +import type { ForkMappingUpsert } from '@/lib/workspaces/fork/mapping/mapping-store' +import { + augmentForkResolver, + buildPromoteCopySelection, + copyPromoteUnmappedResources, + FORK_COPYABLE_KIND_TO_SELECTION_KEY, + hasPromoteCopySelection, + persistPromoteCopiedMappings, +} from '@/lib/workspaces/fork/promote/copy-unmapped' +import { isForkCopyableKind } from '@/lib/workspaces/fork/promote/promote-plan' +import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' + +const candidates: ForkCopyableUnmapped[] = [ + { kind: 'knowledge-base', sourceId: 'kb-1', label: 'KB One', parentId: null, parentLabel: null }, + { kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null }, + { kind: 'custom-tool', sourceId: 'ct-1', label: 'Tool One', parentId: null, parentLabel: null }, + { kind: 'skill', sourceId: 'sk-1', label: 'Skill One', parentId: null, parentLabel: null }, + { + kind: 'file', + sourceId: 'workspace/SRC/a.png', + label: 'a.png', + parentId: 'fld-1', + parentLabel: 'Images', + }, +] + +describe('buildPromoteCopySelection', () => { + it('groups requested ids into the selection by kind and records willResolve keys', () => { + const { selection, willResolve } = buildPromoteCopySelection( + { knowledgeBases: ['kb-1'], tables: ['tbl-1'], customTools: ['ct-1'], skills: ['sk-1'] }, + candidates + ) + expect(selection.knowledgeBases).toEqual(['kb-1']) + expect(selection.tables).toEqual(['tbl-1']) + expect(selection.customTools).toEqual(['ct-1']) + expect(selection.skills).toEqual(['sk-1']) + expect(selection.workflowMcpServers).toEqual([]) + expect(willResolve.has('knowledge-base:kb-1')).toBe(true) + expect(willResolve.has('skill:sk-1')).toBe(true) + }) + + it('ignores a requested id that is not an actual copy candidate (security)', () => { + const { selection, willResolve } = buildPromoteCopySelection( + { knowledgeBases: ['kb-1', 'kb-not-a-candidate'] }, + candidates + ) + expect(selection.knowledgeBases).toEqual(['kb-1']) + expect(willResolve.has('knowledge-base:kb-not-a-candidate')).toBe(false) + }) + + it('groups requested file storage keys (security: only actual candidates)', () => { + const { selection, willResolve } = buildPromoteCopySelection( + { files: ['workspace/SRC/a.png', 'workspace/SRC/not-referenced.png'] }, + candidates + ) + expect(selection.files).toEqual(['workspace/SRC/a.png']) + expect(willResolve.has('file:workspace/SRC/a.png')).toBe(true) + expect(willResolve.has('file:workspace/SRC/not-referenced.png')).toBe(false) + }) + + it('returns an empty selection when nothing is requested', () => { + const { selection, willResolve } = buildPromoteCopySelection(undefined, candidates) + expect(hasPromoteCopySelection(selection)).toBe(false) + expect(willResolve.size).toBe(0) + }) + + it('copy-vs-map: maps win - a mapped resource is absent from the candidates, so a copy request for it is dropped', () => { + // Reconciliation precedence at the server boundary: a resource the user mapped resolves to a + // target, so the plan never lists it in `copyableUnmapped`. Even if a (stale) client still + // requests it for copy, only the genuinely-unmapped candidates survive - the map wins. + const onlyTableUnmapped: ForkCopyableUnmapped[] = [ + { kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null }, + ] + const { selection, willResolve } = buildPromoteCopySelection( + // kb-1 + the file were mapped (so absent from candidates); only the table remains copyable. + { + knowledgeBases: ['kb-1'], + tables: ['tbl-1'], + files: ['workspace/SRC/a.png'], + }, + onlyTableUnmapped + ) + expect(selection.knowledgeBases).toEqual([]) + expect(selection.files).toEqual([]) + expect(selection.tables).toEqual(['tbl-1']) + expect(willResolve.has('knowledge-base:kb-1')).toBe(false) + expect(willResolve.has('file:workspace/SRC/a.png')).toBe(false) + expect(willResolve.has('table:tbl-1')).toBe(true) + }) +}) + +describe('hasPromoteCopySelection', () => { + it('is true only when at least one copyable kind has ids', () => { + expect( + hasPromoteCopySelection({ + customTools: [], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: ['kb-1'], + files: [], + }) + ).toBe(true) + expect( + hasPromoteCopySelection({ + customTools: [], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: [], + files: [], + }) + ).toBe(false) + expect( + hasPromoteCopySelection({ + customTools: [], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: [], + files: ['workspace/SRC/file.png'], + }) + ).toBe(true) + }) +}) + +describe('augmentForkResolver', () => { + it('resolves a just-copied resource via the extra map, else falls through to the base', () => { + const base = (kind: ForkRemapKind, id: string) => + kind === 'credential' && id === 'cred-src' ? 'cred-dst' : null + const extra = new Map>([ + ['knowledge-base', new Map([['kb-src', 'kb-dst']])], + ]) + const resolver = augmentForkResolver(base, extra) + expect(resolver('knowledge-base', 'kb-src')).toBe('kb-dst') + expect(resolver('credential', 'cred-src')).toBe('cred-dst') + expect(resolver('table', 'tbl-x')).toBeNull() + }) +}) + +describe('persistPromoteCopiedMappings', () => { + const tx = {} as DbOrTx + const entry: ForkMappingUpsert = { + resourceType: 'knowledge_base', + parentResourceId: 'src-kb', + childResourceId: 'dst-kb', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('pull keeps the source(parent)->target(child) orientation as-is', async () => { + await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'pull', [entry]) + expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [entry]) + expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled() + }) + + it('push swaps to target(parent)->source(child) and deletes the prior row keyed on the source child', async () => { + await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [entry]) + // Delete keys on the source child resource (the swapped child id = the original parent id). + expect(mockDeleteEdgeMappingsByChildResources).toHaveBeenCalledWith(tx, 'edge-child', [ + { resourceType: 'knowledge_base', childResourceId: 'src-kb' }, + ]) + // The swap flips parent/child: the new copy (dst) becomes the parent side on push. + expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [ + { resourceType: 'knowledge_base', parentResourceId: 'dst-kb', childResourceId: 'src-kb' }, + ]) + }) + + it('push skips an entry with a null child id (the narrowing guard, no bogus mapping)', async () => { + await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', [ + { resourceType: 'knowledge_base', parentResourceId: 'src-kb', childResourceId: null }, + ]) + expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled() + expect(mockUpsertEdgeMappings).not.toHaveBeenCalled() + }) + + it('returns without writing when there are no entries', async () => { + await persistPromoteCopiedMappings(tx, 'edge-child', 'user-1', 'push', []) + expect(mockDeleteEdgeMappingsByChildResources).not.toHaveBeenCalled() + expect(mockUpsertEdgeMappings).not.toHaveBeenCalled() + }) +}) + +describe('copyPromoteUnmappedResources - files + folder content-refs', () => { + const tx = {} as DbOrTx + // Only edge.childWorkspaceId is read by the copy path. + const edge = { childWorkspaceId: 'edge-child' } as unknown as ForkEdge + + beforeEach(() => { + vi.clearAllMocks() + mockCopyForkResourceContainers.mockResolvedValue({ + idMap: new Map(), + mappingEntries: [], + contentPlan: { + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'target-ws', + userId: 'user-1', + tables: [], + knowledgeBases: [], + skills: [], + documents: [], + }, + names: { + tables: [], + knowledgeBases: [], + customTools: [], + skills: [], + workflowMcpServers: [], + }, + }) + mockPlanForkMappedKbDocumentCopies.mockResolvedValue({ + documents: [], + docIdMap: new Map(), + mappingEntries: [], + }) + }) + + it('copies selected files (keyMap + blobTasks), persists the file mapping, and threads file + folder content-ref maps', async () => { + mockPlanForkFileCopies.mockResolvedValue({ + keyMap: new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]), + idMap: new Map([['file-src', 'file-dst']]), + blobTasks: [ + { + sourceKey: 'workspace/SRC/a.png', + targetKey: 'workspace/DST/a.png', + context: 'workspace', + fileName: 'a.png', + contentType: 'image/png', + userId: 'user-1', + workspaceId: 'target-ws', + }, + ], + }) + + const result = await copyPromoteUnmappedResources({ + tx, + edge, + sourceWorkspaceId: 'src-ws', + targetWorkspaceId: 'target-ws', + direction: 'pull', + userId: 'user-1', + now: new Date(), + selection: { + customTools: [], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: [], + files: ['workspace/SRC/a.png'], + }, + workflowIdMap: new Map(), + folderIdMap: new Map([['fld-src', 'fld-dst']]), + resolver: () => null, + referencedDocumentIds: [], + }) + + // planForkFileCopies is invoked by storage key (the sync references key files by key). + expect(mockPlanForkFileCopies).toHaveBeenCalledWith( + expect.objectContaining({ fileKeys: ['workspace/SRC/a.png'] }) + ) + // blobTasks bubble up for the post-commit blob duplication. + expect(result.blobTasks).toHaveLength(1) + // The copied file resolves by storage key for the subblock remap. + expect(result.copyIdMapByKind.get('file')).toEqual( + new Map([['workspace/SRC/a.png', 'workspace/DST/a.png']]) + ) + // The file mapping is persisted (pull keeps source(parent)->target(child) orientation) so a + // re-sync resolves the copy instead of re-copying it. + expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [ + { + resourceType: 'file', + parentResourceId: 'workspace/SRC/a.png', + childResourceId: 'workspace/DST/a.png', + }, + ]) + // The folder map AND the file key/id maps reach the in-content rewriter. + expect(result.contentRefMaps.folders).toEqual({ 'fld-src': 'fld-dst' }) + expect(result.contentRefMaps.fileKeys).toEqual({ 'workspace/SRC/a.png': 'workspace/DST/a.png' }) + expect(result.contentRefMaps.fileIds).toEqual({ 'file-src': 'file-dst' }) + }) + + it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => { + await copyPromoteUnmappedResources({ + tx, + edge, + sourceWorkspaceId: 'src-ws', + targetWorkspaceId: 'target-ws', + direction: 'pull', + userId: 'user-1', + now: new Date(), + selection: { + customTools: [], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: ['kb-1'], + files: [], + }, + workflowIdMap: new Map(), + folderIdMap: new Map(), + resolver: () => null, + // The doc ids come straight from the promote plan's references; the copy must forward them, + // not re-scan every source workflow state inside the locked tx. + referencedDocumentIds: ['doc-1', 'doc-2'], + }) + + expect(mockCopyForkResourceContainers).toHaveBeenCalledWith( + expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] }) + ) + expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith( + expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] }) + ) + }) +}) + +describe('fork copyable kind drift', () => { + it('FORK_COPYABLE_KIND_TO_SELECTION_KEY covers exactly the contract copyable kinds', () => { + expect(Object.keys(FORK_COPYABLE_KIND_TO_SELECTION_KEY).sort()).toEqual( + [...forkCopyableKindSchema.options].sort() + ) + }) + + it('isForkCopyableKind matches the contract copyable kinds and excludes the rest', () => { + for (const kind of forkCopyableKindSchema.options) { + expect(isForkCopyableKind(kind)).toBe(true) + } + const nonCopyable: ForkRemapKind[] = [ + 'credential', + 'env-var', + 'knowledge-document', + 'mcp-server', + ] + for (const kind of nonCopyable) { + expect(isForkCopyableKind(kind)).toBe(false) + } + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts new file mode 100644 index 00000000000..f0080b1d46c --- /dev/null +++ b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts @@ -0,0 +1,335 @@ +import type { + ForkCopyableKind, + ForkCopyableUnmapped, + PromoteCopyResources, +} from '@/lib/api/contracts/workspace-fork' +import type { DbOrTx } from '@/lib/db/types' +import { + type SerializableForkContentRefMaps, + serializeContentRefMaps, +} from '@/lib/workspaces/fork/copy/content-copy-runner' +import { type BlobCopyTask, planForkFileCopies } from '@/lib/workspaces/fork/copy/copy-files' +import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources' +import { + copyForkResourceContainers, + planForkMappedKbDocumentCopies, +} from '@/lib/workspaces/fork/copy/copy-resources' +import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage' +import { + deleteEdgeMappingsByChildResources, + type ForkMappingUpsert, + resourceTypeToForkKind, + upsertEdgeMappings, +} from '@/lib/workspaces/fork/mapping/mapping-store' +import type { + ForkReferenceResolver, + ForkRemapKind, +} from '@/lib/workspaces/fork/remap/remap-references' + +/** The source ids selected for copy at promote, validated against the plan's copyable candidates. */ +export interface PromoteCopySelection { + customTools: string[] + skills: string[] + workflowMcpServers: string[] + tables: string[] + knowledgeBases: string[] + /** Workspace files to copy, identified by storage key (not `workspace_files.id`). */ + files: string[] +} + +/** + * Each copyable kind to its key in {@link PromoteCopySelection}. Keyed on `ForkCopyableKind` + * (the wire contract enum) so TS fails to compile if the copyable enum grows a kind without a + * selection key here, keeping the two in lockstep. + */ +export const FORK_COPYABLE_KIND_TO_SELECTION_KEY: Record< + ForkCopyableKind, + keyof PromoteCopySelection +> = { + 'knowledge-base': 'knowledgeBases', + table: 'tables', + 'custom-tool': 'customTools', + skill: 'skills', + file: 'files', +} + +/** + * Intersect the user's requested copy with the plan's actual copyable candidates, so a sync can + * only copy resources that are genuinely referenced-but-unmapped + still exist in the source (a + * crafted request can never copy an arbitrary resource). Returns the validated selection plus the + * set of `${kind}:${sourceId}` references the copy will resolve, for the pre-copy sync gate. + */ +export function buildPromoteCopySelection( + requested: PromoteCopyResources | undefined, + copyableUnmapped: ForkCopyableUnmapped[] +): { selection: PromoteCopySelection; willResolve: Set } { + const allowed = new Map>() + for (const candidate of copyableUnmapped) { + const set = allowed.get(candidate.kind) + if (set) set.add(candidate.sourceId) + else allowed.set(candidate.kind, new Set([candidate.sourceId])) + } + const selection: PromoteCopySelection = { + customTools: [], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: [], + files: [], + } + const willResolve = new Set() + const apply = ( + kind: keyof typeof FORK_COPYABLE_KIND_TO_SELECTION_KEY, + ids: string[] | undefined + ) => { + const allowedIds = allowed.get(kind) + if (!allowedIds || !ids) return + const key = FORK_COPYABLE_KIND_TO_SELECTION_KEY[kind] + for (const id of ids) { + if (!allowedIds.has(id)) continue + selection[key].push(id) + willResolve.add(`${kind}:${id}`) + } + } + apply('knowledge-base', requested?.knowledgeBases) + apply('table', requested?.tables) + apply('custom-tool', requested?.customTools) + apply('skill', requested?.skills) + apply('file', requested?.files) + return { selection, willResolve } +} + +/** Whether any resource is selected for copy. */ +export function hasPromoteCopySelection(selection: PromoteCopySelection): boolean { + return ( + selection.customTools.length > 0 || + selection.skills.length > 0 || + selection.tables.length > 0 || + selection.knowledgeBases.length > 0 || + selection.files.length > 0 + ) +} + +/** + * Layer the just-copied resources' source->target ids on top of the plan's resolver, so the + * synced workflows' references to those resources resolve to the new copies. The base resolver + * (persisted mappings + env identity) is consulted for everything else. + */ +export function augmentForkResolver( + base: ForkReferenceResolver, + extra: Map> +): ForkReferenceResolver { + return (kind, sourceId) => extra.get(kind)?.get(sourceId) ?? base(kind, sourceId) +} + +export interface PromoteCopyResult { + contentPlan: ForkContentPlan + /** Copied source resource id -> new target id, by remap kind, for resolver augmentation. */ + copyIdMapByKind: Map> + /** + * Serialized in-content reference maps for the post-commit copy to rewrite copied skill bodies + * (their `sim:` links + embedded URLs) off the locked promote tx - carried through the durable + * content-copy payload, mirroring fork. + */ + contentRefMaps: SerializableForkContentRefMaps + /** + * File blob duplications for copied workspace files, run post-commit by the durable content-copy + * runner (no object-storage I/O inside the locked promote tx). Empty when no files were copied. + */ + blobTasks: BlobCopyTask[] +} + +/** + * Copy the referenced-but-unmapped resources a sync brings into the target (reusing the fork copy + * pipeline), then persist the source<->target id map in the direction the edge expects: a pull + * fills the existing `(parent, child=null)` row (fill-null), a push replaces any prior + * `(parent, child)` row keyed on the source child resource (delete-then-insert). This covers: + * - the user-selected copyable containers (KB / table / custom-tool / skill) and workspace files, + * - documents referenced under a copied knowledge base (auto-placed under that copied KB), + * - documents referenced under an ALREADY-mapped (existing) KB - copied into that existing KB so + * the `document-selector` reference remaps instead of being cleared. + * + * The heavy content (table rows, KB documents + embeddings, file blobs) is returned as a content + * plan + blob tasks for a post-commit, best-effort fill; no object-storage I/O runs inside the + * locked promote tx. Always safe to call (a no-op when nothing is selected and nothing references + * a mapped-KB document). + */ +export async function copyPromoteUnmappedResources(params: { + tx: DbOrTx + edge: ForkEdge + sourceWorkspaceId: string + targetWorkspaceId: string + direction: 'push' | 'pull' + userId: string + now: Date + selection: PromoteCopySelection + workflowIdMap: Map + /** source folder id -> target folder id, so copied skill/markdown bodies rewrite `sim:folder/`. */ + folderIdMap: Map + /** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */ + resolver: ForkReferenceResolver + /** + * Knowledge-document ids the synced workflows reference, already scanned once in the promote + * plan and threaded in so the copy doesn't re-scan every source state inside the locked tx. + * `copyForkResourceContainers` / `planForkMappedKbDocumentCopies` place only those whose parent + * KB is in this copy (or already mapped), so an extra id is FK-safe and simply skipped. + */ + referencedDocumentIds: string[] +}): Promise { + const { + tx, + edge, + sourceWorkspaceId, + targetWorkspaceId, + direction, + userId, + now, + selection, + workflowIdMap, + folderIdMap, + resolver, + referencedDocumentIds, + } = params + + const result = await copyForkResourceContainers({ + tx, + sourceWorkspaceId, + childWorkspaceId: targetWorkspaceId, + userId, + now, + selection: { + customTools: selection.customTools, + skills: selection.skills, + workflowMcpServers: selection.workflowMcpServers, + tables: selection.tables, + knowledgeBases: selection.knowledgeBases, + }, + workflowIdMap, + referencedDocumentIds, + // A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs + // rewritten through the same plan resolver that remaps subblock-value env refs. + resolveEnvName: (key) => resolver('env-var', key), + }) + + // Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob + // duplications deferred to the post-commit runner. + const fileResult = + selection.files.length > 0 + ? await planForkFileCopies({ + tx, + sourceWorkspaceId, + childWorkspaceId: targetWorkspaceId, + userId, + fileKeys: selection.files, + now, + }) + : { + keyMap: new Map(), + idMap: new Map(), + blobTasks: [] as BlobCopyTask[], + } + + // U-docs: documents referenced under an already-mapped (not copied this sync) KB. Skip any doc + // already placed under a copied KB above (its parent KB is in this copy), so a doc is never + // copied twice. + const containerDocMap = result.idMap.get('knowledge_document') ?? new Map() + const mappedKbDocs = await planForkMappedKbDocumentCopies({ + tx, + resolver, + referencedDocumentIds, + alreadyCopiedSourceDocIds: new Set(containerDocMap.keys()), + }) + result.contentPlan.documents.push(...mappedKbDocs.documents) + + // Persist every copied resource's mapping (containers + files + U-docs) so a re-sync resolves + // the copy instead of re-copying it. Files map by storage key; U-docs add knowledge_document rows. + const fileMappingEntries: ForkMappingUpsert[] = Array.from( + fileResult.keyMap, + ([source, child]) => ({ + resourceType: 'file' as const, + parentResourceId: source, + childResourceId: child, + }) + ) + await persistPromoteCopiedMappings(tx, edge.childWorkspaceId, userId, direction, [ + ...result.mappingEntries, + ...fileMappingEntries, + ...mappedKbDocs.mappingEntries, + ]) + + const copyIdMapByKind = new Map>() + for (const [resourceType, sourceToTarget] of result.idMap) { + const kind = resourceTypeToForkKind(resourceType) + if (!kind) continue + copyIdMapByKind.set(kind, sourceToTarget) + } + if (fileResult.keyMap.size > 0) copyIdMapByKind.set('file', fileResult.keyMap) + + // Merge the container's copied-KB document map with the U-docs map so every copied document + // (under a copied KB or into an existing one) remaps its `document-selector` reference. + const documentIdMap = new Map([...containerDocMap, ...mappedKbDocs.docIdMap]) + if (documentIdMap.size > 0) copyIdMapByKind.set('knowledge-document', documentIdMap) + + // Serialized maps for the post-commit content rewrite (run off the locked promote tx). Mirrors + // fork: workspace + workflow + folder ids plus this copy's own file/skill/table/KB maps, so a + // copied skill body / markdown blob's `sim:` links + embedded file URLs resolve to the new target + // copies instead of the source. + const contentRefMaps = serializeContentRefMaps({ + workspaceId: { from: sourceWorkspaceId, to: targetWorkspaceId }, + workflows: workflowIdMap, + folders: folderIdMap, + fileKeys: fileResult.keyMap, + fileIds: fileResult.idMap, + skills: result.idMap.get('skill'), + tables: result.idMap.get('table'), + knowledgeBases: result.idMap.get('knowledge_base'), + }) + + return { + contentPlan: result.contentPlan, + copyIdMapByKind, + contentRefMaps, + blobTasks: fileResult.blobTasks, + } +} + +/** + * Persist the copied resources' id mappings for the edge. The copy returns entries oriented + * source(parent)->target(child); a pull matches that orientation directly (fill-null upsert), a + * push swaps it (the parent side is the new TARGET) and first drops any prior row keyed on the + * source child resource so a changed target can't leak a second mapping. + */ +export async function persistPromoteCopiedMappings( + tx: DbOrTx, + childWorkspaceId: string, + userId: string, + direction: 'push' | 'pull', + entries: ForkMappingUpsert[] +): Promise { + if (entries.length === 0) return + if (direction === 'pull') { + await upsertEdgeMappings(tx, childWorkspaceId, userId, entries) + return + } + // Push: re-key on the source child resource. Skip any entry with a null child id (copy entries + // always carry one; the guard narrows the type so neither the swap nor the delete needs a cast). + // After the swap every childResourceId is the original (non-null) parent id, keyed for the + // delete-then-insert that prevents a changed target from leaking a second mapping. + const swapped: ForkMappingUpsert[] = [] + const deleteKeys: Array<{ + resourceType: ForkMappingUpsert['resourceType'] + childResourceId: string + }> = [] + for (const entry of entries) { + if (entry.childResourceId == null) continue + swapped.push({ + resourceType: entry.resourceType, + parentResourceId: entry.childResourceId, + childResourceId: entry.parentResourceId, + }) + deleteKeys.push({ resourceType: entry.resourceType, childResourceId: entry.parentResourceId }) + } + if (swapped.length === 0) return + await deleteEdgeMappingsByChildResources(tx, childWorkspaceId, deleteKeys) + await upsertEdgeMappings(tx, childWorkspaceId, userId, swapped) +} diff --git a/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts b/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts index d6e49f2c23d..bf3d07b1791 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts @@ -2,7 +2,20 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { buildPromoteWorkflowIdMap } from '@/lib/workspaces/fork/promote/promote-plan' +import type { ForkCopyableLabel } from '@/lib/workspaces/fork/mapping/resources' +import { + assembleForkCopyableUnmapped, + buildPromoteWorkflowIdMap, + collectForkCopyableIdsByKind, +} from '@/lib/workspaces/fork/promote/promote-plan' +import type { ForkReference } from '@/lib/workspaces/fork/remap/remap-references' + +const ref = (kind: ForkReference['kind'], sourceId: string): ForkReference => ({ + kind, + sourceId, + subBlockKey: 'sb', + required: false, +}) /** * `buildPromoteWorkflowIdMap` decides which cross-workflow references survive a @@ -80,3 +93,62 @@ describe('buildPromoteWorkflowIdMap', () => { expect(map.get('s')).toBe('t-new') }) }) + +describe('collectForkCopyableIdsByKind', () => { + it('groups copyable kinds and ignores non-copyable kinds (credential / env-var)', () => { + const byKind = collectForkCopyableIdsByKind([ + ref('knowledge-base', 'kb-1'), + ref('knowledge-base', 'kb-2'), + ref('table', 'tbl-1'), + ref('credential', 'cred-1'), + ref('env-var', 'API_KEY'), + ref('custom-tool', 'ct-1'), + ref('skill', 'sk-1'), + ref('file', 'fk-1'), + ]) + expect(byKind).toEqual({ + 'knowledge-base': ['kb-1', 'kb-2'], + table: ['tbl-1'], + 'custom-tool': ['ct-1'], + skill: ['sk-1'], + file: ['fk-1'], + }) + }) +}) + +describe('assembleForkCopyableUnmapped', () => { + const flat = (label: string): ForkCopyableLabel => ({ label, parentId: null, parentLabel: null }) + + it('emits a candidate per copyable ref whose label resolved, carrying its labels', () => { + const labels = new Map([ + ['knowledge-base:kb-1', flat('Docs KB')], + ['file:fk-1', { label: 'a.png', parentId: 'fld-1', parentLabel: 'Folder' }], + ]) + const result = assembleForkCopyableUnmapped( + [ref('knowledge-base', 'kb-1'), ref('file', 'fk-1'), ref('credential', 'cred-1')], + labels + ) + expect(result).toEqual([ + { + kind: 'knowledge-base', + sourceId: 'kb-1', + label: 'Docs KB', + parentId: null, + parentLabel: null, + }, + { kind: 'file', sourceId: 'fk-1', label: 'a.png', parentId: 'fld-1', parentLabel: 'Folder' }, + ]) + }) + + it('drops a copyable ref whose label is missing (no longer exists in the source)', () => { + expect(assembleForkCopyableUnmapped([ref('knowledge-base', 'kb-gone')], new Map())).toEqual([]) + }) + + it('ignores non-copyable kinds entirely (credential / env-var)', () => { + const result = assembleForkCopyableUnmapped( + [ref('credential', 'cred-1'), ref('env-var', 'API_KEY')], + new Map([['credential:cred-1', flat('X')]]) + ) + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/promote-plan.ts b/apps/sim/lib/workspaces/fork/promote/promote-plan.ts index e0dde4eb7db..e4d271b736a 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote-plan.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote-plan.ts @@ -1,6 +1,7 @@ import { workflow } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' +import { type ForkCopyableKind, forkCopyableKindSchema } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import type { DeployedWorkflowSummary } from '@/lib/workspaces/fork/copy/deploy-bridge' import type { ForkEdge } from '@/lib/workspaces/fork/lineage/lineage' @@ -11,9 +12,12 @@ import { resourceTypeToForkKind, } from '@/lib/workspaces/fork/mapping/mapping-store' import { + type ForkCopyableLabel, filterExistingForkTargets, getWorkspaceEnvKeys, + loadForkCopyableResourceLabels, } from '@/lib/workspaces/fork/mapping/resources' +import { toScannerBlocks } from '@/lib/workspaces/fork/remap/reference-scan' import { type ForkReference, type ForkReferenceResolver, @@ -56,11 +60,35 @@ export interface ForkPromotePlan { mcpReauthServerIds: string[] /** Review-only descriptions of inline secrets that cannot be id-mapped. */ inlineSecretSources: string[] + /** + * Referenced-but-unmapped resources of copyable kinds that still exist in the source, so a + * sync can copy them into the target instead of requiring a manual mapping (U15). Documents + * are auto-copied with their parent KB and are not listed here. `parentId`/`parentLabel` carry + * a file's folder grouping (null for non-file kinds and root files), for the nested picker. + */ + copyableUnmapped: Array<{ + kind: ForkCopyableKind + sourceId: string + label: string + parentId: string | null + parentLabel: string | null + }> willUpdate: number willCreate: number willArchive: number } +/** + * Copyable promote kinds, derived from the wire contract (`forkCopyableKindSchema`) so this + * guard can never drift from the single source of truth: growing the schema automatically + * grows the set. Typed as `ForkRemapKind` so `.has` accepts a broad scan-reference kind. + */ +const COPYABLE_PROMOTE_KINDS = new Set(forkCopyableKindSchema.options) + +export function isForkCopyableKind(kind: ForkRemapKind): kind is ForkCopyableKind { + return COPYABLE_PROMOTE_KINDS.has(kind) +} + /** * Build the cross-workflow reference map used to rewrite `workflow-selector`, * `manualWorkflowId`, and `workflow_input` references inside promoted workflows. @@ -92,6 +120,48 @@ export function buildPromoteWorkflowIdMap(params: { return workflowIdMap } +/** + * Collect the source ids of referenced-but-unmapped copyable resources, grouped by kind - the input + * to the source-label lookup that builds {@link ForkPromotePlan.copyableUnmapped}. Pure. + */ +export function collectForkCopyableIdsByKind( + unmappedReferences: ForkReference[] +): Partial> { + const byKind: Partial> = {} + for (const reference of unmappedReferences) { + if (!isForkCopyableKind(reference.kind)) continue + ;(byKind[reference.kind] ??= []).push(reference.sourceId) + } + return byKind +} + +/** + * Assemble {@link ForkPromotePlan.copyableUnmapped} from the unmapped references and the loaded + * source labels: each copyable reference whose label resolved becomes a copy candidate; one whose + * label is missing (the resource no longer exists in the source) is dropped. Pure - split from the + * DB label load so it is unit-testable. + */ +export function assembleForkCopyableUnmapped( + unmappedReferences: ForkReference[], + copyableLabels: Map +): ForkPromotePlan['copyableUnmapped'] { + return unmappedReferences.flatMap((reference) => { + if (!isForkCopyableKind(reference.kind)) return [] + const entry = copyableLabels.get(`${reference.kind}:${reference.sourceId}`) + return entry + ? [ + { + kind: reference.kind, + sourceId: reference.sourceId, + label: entry.label, + parentId: entry.parentId, + parentLabel: entry.parentLabel, + }, + ] + : [] + }) +} + /** * Compute everything a promote needs without mutating. Only the source's * **deployed** workflows participate; each plan item carries the source's active @@ -208,12 +278,8 @@ export async function computeForkPromotePlan(params: { }, }) - const blocks = Object.values(sourceState.blocks).map((block) => ({ - id: block.id, - name: block.name, - subBlocks: block.subBlocks as unknown, - })) - for (const reference of scanWorkflowReferences(blocks, resolver).references) { + for (const reference of scanWorkflowReferences(toScannerBlocks(sourceState), resolver) + .references) { referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference) } } @@ -257,6 +323,15 @@ export async function computeForkPromotePlan(params: { const unmappedRequired = allUnmapped.filter((reference) => reference.required) const unmappedOptional = allUnmapped.filter((reference) => !reference.required) + // Referenced-but-unmapped resources of copyable kinds that still exist in the source, so the + // sync modal can offer to copy them into the target (fork-style) instead of mapping by hand. + const copyableLabels = await loadForkCopyableResourceLabels( + executor, + sourceWorkspaceId, + collectForkCopyableIdsByKind(allUnmapped) + ) + const copyableUnmapped = assembleForkCopyableUnmapped(allUnmapped, copyableLabels) + const willUpdate = items.filter((i) => i.mode === 'replace').length const willCreate = items.filter((i) => i.mode === 'create').length @@ -275,6 +350,7 @@ export async function computeForkPromotePlan(params: { unmappedOptional, mcpReauthServerIds: cascade.mcpReauthServerIds, inlineSecretSources: cascade.inlineSecretSources, + copyableUnmapped, willUpdate, willCreate, willArchive: archivedTargetIds.length, diff --git a/apps/sim/lib/workspaces/fork/promote/promote.ts b/apps/sim/lib/workspaces/fork/promote/promote.ts index 9694f279bf5..ad6e5d792c6 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' +import type { PromoteCopyResources } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import { enqueueWorkflowUndeploySideEffects, @@ -11,6 +12,15 @@ import { } from '@/lib/workflows/deployment-outbox' import { performFullDeploy } from '@/lib/workflows/orchestration/deploy' import { undeployWorkflow } from '@/lib/workflows/persistence/utils' +import { startBackgroundWork } from '@/lib/workspaces/fork/background-work/store' +import { + type ForkContentCopyPayload, + hasForkContentToCopy, + type SerializableForkContentRefMaps, + scheduleForkContentCopy, +} from '@/lib/workspaces/fork/copy/content-copy-runner' +import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files' +import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources' import { copyWorkflowStateIntoTarget, loadTargetDraftSubBlocks, @@ -43,6 +53,12 @@ import { type ForkMappingUpsert, upsertEdgeMappings, } from '@/lib/workspaces/fork/mapping/mapping-store' +import { + augmentForkResolver, + buildPromoteCopySelection, + copyPromoteUnmappedResources, + hasPromoteCopySelection, +} from '@/lib/workspaces/fork/promote/copy-unmapped' import { computeForkPromotePlan, type ForkPromotePlan, @@ -82,6 +98,12 @@ export interface PromoteForkParams { subBlockKey: string value: string }> + /** + * Referenced-but-unmapped resources (by source id) the caller chose to copy into the target + * before the sync gate. Validated against the plan's copyable candidates, so an arbitrary id is + * ignored. Each copied resource's references then resolve to the new copy instead of blocking. + */ + copyResources?: PromoteCopyResources requestId?: string } @@ -264,6 +286,12 @@ interface PromoteTxApplied { needsConfiguration: Array<{ workflowId: string; workflowName: string; blocks: string[] }> /** Per-workflow optional dependents a parent change cleared (surfaced, not gated). */ clearedOptional: Array<{ workflowName: string; blocks: string[] }> + /** Heavy content for resources copied into the target this sync, filled best-effort post-commit. */ + copyContentPlan: ForkContentPlan | null + /** Serialized in-content maps for the post-commit skill-body rewrite (paired with the plan). */ + copyContentRefMaps: SerializableForkContentRefMaps | null + /** File blob duplications for copied workspace files, run post-commit by the content-copy runner. */ + copyContentBlobTasks: BlobCopyTask[] } /** @@ -352,10 +380,25 @@ export async function promoteFork(params: PromoteForkParams): Promise 0) { + const now = new Date() + + // Copy the selected referenced-but-unmapped resources into the target BEFORE the gate, so a + // user can copy rather than map each one. The gate is evaluated against the post-copy state + // (the copy resolves the selected refs), so the copy only runs when the sync will actually + // proceed - if required refs remain unmapped, we block without copying anything. + const { selection: copySelection, willResolve } = buildPromoteCopySelection( + params.copyResources, + plan.copyableUnmapped + ) + // plan.unmappedRequired is already references.filter(resolver == null).filter(required), so + // subtracting the refs the copy will resolve is equivalent to re-scanning the predicate. + const postCopyUnmappedRequired = plan.unmappedRequired.filter( + (reference) => !willResolve.has(`${reference.kind}:${reference.sourceId}`) + ) + if (postCopyUnmappedRequired.length > 0) { return { blocked: 'unmapped', - unmappedRequired: plan.unmappedRequired.map((reference) => ({ + unmappedRequired: postCopyUnmappedRequired.map((reference) => ({ kind: reference.kind, sourceId: reference.sourceId, required: reference.required, @@ -364,8 +407,10 @@ export async function promoteFork(params: PromoteForkParams): Promisetarget folder map BEFORE the copy so the folders already exist in the + // target and the copy can rewrite `sim:folder/` references inside copied skill / markdown + // bodies (the post-commit content rewrite reads this map). Idempotent: it reuses target + // folders that already match by name within the same mapped parent. const folderIdMap = await resolveForkFolderMapping({ tx, sourceWorkspaceId, @@ -374,6 +419,42 @@ export async function promoteFork(params: PromoteForkParams): Promise reference.kind === 'knowledge-document') + .map((reference) => reference.sourceId) + // Run the copy when the user selected resources to copy OR any document is referenced (a + // referenced document under an already-mapped KB is auto-copied into that KB so its reference + // remaps instead of clearing). It runs only after the required-reference gate above, so a + // blocked sync copies nothing. + if (hasPromoteCopySelection(copySelection) || referencedDocumentIds.length > 0) { + const copyResult = await copyPromoteUnmappedResources({ + tx, + edge, + sourceWorkspaceId, + targetWorkspaceId, + direction, + userId, + now, + selection: copySelection, + workflowIdMap: plan.workflowIdMap, + folderIdMap, + resolver: plan.resolver, + referencedDocumentIds, + }) + resolver = augmentForkResolver(plan.resolver, copyResult.copyIdMapByKind) + copyContentPlan = copyResult.contentPlan + copyContentRefMaps = copyResult.contentRefMaps + copyContentBlobTasks = copyResult.blobTasks + } + + const transform = createForkSubBlockTransform(resolver) + // Batch every prior-version read (replace + archive targets) into one query before any // write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so // they still reflect the active version each target had before this sync. @@ -634,6 +715,9 @@ export async function promoteFork(params: PromoteForkParams): Promise Activity the user is + // viewing (the one the sync was initiated from), matching where the route records the sync. + const activityWorkspaceId = direction === 'push' ? sourceWorkspaceId : targetWorkspaceId + // The sync already committed; failing to record the tracking row must not turn it into a 500. + // The runner no-ops its status updates when statusId is absent, so the copy still runs. + let statusId: string | undefined + try { + statusId = await startBackgroundWork(db, { + workspaceId: activityWorkspaceId, + kind: 'fork_content_copy', + // Append-only: each sync's content fill is a distinct entry in the Activity history. + supersede: false, + message: 'Copying synced resources', + metadata: { + tables: copyContentPlan.tables.length, + knowledgeBases: copyContentPlan.knowledgeBases.length, + files: copyBlobTasks.length, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Failed to record sync content-copy status`, { + targetWorkspaceId, + error: getErrorMessage(error), + }) + } + + const payload: ForkContentCopyPayload = { + contentPlan: copyContentPlan, + blobTasks: copyBlobTasks, + contentRefMaps: txResult.copyContentRefMaps ?? undefined, + statusId, + // The targets this sync wrote and deployed above, so a failed content fill can sweep the + // dropped placeholder from their DEPLOYED version states too, not just drafts. + deployedTargetWorkflowIds: txResult.deployTargetIds, + requestId, + } + await scheduleForkContentCopy(payload, { detachedLabel: 'fork-sync-content-copy', requestId }) + } + if (deployFailures.length > 0) { logger.warn(`[${requestId}] Promote wrote state but some targets failed to deploy`, { sourceWorkspaceId, diff --git a/apps/sim/lib/workspaces/fork/remap/fork-bootstrap.ts b/apps/sim/lib/workspaces/fork/remap/fork-bootstrap.ts index a913ada3143..2e4bdb0f124 100644 --- a/apps/sim/lib/workspaces/fork/remap/fork-bootstrap.ts +++ b/apps/sim/lib/workspaces/fork/remap/fork-bootstrap.ts @@ -1,4 +1,5 @@ import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' import { clearDependentsOnRemap, type ForkRemapKind, @@ -22,9 +23,13 @@ export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string */ export function createForkBootstrapTransform( resolveCopied: ForkCopyResolver -): (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord { - return (subBlocks, blockType) => { +): ( + subBlocks: SubBlockRecord, + blockType: string, + canonicalModes?: CanonicalModeOverrides +) => SubBlockRecord { + return (subBlocks, blockType, canonicalModes) => { const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create') - return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys) + return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys, canonicalModes) } } diff --git a/apps/sim/lib/workspaces/fork/remap/reference-scan.ts b/apps/sim/lib/workspaces/fork/remap/reference-scan.ts new file mode 100644 index 00000000000..c0085f59fc3 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/remap/reference-scan.ts @@ -0,0 +1,67 @@ +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' +import { + type ForkRemapKind, + scanWorkflowReferences, +} from '@/lib/workspaces/fork/remap/remap-references' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** A block reduced to what the reference scanner reads (incl. canonical context for detection). */ +interface ScannerBlock { + id: string + name: string + type: string + subBlocks: unknown + canonicalModes?: CanonicalModeOverrides +} + +/** + * Unique source ids of one remap kind referenced across a set of blocks - both top-level + * selectors and nested tool params. Used at fork/promote time to decide which KB documents + * a copy must create placeholders for (so their references remap to the copied doc instead + * of being cleared). The resolver is irrelevant here (every reference is detected regardless + * of mapping), so a null resolver is passed. + */ +export function collectReferencedResourceIds( + blocks: ScannerBlock[], + kind: ForkRemapKind +): Set { + const ids = new Set() + for (const reference of scanWorkflowReferences(blocks, () => null).references) { + if (reference.kind === kind) ids.add(reference.sourceId) + } + return ids +} + +/** + * Map a workflow state's blocks to the `{ id, name, subBlocks }` shape the reference scanner + * consumes. Confines the `subBlocks as unknown` widening (the stored subblock record is opaque + * to the scanner, which re-narrows per subblock type) to one spot shared by every fork caller + * that scans a source workflow's references. + */ +export function toScannerBlocks(state: WorkflowState): ScannerBlock[] { + return Object.values(state.blocks).map((block) => ({ + id: block.id, + name: block.name, + type: block.type, + subBlocks: block.subBlocks as unknown, + canonicalModes: block.data?.canonicalModes, + })) +} + +/** + * Unique knowledge-document ids referenced across a set of source workflow states. Fork and + * promote use this to decide which KB documents a copy must pre-create placeholders for, so a + * `document-selector` reference remaps to the copied doc instead of being cleared. + */ +export function collectReferencedDocumentIds(states: Iterable): Set { + const ids = new Set() + for (const state of states) { + for (const docId of collectReferencedResourceIds( + toScannerBlocks(state), + 'knowledge-document' + )) { + ids.add(docId) + } + } + return ids +} diff --git a/apps/sim/lib/workspaces/fork/remap/remap-content-refs.test.ts b/apps/sim/lib/workspaces/fork/remap/remap-content-refs.test.ts new file mode 100644 index 00000000000..a6acca88bf7 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/remap/remap-content-refs.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type ForkContentRefMaps, + rewriteForkContentRefs, + rewriteForkResourceUrls, +} from '@/lib/workspaces/fork/remap/remap-content-refs' + +const SRC_KEY = 'workspace/SRC/1700000000000-deadbeef-photo.png' +const DST_KEY = 'workspace/DST/1700000000001-cafebabe-photo.png' + +const maps = (): ForkContentRefMaps => ({ + workspaceId: { from: 'SRC', to: 'DST' }, + fileKeys: new Map([[SRC_KEY, DST_KEY]]), + fileIds: new Map([['file-src', 'file-dst']]), + workflows: new Map([['wf-src', 'wf-dst']]), + knowledgeBases: new Map([['kb-src', 'kb-dst']]), + tables: new Map([['tbl-src', 'tbl-dst']]), + skills: new Map([['skill-src', 'skill-dst']]), + folders: new Map([['fld-src', 'fld-dst']]), +}) + +describe('rewriteForkContentRefs - sim: links', () => { + it('remaps each mapped sim: link kind by its id map', () => { + const input = [ + 'see [F](sim:file/file-src)', + '[W](sim:workflow/wf-src)', + '[K](sim:knowledge/kb-src)', + '[T](sim:table/tbl-src)', + '[S](sim:skill/skill-src)', + '[D](sim:folder/fld-src)', + ].join(' ') + expect(rewriteForkContentRefs(input, maps())).toBe( + [ + 'see [F](sim:file/file-dst)', + '[W](sim:workflow/wf-dst)', + '[K](sim:knowledge/kb-dst)', + '[T](sim:table/tbl-dst)', + '[S](sim:skill/skill-dst)', + '[D](sim:folder/fld-dst)', + ].join(' ') + ) + }) + + it('leaves an unmapped id unchanged (graceful broken link)', () => { + const input = '[F](sim:file/unknown-file) and [I](sim:integration/gmail_v2)' + expect(rewriteForkContentRefs(input, maps())).toBe(input) + }) + + it('leaves a kind with no supplied map unchanged', () => { + const input = '[W](sim:workflow/wf-src)' + expect(rewriteForkContentRefs(input, { fileIds: new Map() })).toBe(input) + }) +}) + +describe('rewriteForkContentRefs - embedded urls', () => { + it('remaps a serve-url storage key (encoded form output)', () => { + const input = `![a](/api/files/serve/${encodeURIComponent(SRC_KEY)}?context=workspace)` + expect(rewriteForkContentRefs(input, maps())).toBe( + `![a](/api/files/serve/${encodeURIComponent(DST_KEY)}?context=workspace)` + ) + }) + + it('remaps a serve-url key given in raw (unencoded) form', () => { + const input = `![a](/api/files/serve/${SRC_KEY})` + expect(rewriteForkContentRefs(input, maps())).toBe( + `![a](/api/files/serve/${encodeURIComponent(DST_KEY)})` + ) + }) + + it('remaps an s3/blob-prefixed serve url, preserving the prefix', () => { + const input = `![a](/api/files/serve/s3/${encodeURIComponent(SRC_KEY)})` + expect(rewriteForkContentRefs(input, maps())).toBe( + `![a](/api/files/serve/s3/${encodeURIComponent(DST_KEY)})` + ) + }) + + it('remaps a view-url file id', () => { + const input = '![a](/api/files/view/file-src)' + expect(rewriteForkContentRefs(input, maps())).toBe('![a](/api/files/view/file-dst)') + }) + + it('remaps both the workspace id and file id in an in-app files path', () => { + const input = '![a](/workspace/SRC/files/file-src)' + expect(rewriteForkContentRefs(input, maps())).toBe('![a](/workspace/DST/files/file-dst)') + }) + + it('leaves a foreign-workspace in-app file path unchanged (both-or-nothing)', () => { + // The ws id is not the mapped source, so emitting the child file id under OTHER would 404. + const input = '![a](/workspace/OTHER/files/file-src)' + expect(rewriteForkContentRefs(input, maps())).toBe(input) + }) + + it('leaves an in-app file path unchanged when the file id is unmapped (both-or-nothing)', () => { + const input = '![a](/workspace/SRC/files/unknown-file)' + expect(rewriteForkContentRefs(input, maps())).toBe(input) + }) + + it('leaves an unmapped storage key / file id unchanged', () => { + const input = + '![a](/api/files/serve/workspace%2FSRC%2Funknown.png) ![b](/api/files/view/unknown-id)' + expect(rewriteForkContentRefs(input, maps())).toBe(input) + }) + + it('leaves an external / data url unchanged', () => { + const input = '![a](https://cdn.example.com/x.png) ![b](data:image/png;base64,AAAA)' + expect(rewriteForkContentRefs(input, maps())).toBe(input) + }) +}) + +describe('rewriteForkContentRefs - mixed and edge cases', () => { + it('rewrites multiple references of different shapes in one string', () => { + const input = [ + 'intro [S](sim:skill/skill-src)', + `![img](/api/files/serve/${encodeURIComponent(SRC_KEY)})`, + 'link [W](sim:workflow/wf-src)', + '![v](/api/files/view/file-src)', + ].join('\n') + const output = rewriteForkContentRefs(input, maps()) + expect(output).toContain('sim:skill/skill-dst') + expect(output).toContain('sim:workflow/wf-dst') + expect(output).toContain(encodeURIComponent(DST_KEY)) + expect(output).toContain('/api/files/view/file-dst') + }) + + it('returns the input unchanged when there are no references', () => { + const input = '# Heading\n\nNo references here, just text.' + expect(rewriteForkContentRefs(input, maps())).toBe(input) + }) + + it('returns the input unchanged for an empty string', () => { + expect(rewriteForkContentRefs('', maps())).toBe('') + }) + + it('leaves a malformed (un-decodable) serve key unchanged', () => { + const input = '![a](/api/files/serve/%E0%A4%A)' + expect(rewriteForkContentRefs(input, maps())).toBe(input) + }) + + it('does nothing when no maps are supplied', () => { + const input = `[S](sim:skill/skill-src) ![a](/api/files/serve/${SRC_KEY})` + expect(rewriteForkContentRefs(input, {})).toBe(input) + }) +}) + +describe('rewriteForkResourceUrls - table cell resource chip urls', () => { + it('rewrites the workspace id + resource id for each section when the resource is mapped', () => { + expect(rewriteForkResourceUrls('/workspace/SRC/w/wf-src', maps())).toBe( + '/workspace/DST/w/wf-dst' + ) + expect(rewriteForkResourceUrls('/workspace/SRC/tables/tbl-src', maps())).toBe( + '/workspace/DST/tables/tbl-dst' + ) + expect(rewriteForkResourceUrls('/workspace/SRC/knowledge/kb-src', maps())).toBe( + '/workspace/DST/knowledge/kb-dst' + ) + expect(rewriteForkResourceUrls('/workspace/SRC/files/file-src', maps())).toBe( + '/workspace/DST/files/file-dst' + ) + }) + + it('leaves an unmapped resource id unchanged (both-or-nothing -> graceful plain link)', () => { + expect(rewriteForkResourceUrls('/workspace/SRC/w/wf-unknown', maps())).toBe( + '/workspace/SRC/w/wf-unknown' + ) + }) + + it('leaves a foreign / unknown workspace id unchanged', () => { + expect(rewriteForkResourceUrls('/workspace/OTHER/w/wf-src', maps())).toBe( + '/workspace/OTHER/w/wf-src' + ) + }) + + it('leaves a non-matching string (unknown section / no resource path) unchanged', () => { + const input = 'text /workspace/ and /workspace/SRC/settings/x and /workspace/SRC/w/' + expect(rewriteForkResourceUrls(input, maps())).toBe(input) + }) + + it('does nothing without a workspaceId map', () => { + const input = '/workspace/SRC/w/wf-src' + expect(rewriteForkResourceUrls(input, { workflows: new Map([['wf-src', 'wf-dst']]) })).toBe( + input + ) + }) + + it('rewrites a URL embedded mid-text', () => { + const input = 'See [chip](/workspace/SRC/knowledge/kb-src) here' + expect(rewriteForkResourceUrls(input, maps())).toBe( + 'See [chip](/workspace/DST/knowledge/kb-dst) here' + ) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/remap/remap-content-refs.ts b/apps/sim/lib/workspaces/fork/remap/remap-content-refs.ts new file mode 100644 index 00000000000..1f62e4057b0 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/remap/remap-content-refs.ts @@ -0,0 +1,127 @@ +/** + * Pure rewriter for the in-content references embedded in copied free-text (skill bodies, + * markdown file blobs) at fork/sync time. It rewrites the two reference shapes a copy must + * keep pointing at the right workspace: + * + * - `sim:/` deep links (the `@`-mention / chip scheme) - the id is remapped through + * the matching fork id map by kind (file, folder, table, knowledge, workflow, skill). + * - Embedded file/image URLs - `/api/files/serve/` (workspace storage key), `/api/files/view/` + * (workspace file id), and the in-app `/workspace//files/` path - remapped through the file + * key / file id / workspace id maps. + * + * A reference whose target has no mapping is LEFT UNCHANGED (a graceful broken link), never deleted, + * so a copied document is never silently corrupted. Pure and isomorphic (no DOM/Node/DB). + */ + +/** Per-kind source->target id maps a content copy threads in (any subset may be supplied). */ +export interface ForkContentRefMaps { + /** Workspace id rewrite for the in-app `/workspace//files/...` path. */ + workspaceId?: { from: string; to: string } + /** source workspace-file storage key -> child storage key (serve-url embeds). */ + fileKeys?: ReadonlyMap + /** source workspace-file id -> child id (view-url + in-app-path embeds). */ + fileIds?: ReadonlyMap + /** source workflow id -> child id (`sim:workflow/`). */ + workflows?: ReadonlyMap + /** source knowledge-base id -> child id (`sim:knowledge/`). */ + knowledgeBases?: ReadonlyMap + /** source table id -> child id (`sim:table/`). */ + tables?: ReadonlyMap + /** source skill id -> child id (`sim:skill/`). */ + skills?: ReadonlyMap + /** source folder id -> child id (`sim:folder/`). */ + folders?: ReadonlyMap +} + +/** `sim:/` token; the id charset matches generateId/generateShortId so it stops at delimiters. */ +const SIM_LINK_RE = /sim:([a-z_]+)\/([A-Za-z0-9_-]+)/g +/** `/api/files/serve/[s3/|blob/]` (key may be raw or percent-encoded, ends at a delimiter). */ +const SERVE_URL_RE = /\/api\/files\/serve\/(s3\/|blob\/)?([^\s)"'<>?]+)/g +/** `/api/files/view/`. */ +const VIEW_URL_RE = /\/api\/files\/view\/([A-Za-z0-9_-]+)/g +/** In-app `/workspace//files/` embed path. */ +const INAPP_FILE_RE = /\/workspace\/([A-Za-z0-9-]+)\/files\/([A-Za-z0-9_-]+)/g + +export function rewriteForkContentRefs(content: string, maps: ForkContentRefMaps): string { + if (!content) return content + + const idMapForSimKind: Record | undefined> = { + file: maps.fileIds, + folder: maps.folders, + table: maps.tables, + knowledge: maps.knowledgeBases, + workflow: maps.workflows, + skill: maps.skills, + } + + let result = content.replace(SIM_LINK_RE, (full, kind: string, id: string) => { + const target = idMapForSimKind[kind]?.get(id) + return target ? `sim:${kind}/${target}` : full + }) + + result = result.replace(SERVE_URL_RE, (full, prefix: string | undefined, encodedKey: string) => { + if (!maps.fileKeys) return full + let key: string + try { + key = decodeURIComponent(encodedKey) + } catch { + return full + } + const target = maps.fileKeys.get(key) + return target ? `/api/files/serve/${prefix ?? ''}${encodeURIComponent(target)}` : full + }) + + result = result.replace(VIEW_URL_RE, (full, id: string) => { + const target = maps.fileIds?.get(id) + return target ? `/api/files/view/${target}` : full + }) + + // Both-or-nothing: rewrite only when the workspace id is the mapped source AND the file id + // resolves, so we never emit a child-workspace path with an unmapped (guaranteed-404) id - + // matching the serve/view branches and rewriteForkResourceUrls. A foreign workspace id or an + // unmapped file id leaves the original path untouched (graceful). + result = result.replace(INAPP_FILE_RE, (full, wsId: string, fileId: string) => { + if (maps.workspaceId?.from !== wsId) return full + const mappedFile = maps.fileIds?.get(fileId) + return mappedFile ? `/workspace/${maps.workspaceId.to}/files/${mappedFile}` : full + }) + + return result +} + +/** + * In-app `/workspace//(w|tables|knowledge|files)/` deep link - the form a TABLE + * CELL renders as a resource chip (`resolveSimResourceKind`), but ONLY when `wsId` matches the + * current workspace. The id charset stops at delimiters. + */ +const RESOURCE_URL_RE = + /\/workspace\/([A-Za-z0-9-]+)\/(w|tables|knowledge|files)\/([A-Za-z0-9_-]+)/g + +/** + * Rewrite the in-workspace resource deep links a copied table cell renders as a resource chip, so + * the chip keeps resolving after a cross-workspace copy (a cell chip renders only when the URL's + * workspace id is the current workspace, so a stale source id silently degrades to a plain link). + * Repoints both the workspace id and the resource id at the child copy, per section: `w` -> + * workflows, `tables` -> tables, `knowledge` -> knowledge bases, `files` -> file ids. + * + * Both-or-nothing: a match is rewritten ONLY when its workspace id is the mapped source AND its + * resource id is in that section's map - otherwise it is left UNCHANGED. Emitting a child-workspace + * URL with an unmapped id would render a "Not found" chip (worse than the graceful plain link an + * unchanged URL degrades to). Distinct from {@link rewriteForkContentRefs}: table cells do NOT + * specially render the `sim:` / serve / view forms that skill + markdown bodies do. + */ +export function rewriteForkResourceUrls(content: string, maps: ForkContentRefMaps): string { + if (!content || !maps.workspaceId) return content + const { from, to } = maps.workspaceId + const idMapForSection: Record | undefined> = { + w: maps.workflows, + tables: maps.tables, + knowledge: maps.knowledgeBases, + files: maps.fileIds, + } + return content.replace(RESOURCE_URL_RE, (full, wsId: string, section: string, id: string) => { + if (wsId !== from) return full + const mappedId = idMapForSection[section]?.get(id) + return mappedId ? `/workspace/${to}/${section}/${mappedId}` : full + }) +} diff --git a/apps/sim/lib/workspaces/fork/remap/remap-files.ts b/apps/sim/lib/workspaces/fork/remap/remap-files.ts index ea83a6d161c..a0442c352d0 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-files.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-files.ts @@ -36,6 +36,30 @@ function fileItemKeyField(item: unknown): { field: 'key' | 'path' | 'name'; key: return null } +/** + * Enumerate the workspace-file storage keys referenced by a `file-upload` subblock + * value (single object, array, or JSON-string form). Used at promote time to emit each + * workspace file as a `file` reference (keyed by storage key) so it surfaces in the + * scan / unmapped set and can be copied into the target. Deduplicated, order-preserving. + */ +export function collectForkFileUploadKeys(value: unknown): string[] { + const parsed = parseMaybeJson(value) + const items = Array.isArray(parsed.value) + ? (parsed.value as unknown[]) + : parsed.value + ? [parsed.value] + : [] + const keys: string[] = [] + const seen = new Set() + for (const item of items) { + const keyInfo = fileItemKeyField(item) + if (!keyInfo || seen.has(keyInfo.key)) continue + seen.add(keyInfo.key) + keys.push(keyInfo.key) + } + return keys +} + /** * Remap a `file-upload` subblock value. `resolveFileKey(sourceKey)` returns the * copied target storage key, or null when the file was not copied (drop the ref). diff --git a/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts b/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts index f607a1fb70f..849273501aa 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts @@ -20,13 +20,16 @@ vi.mock('@/tools/params', () => ({ })) import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import { createForkBootstrapTransform } from '@/lib/workspaces/fork/remap/fork-bootstrap' import { applyDependentOverrides, + clearDependentsOnRemap, collectClearedDependents, parseNestedDependentKey, readTargetDraftDependentValue, remapForkSubBlocks, remapToolBlockResources, + scanWorkflowReferences, } from '@/lib/workspaces/fork/remap/remap-references' import { getBlock } from '@/blocks/registry' @@ -201,6 +204,38 @@ describe('remapToolBlockResources', () => { }) expect(result.params).toEqual({ knowledgeBaseId: 'kb-dst', documentId: '' }) }) + + it('remaps a nested documentId through the doc map when its document was copied', () => { + const tool = { + type: 'depblock', + toolId: 'depblock_run', + params: { knowledgeBaseId: 'kb-src', documentId: 'doc-src' }, + } + const map: Record = { + 'knowledge-base:kb-src': 'kb-dst', + 'knowledge-document:doc-src': 'doc-dst', + } + const result = remapToolBlockResources(tool, { + resolve: (kind, id) => map[`${kind}:${id}`] ?? null, + resolveFileKey: () => null, + clearUnresolved: true, + blockConfigs: { + depblock: { + subBlocks: [ + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { + id: 'documentId', + title: 'Doc', + type: 'document-selector', + dependsOn: ['knowledgeBaseId'], + }, + ], + }, + }, + }) + // documentId is remapped (not cleared as a dependent) because its document was copied. + expect(result.params).toEqual({ knowledgeBaseId: 'kb-dst', documentId: 'doc-dst' }) + }) }) describe('remapForkSubBlocks', () => { @@ -283,6 +318,52 @@ describe('remapForkSubBlocks', () => { const tools = result.subBlocks.tools.value as Array<{ params: { subject: string } }> expect(tools[0].params.subject).toBe('Hi {{NEW}}') }) + + const fileSubBlock = (): SubBlockRecord => ({ + file: { + id: 'file', + type: 'file-upload', + value: { key: 'workspace/SRC/a.png', name: 'a.png' }, + }, + }) + + it('promote mode: records an unmapped file-upload key as a file reference and clears it', () => { + const result = remapForkSubBlocks(fileSubBlock(), () => null, 'promote') + const keys = result.references.map((r) => `${r.kind}:${r.sourceId}`) + expect(keys).toContain('file:workspace/SRC/a.png') + // file refs are optional (not required), surfaced for the copy/clear decision. + expect(result.references.find((r) => r.kind === 'file')?.required).toBe(false) + expect(result.unmapped.map((r) => `${r.kind}:${r.sourceId}`)).toContain( + 'file:workspace/SRC/a.png' + ) + // An uncopied file key is dropped rather than carried cross-workspace. + expect(result.subBlocks.file.value).toBe('') + }) + + it('promote mode: remaps a file-upload key to the copied target and records it mapped', () => { + const result = remapForkSubBlocks( + fileSubBlock(), + (kind, id) => + kind === 'file' && id === 'workspace/SRC/a.png' ? 'workspace/DST/a.png' : null, + 'promote' + ) + expect(result.references.map((r) => `${r.kind}:${r.sourceId}`)).toContain( + 'file:workspace/SRC/a.png' + ) + expect(result.unmapped).toHaveLength(0) + expect((result.subBlocks.file.value as { key: string }).key).toBe('workspace/DST/a.png') + }) + + it('create mode: does not record file references but still remaps copied files', () => { + const result = remapForkSubBlocks( + fileSubBlock(), + (kind, id) => + kind === 'file' && id === 'workspace/SRC/a.png' ? 'workspace/DST/a.png' : null, + 'create' + ) + expect(result.references).toHaveLength(0) + expect((result.subBlocks.file.value as { key: string }).key).toBe('workspace/DST/a.png') + }) }) const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig => @@ -290,6 +371,179 @@ const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig => const entry = (id: string, type: string, value: unknown) => ({ id, type, value }) +describe('createForkBootstrapTransform document-selector remap', () => { + const docBlock = () => + blockWith([ + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { id: 'documentId', title: 'Doc', type: 'document-selector', dependsOn: ['knowledgeBaseId'] }, + ]) + const subBlocks = (): SubBlockRecord => ({ + knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-src' }, + documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' }, + }) + + it('remaps documentId to the copied document (not cleared as a KB dependent)', () => { + vi.mocked(getBlock).mockReturnValue(docBlock()) + const map: Record = { + 'knowledge-base:kb-src': 'kb-dst', + 'knowledge-document:doc-src': 'doc-dst', + } + const transform = createForkBootstrapTransform((kind, id) => map[`${kind}:${id}`] ?? null) + const result = transform(subBlocks(), 'knowledge') + expect(result.knowledgeBaseId.value).toBe('kb-dst') + expect(result.documentId.value).toBe('doc-dst') + }) + + it('clears documentId when its parent KB was not copied', () => { + vi.mocked(getBlock).mockReturnValue(docBlock()) + const transform = createForkBootstrapTransform(() => null) + const result = transform(subBlocks(), 'knowledge') + expect(result.knowledgeBaseId.value).toBe('') + expect(result.documentId.value).toBe('') + }) + + it('clears documentId when its KB was copied but the document was not', () => { + vi.mocked(getBlock).mockReturnValue(docBlock()) + const transform = createForkBootstrapTransform((kind, id) => + kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null + ) + const result = transform(subBlocks(), 'knowledge') + expect(result.knowledgeBaseId.value).toBe('kb-dst') + expect(result.documentId.value).toBe('') + }) +}) + +describe('clearDependentsOnRemap canonical-pair gating', () => { + const kbCanonicalBlock = () => + blockWith([ + { + id: 'knowledgeBaseSelector', + title: 'KB', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + mode: 'basic', + }, + { + id: 'manualKnowledgeBaseId', + title: 'KB ID', + type: 'short-input', + canonicalParamId: 'knowledgeBaseId', + mode: 'advanced', + }, + { + id: 'documentSelector', + title: 'Document', + type: 'document-selector', + dependsOn: ['knowledgeBaseSelector'], + }, + ]) + + it('does not clear a dependent when only the DORMANT basic selector was remapped (advanced active)', () => { + vi.mocked(getBlock).mockReturnValue(kbCanonicalBlock()) + const subBlocks: SubBlockRecord = { + knowledgeBaseSelector: { type: 'knowledge-base-selector', value: '' }, + manualKnowledgeBaseId: { type: 'short-input', value: 'kb-active' }, + documentSelector: { type: 'document-selector', value: 'doc-1' }, + } + const result = clearDependentsOnRemap( + subBlocks, + 'knowledge', + new Set(['knowledgeBaseSelector']), + { + knowledgeBaseId: 'advanced', + } + ) + // The active advanced parent is unchanged, so the dependent must be preserved. + expect(result.documentSelector.value).toBe('doc-1') + }) + + it('clears a dependent when the ACTIVE basic selector was remapped (basic active)', () => { + vi.mocked(getBlock).mockReturnValue(kbCanonicalBlock()) + const subBlocks: SubBlockRecord = { + knowledgeBaseSelector: { type: 'knowledge-base-selector', value: 'kb-new' }, + manualKnowledgeBaseId: { type: 'short-input', value: '' }, + documentSelector: { type: 'document-selector', value: 'doc-1' }, + } + const result = clearDependentsOnRemap( + subBlocks, + 'knowledge', + new Set(['knowledgeBaseSelector']), + { + knowledgeBaseId: 'basic', + } + ) + // Basic is active; its remap clears the dependent (unchanged behavior). + expect(result.documentSelector.value).toBe('') + }) +}) + +describe('scanWorkflowReferences canonical-pair detection', () => { + const credBlock = () => + blockWith([ + { + id: 'credential', + title: 'Account', + type: 'oauth-input', + canonicalParamId: 'credential', + mode: 'basic', + }, + { + id: 'manualCredential', + title: 'Account ID', + type: 'short-input', + canonicalParamId: 'credential', + mode: 'advanced', + }, + ]) + // The advanced manualCredential is a short-input escape hatch (never scanned); the basic + // oauth-input is the detectable member, so the "active" assertion targets the basic mode. + const scanBlock = (canonicalModes?: Record) => ({ + id: 'b1', + name: 'Send', + type: 'gmail', + canonicalModes, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'cred-stale' }, + manualCredential: { id: 'manualCredential', type: 'short-input', value: 'cred-active' }, + }, + }) + + it('does not detect a DORMANT basic credential while advanced is active (no required ref / sync gate)', () => { + vi.mocked(getBlock).mockReturnValue(credBlock()) + const scan = scanWorkflowReferences([scanBlock({ credential: 'advanced' })], () => null) + expect(scan.references.filter((ref) => ref.kind === 'credential')).toEqual([]) + expect(scan.unmapped.filter((ref) => ref.kind === 'credential')).toEqual([]) + }) + + it('detects the ACTIVE basic credential as a required reference (basic active)', () => { + vi.mocked(getBlock).mockReturnValue(credBlock()) + const scan = scanWorkflowReferences([scanBlock({ credential: 'basic' })], () => null) + const creds = scan.references.filter((ref) => ref.kind === 'credential') + expect(creds).toHaveLength(1) + expect(creds[0].sourceId).toBe('cred-stale') + expect(creds[0].required).toBe(true) + }) + + it('skips DETECTION for a dormant member but still REWRITES its value (separation)', () => { + vi.mocked(getBlock).mockReturnValue(credBlock()) + const result = remapForkSubBlocks( + { + credential: { id: 'credential', type: 'oauth-input', value: 'cred-stale' }, + manualCredential: { id: 'manualCredential', type: 'short-input', value: 'cred-active' }, + }, + () => null, + 'promote', + { blockType: 'gmail', canonicalModes: { credential: 'advanced' } } + ) + // Detection skipped (dormant basic), so it never gates sync... + expect(result.references.filter((ref) => ref.kind === 'credential')).toEqual([]) + // ...but the dual-mode rewrite still cleared the unresolved dormant basic credential. + expect(result.subBlocks.credential.value).toBe('') + // The advanced escape-hatch id is preserved verbatim (not auto-remapped). + expect(result.subBlocks.manualCredential.value).toBe('cred-active') + }) +}) + describe('collectClearedDependents', () => { it('flags a required dependent the target had set but the merge left empty', () => { vi.mocked(getBlock).mockReturnValue( diff --git a/apps/sim/lib/workspaces/fork/remap/remap-references.ts b/apps/sim/lib/workspaces/fork/remap/remap-references.ts index 7df375febaa..dc4e9c269bf 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-references.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-references.ts @@ -18,12 +18,19 @@ import { type StructuredWorkflowSearchResourceKind, } from '@/lib/workflows/search-replace/resources/registry' import { + buildCanonicalIndex, buildSubBlockValues, + type CanonicalModeOverrides, evaluateSubBlockCondition, + isCanonicalPair, isNonEmptyValue, + resolveCanonicalMode, } from '@/lib/workflows/subblocks/visibility' import type { ParsedStoredTool } from '@/lib/workflows/tool-input/types' -import { remapForkFileUploadValue } from '@/lib/workspaces/fork/remap/remap-files' +import { + collectForkFileUploadKeys, + remapForkFileUploadValue, +} from '@/lib/workspaces/fork/remap/remap-files' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' @@ -38,7 +45,12 @@ export type ForkRemapKind = z.infer const logger = createLogger('WorkspaceForkRemapReferences') -const REQUIRED_KINDS = new Set(['credential', 'env-var']) +/** + * Reference kinds whose absence BLOCKS a sync (they gate `requiredComplete` and are resolved by + * mapping), as opposed to optional kinds that silently clear. Exported so the cleared-ref preview + * can exclude them - a required ref is a blocker, never a silent "will be cleared" item. + */ +export const REQUIRED_KINDS = new Set(['credential', 'env-var']) /** * Id-based override kind for a TOOL param's credential, resolved by subblock id so a @@ -59,20 +71,43 @@ export const REGISTRY_KIND_TO_FORK_KIND: Partial< > = { 'oauth-credential': 'credential', 'knowledge-base': 'knowledge-base', + 'knowledge-document': 'knowledge-document', table: 'table', 'mcp-server': 'mcp-server', } -// `file` and `knowledge-document` are intentionally excluded from the generic -// registry path. `file-upload` (workspace files) is remapped by storage key via -// `remapForkFileUploadValue`; `file-selector` (external provider file ids, -// credential-scoped) carries over unchanged; `document-selector` is cleared by the -// `dependsOn` rule (clearDependentsOnRemap) when its parent knowledge base is remapped. -// `mcp-tool-selector` is likewise cleared by `dependsOn` when its `mcp-server-selector` -// parent is remapped - the tool list is server-scoped and may differ in the target. +// `file` is intentionally excluded from the generic registry path: `file-upload` +// (workspace files) is remapped by storage key via `remapForkFileUploadValue`, and +// `file-selector` (external provider file ids, credential-scoped) carries over +// unchanged. `document-selector` (`knowledge-document`) IS remapped through the doc-id +// map when its referenced document was copied into the fork; an unmapped document (its +// parent KB wasn't copied, or the doc wasn't copyable) resolves to null and is cleared, +// and `clearDependentsOnRemap` still clears it as a `knowledgeBaseId` dependent when the +// parent KB itself is unmapped. `mcp-tool-selector` is cleared by `dependsOn` when its +// `mcp-server-selector` parent is remapped - the tool list is server-scoped and may +// differ in the target. /** Matches `{{ENV_KEY}}` references inside subblock values; shared with cascade detection. */ export const ENV_REF_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g +/** + * Rewrite `{{ENV}}` references in free text (a copied custom tool's `code`, an MCP url/header) + * through an env-name resolver, so a promote that renames an env var (e.g. SLACK_API_KEY -> + * SLACK_API_KEY_TEST) keeps the copied text pointing at the right key. A key the resolver leaves + * unmapped (null/undefined) or maps to the same name is kept verbatim - a graceful no-op so an env + * that exists under the same name in the target still works. Pure; mirrors {@link remapEnvInValue}'s + * preserve-by-name policy for the string case. + */ +export function rewriteEnvRefsInText( + text: string, + resolveEnvName: (key: string) => string | null | undefined +): string { + if (!text) return text + return text.replace(ENV_REF_PATTERN, (full, key: string) => { + const target = resolveEnvName(key) + return target && target !== key ? `{{${target}}}` : full + }) +} + /** * A `credentialSet:` reference points at an ORG-scoped credential set. A fork * inherits its parent's org, so the set is already valid in the target — these refs @@ -110,6 +145,16 @@ export interface RemapSubBlocksResult { remappedKeys: Set } +/** Per-block context for the fork remap. `blockType`/`canonicalModes` gate DETECTION (not rewrite). */ +export interface RemapForkContext { + blockId?: string + blockName?: string + /** Block type, to build the canonical index for active-member DETECTION gating (rewrite unaffected). */ + blockType?: string + /** Canonical-mode overrides (`block.data.canonicalModes`), picking the active member per pair. */ + canonicalModes?: CanonicalModeOverrides +} + function remapEnvInValue( value: unknown, resolve: ForkReferenceResolver, @@ -232,8 +277,12 @@ export function remapToolBlockResources( if (definition.kind === 'file') { // file-upload (workspace file) remaps by storage key; file-selector (external - // provider id) carries over unchanged. + // provider id) carries over unchanged. Each key is recorded as a `file` reference so + // a nested tool's workspace file surfaces in the scan / unmapped set and can be copied. if (config.type !== 'file-upload') continue + for (const fileKey of collectForkFileUploadKeys(currentValue)) { + opts.record?.('file', fileKey, opts.resolveFileKey(fileKey) != null) + } const remapped = remapForkFileUploadValue(currentValue, opts.resolveFileKey) if (remapped !== currentValue) { setParam(paramId, remapped) @@ -420,7 +469,7 @@ export function remapForkSubBlocks( subBlocks: SubBlockRecord, resolve: ForkReferenceResolver, mode: 'create' | 'promote', - context?: { blockId?: string; blockName?: string } + context?: RemapForkContext ): RemapSubBlocksResult { const clearUnresolved = true const result: SubBlockRecord = {} @@ -434,6 +483,25 @@ export function remapForkSubBlocks( if (!mapped) unmapped.set(key, reference) } + // DETECTION gate: a DORMANT canonical member's stale value must not be recorded as a reference + // (so it is never offered as a required mapping / copyable / usage and can't gate sync). The value + // REWRITE below is untouched - both basic + advanced ids are still remapped. Needs `blockType` to + // build the canonical index; callers that omit it (create-mode transforms) keep today's detection, + // and with `canonicalModes` absent the value heuristic keeps a populated member active (no-op). + const canonicalIndex = context?.blockType + ? buildCanonicalIndex(getBlock(context.blockType)?.subBlocks ?? []) + : undefined + const detectionValues = canonicalIndex ? buildSubBlockValues(subBlocks) : {} + const isDormantCanonicalMember = (key: string): boolean => { + if (!canonicalIndex) return false + const baseKey = key.replace(/_\d+$/, '') + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[baseKey] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + if (!group || !isCanonicalPair(group)) return false + const activeMode = resolveCanonicalMode(group, detectionValues, context?.canonicalModes) + return (activeMode === 'advanced') !== group.advancedIds.includes(baseKey) + } + for (const [subBlockKey, subBlock] of Object.entries(subBlocks)) { if (!subBlock || typeof subBlock !== 'object') { result[subBlockKey] = subBlock @@ -450,6 +518,8 @@ export function remapForkSubBlocks( const forkKind = definition ? REGISTRY_KIND_TO_FORK_KIND[definition.kind] : undefined if (definition && forkKind && subBlockType) { + // A dormant canonical member is rewritten (below) but NOT detected as a reference. + const isDormant = isDormantCanonicalMember(subBlockKey) const parsed = parseWorkflowSearchSubBlockResources(value, { type: subBlockType as SubBlockType, }) @@ -469,7 +539,7 @@ export function remapForkSubBlocks( } const target = resolve(forkKind, ref.rawValue) const mapped = target != null - recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped) + if (!isDormant) recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped) if (mapped) { if (target !== ref.rawValue) { const replaceResult = definition.codec.replace(value, ref.rawValue, target) @@ -485,9 +555,25 @@ export function remapForkSubBlocks( } if (subBlockType === 'file-upload') { - // Workspace-file refs don't sync on promote (the target lacks the source's - // blob); clear them rather than carry a cross-workspace key. On fork, the - // resolver returns the copied key. `file-selector` (external) is untouched. + // Each workspace-file key is a `file` reference (keyed by storage key). Recording it + // surfaces the file in the scan / unmapped set so a sync can copy it into the target, + // exactly like fork - rather than silently clearing it. The resolver returns the copied + // key once the file has been copied; an unmapped (uncopied) key is dropped by the remap + // below. `file-selector` (external provider ids) is untouched. + for (const fileKey of collectForkFileUploadKeys(value)) { + recordReference( + `file:${fileKey}`, + { + kind: 'file', + sourceId: fileKey, + blockId: context?.blockId, + blockName: context?.blockName, + subBlockKey, + required: false, + }, + resolve('file', fileKey) != null + ) + } value = remapForkFileUploadValue(value, (sourceKey) => resolve('file', sourceKey) ?? null) } else if (subBlockType === 'tool-input' || subBlockType === 'skill-input') { const record = (kind: ForkRemapKind, sourceId: string, mapped: boolean) => @@ -552,14 +638,32 @@ export function remapForkSubBlocks( export function clearDependentsOnRemap( subBlocks: SubBlockRecord, blockType: string, - remappedKeys: ReadonlySet + remappedKeys: ReadonlySet, + canonicalModes?: CanonicalModeOverrides ): SubBlockRecord { if (remappedKeys.size === 0) return subBlocks const config = getBlock(blockType) if (!config) return subBlocks + // Only a remap of the ACTIVE canonical member should clear its dependents: a dormant member's + // stale value being remapped/cleared must not clear a child that hangs off the active parent + // (only the active mode is serialized). With `canonicalModes` absent the value heuristic keeps a + // populated basic member active, so this is a no-op for the normal case; the gate only bites the + // toggle-with-stale-dormant edge (advanced active + a dormant basic that was remapped). + const canonicalIndex = buildCanonicalIndex(config.subBlocks) + const values = buildSubBlockValues(subBlocks) + const isDormantCanonicalMember = (key: string): boolean => { + const baseKey = key.replace(/_\d+$/, '') + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[baseKey] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + if (!group || !isCanonicalPair(group)) return false + const mode = resolveCanonicalMode(group, values, canonicalModes) + return (mode === 'advanced') !== group.advancedIds.includes(baseKey) + } + const toClear = new Set() for (const key of remappedKeys) { + if (isDormantCanonicalMember(key)) continue for (const clear of getWorkflowSearchDependentClears(config.subBlocks, key)) { if (!remappedKeys.has(clear.subBlockId)) toClear.add(clear.subBlockId) } @@ -864,7 +968,7 @@ export function applyDependentOverrides( export function remapSubBlocks( subBlocks: SubBlockRecord, resolve: ForkReferenceResolver, - context?: { blockId?: string; blockName?: string } + context?: RemapForkContext ): RemapSubBlocksResult { return remapForkSubBlocks(subBlocks, resolve, 'promote', context) } @@ -872,10 +976,14 @@ export function remapSubBlocks( /** A `copyWorkflowStateIntoTarget` subBlock transform that rewrites references via the resolver. */ export function createForkSubBlockTransform( resolve: ForkReferenceResolver -): (subBlocks: SubBlockRecord, blockType: string) => SubBlockRecord { - return (subBlocks, blockType) => { +): ( + subBlocks: SubBlockRecord, + blockType: string, + canonicalModes?: CanonicalModeOverrides +) => SubBlockRecord { + return (subBlocks, blockType, canonicalModes) => { const result = remapSubBlocks(subBlocks, resolve) - return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys) + return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys, canonicalModes) } } @@ -890,7 +998,15 @@ export interface WorkflowReferenceScan { * paths to surface what needs mapping and to block on unmapped required refs. */ export function scanWorkflowReferences( - blocks: Array<{ id: string; name: string; subBlocks: unknown }>, + blocks: Array<{ + id: string + name: string + /** Block type, so detection can collapse a canonical pair to its active member. */ + type?: string + subBlocks: unknown + /** `block.data.canonicalModes`, picking the active member per canonical pair for detection. */ + canonicalModes?: CanonicalModeOverrides + }>, resolve: ForkReferenceResolver ): WorkflowReferenceScan { const references = new Map() @@ -903,6 +1019,8 @@ export function scanWorkflowReferences( const blockResult = remapSubBlocks(block.subBlocks as SubBlockRecord, resolve, { blockId: block.id, blockName: block.name, + blockType: block.type, + canonicalModes: block.canonicalModes, }) for (const reference of blockResult.references) { const key = `${reference.kind}:${reference.sourceId}` diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 9ed017b581b..e6eaf2122d1 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1583,6 +1583,13 @@ describe('transformBlockTool multi-instance unique IDs', () => { expect(result?.id).toBe('table_query_rows_tbl_xyz') }) + it('resolves an advanced-only manual id via the heuristic when basic is empty and no mode is set', async () => { + // No canonicalModes entry: routing through resolveCanonicalMode picks advanced (empty basic), + // where the old `?? 'basic'` fallback dropped the advanced-only value. + const result = await transformTable({ manualTableId: 'tbl_only' }) + expect(result?.id).toBe('table_query_rows_tbl_only') + }) + it('appends the canonical table id when already present in params', async () => { const result = await transformTable({ tableId: 'tbl_direct' }) expect(result?.id).toBe('table_query_rows_tbl_direct') diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 9d8e5dce848..40ff390cadf 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -14,8 +14,8 @@ import { import { buildCanonicalIndex, type CanonicalGroup, - getCanonicalValues, isCanonicalPair, + resolveActiveCanonicalValue, } from '@/lib/workflows/subblocks/visibility' import { isCustomTool } from '@/executor/constants' import { @@ -498,9 +498,14 @@ function resolveCanonicalResourceParams( for (const group of canonicalGroups) { const existing = resolved[group.canonicalId] if (existing !== undefined && existing !== null && existing !== '') continue - const { basicValue, advancedValue } = getCanonicalValues(group, params) - const pairMode = canonicalModes?.[`${blockType}:${group.canonicalId}`] ?? 'basic' - const chosen = pairMode === 'advanced' ? advancedValue : basicValue + // Route through the canonical SOT: an explicit scoped override wins, else the value heuristic - + // no `?? 'basic'` (which ignored an advanced-only value when basic was empty). + const explicitMode = canonicalModes?.[`${blockType}:${group.canonicalId}`] + const chosen = resolveActiveCanonicalValue( + group, + params, + explicitMode ? { [group.canonicalId]: explicitMode } : undefined + ) if (chosen !== undefined) resolved[group.canonicalId] = chosen } return resolved @@ -627,10 +632,14 @@ export async function transformBlockTool( let result = { ...params } for (const group of canonicalGroups) { - const { basicValue, advancedValue } = getCanonicalValues(group, result) - const scopedKey = `${block.type}:${group.canonicalId}` - const pairMode = canonicalModes?.[scopedKey] ?? 'basic' - const chosen = pairMode === 'advanced' ? advancedValue : basicValue + // Route through the canonical SOT: an explicit scoped override wins, else the value + // heuristic - no `?? 'basic'` (which dropped an advanced-only value when basic was empty). + const explicitMode = canonicalModes?.[`${block.type}:${group.canonicalId}`] + const chosen = resolveActiveCanonicalValue( + group, + result, + explicitMode ? { [group.canonicalId]: explicitMode } : undefined + ) const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] sourceIds.forEach((id) => delete result[id]) diff --git a/apps/sim/tools/params-resolver.test.ts b/apps/sim/tools/params-resolver.test.ts new file mode 100644 index 00000000000..7adcfc50be0 --- /dev/null +++ b/apps/sim/tools/params-resolver.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildCanonicalIndex, buildPreviewContextValues } from '@/tools/params-resolver' + +const canonicalIndex = buildCanonicalIndex([ + { + id: 'knowledgeBaseSelector', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + mode: 'basic', + }, + { + id: 'manualKnowledgeBaseId', + type: 'short-input', + canonicalParamId: 'knowledgeBaseId', + mode: 'advanced', + }, +] as Parameters[0]) + +const values = { knowledgeBaseSelector: 'kb-basic', manualKnowledgeBaseId: 'kb-advanced' } + +describe('buildPreviewContextValues', () => { + it('honors an explicit advanced override so the preview matches execution', () => { + const result = buildPreviewContextValues(values, { + blockType: 'knowledge', + subBlocks: [], + canonicalIndex, + values, + overrides: { knowledgeBaseId: 'advanced' }, + }) + expect(result.knowledgeBaseId).toBe('kb-advanced') + }) + + it('falls back to the value heuristic (basic when present) without an override', () => { + const result = buildPreviewContextValues(values, { + blockType: 'knowledge', + subBlocks: [], + canonicalIndex, + values, + }) + expect(result.knowledgeBaseId).toBe('kb-basic') + }) +}) diff --git a/apps/sim/tools/params-resolver.ts b/apps/sim/tools/params-resolver.ts index b40d324399c..70d31b3d898 100644 --- a/apps/sim/tools/params-resolver.ts +++ b/apps/sim/tools/params-resolver.ts @@ -8,6 +8,7 @@ import { resolveCanonicalMode, resolveDependencyValue, type SubBlockCondition, + scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' @@ -19,6 +20,7 @@ export { isCanonicalPair, resolveCanonicalMode, resolveDependencyValue, + scopeCanonicalModesForTool, type SubBlockCondition, } @@ -27,6 +29,11 @@ export interface ToolParamContext { subBlocks: BlockSubBlockConfig[] canonicalIndex: CanonicalIndex values: Record + /** + * Canonical-id-keyed mode overrides (the tool-scoped `canonicalModes`) so the preview honors an + * explicit basic/advanced toggle, matching execution. Omitted -> the value heuristic. + */ + overrides?: CanonicalModeOverrides } /** @@ -41,7 +48,7 @@ export function buildPreviewContextValues( for (const [canonicalId, group] of Object.entries(context.canonicalIndex.groupsById)) { if (isCanonicalPair(group)) { - const mode = resolveCanonicalMode(group, context.values) + const mode = resolveCanonicalMode(group, context.values, context.overrides) const { basicValue, advancedValue } = getCanonicalValues(group, context.values) result[canonicalId] = mode === 'advanced' ? (advancedValue ?? basicValue) : (basicValue ?? advancedValue) diff --git a/packages/db/migrations/0253_canonical_trigger_provider_config.sql b/packages/db/migrations/0253_canonical_trigger_provider_config.sql new file mode 100644 index 00000000000..b20cd42e1e1 --- /dev/null +++ b/packages/db/migrations/0253_canonical_trigger_provider_config.sql @@ -0,0 +1,71 @@ +-- migration-safe: data-only backfill. Expand-phase companion to the canonical-key collapse in +-- buildProviderConfig + the canonical-first poller reads. Populates each polling trigger's canonical +-- providerConfig key from the value the CURRENT poller already reads (basic-first), so NO deployed +-- trigger changes which resource it polls. Per provider the basic-first effective value is: +-- google-drive folderId <- folderId, else manualFolderId (canonical key == basic key) +-- google-sheets spreadsheetId <- spreadsheetId, else manualSpreadsheetId +-- google-sheets sheetName <- sheetName, else manualSheetName +-- google-calendar calendarId <- calendarId, else manualCalendarId +-- table tableId <- tableSelector, else manualTableId (canonical key is distinct) +-- For the Google triggers the canonical key IS the basic subblock key, so a row whose canonical key +-- is already set is left untouched (it already equals the basic-first read); only rows where it is +-- absent are filled from the advanced key. The table canonical key (tableId) was never written +-- before, so it is filled from tableSelector/manualTableId. +-- +-- Idempotent: every statement only writes where the canonical key is still empty, so a replay (a +-- failed migration re-runs unjournaled files from the top) is a no-op. Bounded: each UPDATE is scoped +-- to one provider and the empty-canonical predicate, touching only un-backfilled rows. Safe under +-- concurrent writes: the still-live previous app version never writes these canonical keys (it writes +-- the raw subblock keys), and any row it inserts after this runs is handled by the pollers' +-- transitional basic-first fallback until its next redeploy. providerConfig is a `json` column, so it +-- is cast to jsonb for the `||` merge and back to json for storage. +UPDATE "webhook" +SET "provider_config" = ( + COALESCE(("provider_config")::jsonb, '{}'::jsonb) + || jsonb_build_object('folderId', ("provider_config")::jsonb ->> 'manualFolderId') +)::json +WHERE "provider" = 'google-drive' + AND NULLIF(("provider_config")::jsonb ->> 'folderId', '') IS NULL + AND NULLIF(("provider_config")::jsonb ->> 'manualFolderId', '') IS NOT NULL; +--> statement-breakpoint +UPDATE "webhook" +SET "provider_config" = ( + COALESCE(("provider_config")::jsonb, '{}'::jsonb) + || jsonb_build_object('spreadsheetId', ("provider_config")::jsonb ->> 'manualSpreadsheetId') +)::json +WHERE "provider" = 'google-sheets' + AND NULLIF(("provider_config")::jsonb ->> 'spreadsheetId', '') IS NULL + AND NULLIF(("provider_config")::jsonb ->> 'manualSpreadsheetId', '') IS NOT NULL; +--> statement-breakpoint +UPDATE "webhook" +SET "provider_config" = ( + COALESCE(("provider_config")::jsonb, '{}'::jsonb) + || jsonb_build_object('sheetName', ("provider_config")::jsonb ->> 'manualSheetName') +)::json +WHERE "provider" = 'google-sheets' + AND NULLIF(("provider_config")::jsonb ->> 'sheetName', '') IS NULL + AND NULLIF(("provider_config")::jsonb ->> 'manualSheetName', '') IS NOT NULL; +--> statement-breakpoint +UPDATE "webhook" +SET "provider_config" = ( + COALESCE(("provider_config")::jsonb, '{}'::jsonb) + || jsonb_build_object('calendarId', ("provider_config")::jsonb ->> 'manualCalendarId') +)::json +WHERE "provider" = 'google-calendar' + AND NULLIF(("provider_config")::jsonb ->> 'calendarId', '') IS NULL + AND NULLIF(("provider_config")::jsonb ->> 'manualCalendarId', '') IS NOT NULL; +--> statement-breakpoint +UPDATE "webhook" +SET "provider_config" = ( + COALESCE(("provider_config")::jsonb, '{}'::jsonb) + || jsonb_build_object('tableId', COALESCE( + NULLIF(("provider_config")::jsonb ->> 'tableSelector', ''), + NULLIF(("provider_config")::jsonb ->> 'manualTableId', '') + )) +)::json +WHERE "provider" = 'table' + AND NULLIF(("provider_config")::jsonb ->> 'tableId', '') IS NULL + AND COALESCE( + NULLIF(("provider_config")::jsonb ->> 'tableSelector', ''), + NULLIF(("provider_config")::jsonb ->> 'manualTableId', '') + ) IS NOT NULL; diff --git a/packages/db/migrations/meta/0253_snapshot.json b/packages/db/migrations/meta/0253_snapshot.json new file mode 100644 index 00000000000..0ef069d9252 --- /dev/null +++ b/packages/db/migrations/meta/0253_snapshot.json @@ -0,0 +1,17002 @@ +{ + "id": "5fb249c9-5269-456d-83bd-7a2d1bac7a22", + "prevId": "1540e14e-d6da-4953-8899-2c1c439e7c16", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "columns": ["certificate_number"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "columns": ["key"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "columnsFrom": ["actor_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "columnsFrom": ["run_id"], + "tableTo": "copilot_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "columnsFrom": ["checkpoint_id"], + "tableTo": "copilot_run_checkpoints", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "columnsFrom": ["run_id"], + "tableTo": "copilot_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "account_id IS NOT NULL", + "concurrently": false + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "type = 'env_workspace'", + "concurrently": false + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "type = 'env_personal'", + "concurrently": false + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "columnsFrom": ["account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "columnsFrom": ["env_owner_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "columnsFrom": ["credential_id"], + "tableTo": "credential", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "columnsFrom": ["invited_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "columnsFrom": ["credential_set_id"], + "tableTo": "credential_set", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "columnsFrom": ["invited_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "columnsFrom": ["accepted_by_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "columns": ["token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "columnsFrom": ["credential_set_id"], + "tableTo": "credential_set", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "columnsFrom": ["invited_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "columnsFrom": ["drain_id"], + "tableTo": "data_drains", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": { + "m": 16, + "ef_construction": 64 + }, + "method": "hnsw", + "concurrently": false + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "columnsFrom": ["knowledge_base_id"], + "tableTo": "knowledge_base", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "columnsFrom": ["connector_id"], + "tableTo": "knowledge_connector", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "columnsFrom": ["uploaded_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "to_tsvector('english', \"embedding\".\"content\")" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": { + "m": 16, + "ef_construction": 64 + }, + "method": "hnsw", + "concurrently": false + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "columnsFrom": ["knowledge_base_id"], + "tableTo": "knowledge_base", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "columnsFrom": ["document_id"], + "tableTo": "document", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "columns": ["user_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "columnsFrom": ["inviter_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "columns": ["token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "columnsFrom": ["invitation_id"], + "tableTo": "invitation", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "columnsFrom": ["schedule_id"], + "tableTo": "workflow_schedule", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "columnsFrom": ["knowledge_base_id"], + "tableTo": "knowledge_base", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "columnsFrom": ["knowledge_base_id"], + "tableTo": "knowledge_base", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "columnsFrom": ["connector_id"], + "tableTo": "knowledge_connector", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "columnsFrom": ["mcp_server_id"], + "tableTo": "mcp_servers", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "columnsFrom": ["added_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "columns": ["workspace_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "columnsFrom": ["set_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "columnsFrom": ["credential_id"], + "tableTo": "credential", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "is_default = true", + "concurrently": false + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "columnsFrom": ["permission_group_id"], + "tableTo": "permission_group", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "columnsFrom": ["assigned_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "columnsFrom": ["permission_group_id"], + "tableTo": "permission_group", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "columnsFrom": ["paused_execution_id"], + "tableTo": "paused_executions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "columnsFrom": ["active_organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "columns": ["user_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "columnsFrom": ["row_id"], + "tableTo": "user_table_rows", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "columnsFrom": ["triggered_by_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "nullsNotDistinct": false + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "columns": ["normalized_email"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "columns": ["user_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "columnsFrom": ["table_id"], + "tableTo": "user_table_definitions", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "columns": ["email"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "columnsFrom": ["deployment_version_id"], + "tableTo": "workflow_deployment_version", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "columnsFrom": ["credential_set_id"], + "tableTo": "credential_set", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "columnsFrom": ["folder_id"], + "tableTo": "workflow_folder", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "columnsFrom": ["source_block_id"], + "tableTo": "workflow_blocks", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "columnsFrom": ["target_block_id"], + "tableTo": "workflow_blocks", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "status = 'running'", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "columnsFrom": ["state_snapshot_id"], + "tableTo": "workflow_execution_snapshots", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "columnsFrom": ["deployment_version_id"], + "tableTo": "workflow_deployment_version", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "columnsFrom": ["server_id"], + "tableTo": "workflow_mcp_server", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["deployment_version_id"], + "tableTo": "workflow_deployment_version", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["source_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "columnsFrom": ["source_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "columnsFrom": ["workflow_id"], + "tableTo": "workflow", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "columnsFrom": ["owner_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "columnsFrom": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "columnsFrom": ["billed_account_user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "columnsFrom": ["uploaded_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "columns": ["key"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "tableTo": "workspace_file_folders", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "columnsFrom": ["user_id"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "columnsFrom": ["workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "columnsFrom": ["folder_id"], + "tableTo": "workspace_file_folders", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "columnsFrom": ["chat_id"], + "tableTo": "copilot_chats", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "columnsFrom": ["child_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "columnsFrom": ["child_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "columnsFrom": ["child_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "columnsFrom": ["child_workspace_id"], + "tableTo": "workspace", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "columnsFrom": ["created_by"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 1d448c60dfd..61a3560d9e2 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1765,6 +1765,13 @@ "when": 1782685998491, "tag": "0252_remove_a2a", "breakpoints": true + }, + { + "idx": 253, + "version": "7", + "when": 1782860073503, + "tag": "0253_canonical_trigger_provider_config", + "breakpoints": true } ] }