From 6f90d8976d49aaca633097bbd045f7f7f6322cde Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 15:27:16 -0700 Subject: [PATCH 01/32] fix(mcp): stream pinned transport under Bun (providers + self-hosted-private MCP) (#5901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): stream pinned transport via undici.request + redirect interceptor Extends the Bun undici-streaming fix to createPinnedFetchWithDispatcher (providers, A2A, self-hosted-private MCP over SSE). It now routes through undiciRequestAsResponse like the guarded builder, so streaming bodies deliver under Bun. Unlike the guarded path it has no followRedirectsGuarded wrapper (it's handed straight to provider SDKs), so redirects are followed via undici's redirect interceptor composed onto the pinned Agent — every hop still dispatches through the pinned connect.lookup (resolvedIP), so a redirect can't escape to another address, matching the old fetch guarantee. secureFetchWithPinnedIP (raw Node http, tools path) is untouched. * fix(mcp): honor redirect mode + drop cross-origin credentials on pinned fetch Replaces the always-on redirect interceptor with redirect-mode-aware handling: - redirect:'manual' returns the 3xx without following (detectMcpAuthType inspects it) - redirect:'error' throws on a 3xx - default 'follow' uses followRedirectsGuarded, which drops ALL headers on a cross-origin hop (so a redirect can't disclose a provider api-key to another origin — Greptile P1) and stamps the final response.url + redirected flag. Extracts the shared Request-lift helper used by both guarded and pinned builders. * fix(mcp): don't block private IP-literal URLs on the pinned fetch path Routing the pinned fetch through followRedirectsGuarded added an initial assertGuardedRedirectTarget check the old undici.fetch path never ran, which would block a self-hosted MCP configured with a private IP-literal URL (e.g. http://10.0.0.5:3000/mcp) — its own transport. The pinned path's callers already validate the target and the private carve-out intentionally pins to a private IP, so skip the initial-target check (validateInitialTarget: false) while still validating every redirect hop. Adds a regression test. * fix(mcp): carry redirect mode from a Request input in liftFetchArgs liftFetchArgs copied method/headers/body/signal from a Request but omitted redirect, so a Request({ redirect: 'manual' }) on the pinned path defaulted to 'follow' and was transparently followed. Copy input.redirect (explicit init still wins). Adds a Request-input redirect-mode test. * fix(mcp): permit the pinned IP as a redirect target (initial + hops), block other private IPs Consolidates the pinned-path redirect policy into one mechanism. followRedirectsGuarded took validateInitialTarget to skip the initial private-IP check, but per-hop checks still blocked a self-hosted MCP redirecting to its own pinned private IP (e.g. a trailing-slash 301 to http://10.0.0.5/mcp/). Replace it with allowRedirectToIp: the pinned fetch permits exactly its own validated IP as a target — initial URL and any hop that stays on it — while every OTHER private target (e.g. the 169.254.169.254 metadata IP) stays blocked. Tests cover the same-IP hop (followed) and the metadata-IP escape (still refused). --- .../core/security/input-validation.server.ts | 122 +++++++++---- .../core/security/pinned-fetch.server.test.ts | 163 ++++++++++++++---- 2 files changed, 218 insertions(+), 67 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index c8a632e167f..e8ea4071577 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -14,7 +14,6 @@ import { Agent, type Dispatcher, type RequestInit as UndiciRequestInit, - fetch as undiciFetch, request as undiciRequest, } from 'undici' import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' @@ -544,7 +543,7 @@ const MAX_GUARDED_REDIRECTS = 5 * a 3xx to `http://169.254.169.254/` would otherwise connect directly. Hostname * targets are covered by {@link createSsrfGuardedLookup} at connect time. */ -function assertGuardedRedirectTarget(url: URL): void { +function assertGuardedRedirectTarget(url: URL, allowedPinnedIp?: string): void { if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error(`Blocked by SSRF policy: redirect to unsupported protocol ${url.protocol}`) } @@ -553,6 +552,16 @@ function assertGuardedRedirectTarget(url: URL): void { ? url.hostname.slice(1, -1) : url.hostname if (ipaddr.isValid(host) && isPrivateOrReservedIP(host)) { + // The pinned-private carve-out permits exactly its own validated IP as a target (a + // self-hosted MCP on a private IP, or a same-host redirect that stays on it) — but nothing + // else private (a redirect to e.g. the cloud metadata IP is still blocked). + if ( + allowedPinnedIp && + ipaddr.isValid(allowedPinnedIp) && + ipaddr.process(host).toString() === ipaddr.process(allowedPinnedIp).toString() + ) { + return + } throw new Error('Blocked by SSRF policy: redirect to a private or reserved address') } } @@ -568,12 +577,15 @@ function assertGuardedRedirectTarget(url: URL): void { export async function followRedirectsGuarded( rawFetch: (url: string, init: UndiciRequestInit) => Promise, input: string, - init: UndiciRequestInit + init: UndiciRequestInit, + options?: { allowRedirectToIp?: string } ): Promise { let currentUrl = new URL(input) - // The initial URL gets the same IP-literal check as redirect hops, so the exported - // guard is self-contained even when a caller skips its own up-front validation. - assertGuardedRedirectTarget(currentUrl) + // The initial URL gets the same IP-literal check as redirect hops, so the exported guard is + // self-contained even when a caller skips its own up-front validation. `allowRedirectToIp` + // (the pinned-private MCP carve-out's validated IP) permits that one private target — both the + // initial URL and any hop that stays on it — while everything else private stays blocked. + assertGuardedRedirectTarget(currentUrl, options?.allowRedirectToIp) let method = (init.method ?? 'GET').toUpperCase() let body = init.body let headers = init.headers @@ -587,7 +599,13 @@ export async function followRedirectsGuarded( }) const status = response.status const location = response.headers.get('location') - if (![301, 302, 303, 307, 308].includes(status) || !location) return response + if (![301, 302, 303, 307, 308].includes(status) || !location) { + // `response.url` is already the final hop's URL (set per-request by the raw fetch); flag + // `redirected` too when at least one hop was followed, matching fetch semantics. + if (hop > 0) + Object.defineProperty(response, 'redirected', { value: true, configurable: true }) + return response + } // Cancel the redirect body up front so the throw paths below (hop cap, blocked // target) can't leave a socket checked out on the long-lived Agent. await response.body?.cancel().catch(() => {}) @@ -595,7 +613,7 @@ export async function followRedirectsGuarded( throw new Error(`Blocked by SSRF policy: more than ${MAX_GUARDED_REDIRECTS} redirects`) } const nextUrl = new URL(location, currentUrl) - assertGuardedRedirectTarget(nextUrl) + assertGuardedRedirectTarget(nextUrl, options?.allowRedirectToIp) // Per the fetch spec: 303 (and 301/302 on POST) switch to a bodyless GET, dropping // the entity headers that described the removed body (a retained Content-Length / // Content-Type on a bodyless GET is malformed and undici rejects it). @@ -781,7 +799,7 @@ function nodeReadableToWebStream(nodeStream: Readable): ReadableStream { let url: string let effectiveInit = init as UndiciRequestInit @@ -880,6 +898,36 @@ async function undiciRequestAsResponse( } } +/** + * Normalizes a `fetch(input, init)` call into a URL string + init. A `Request` input carries + * its own method/headers/body/signal; lift them into the init (explicit init fields win, per + * fetch semantics) so a manual redirect follower can't silently downgrade a POST Request to a + * bare GET or lose its headers. + */ +async function liftFetchArgs( + input: RequestInfo | URL, + init?: RequestInit +): Promise<{ target: string; effectiveInit: RequestInit }> { + const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (typeof Request !== 'undefined' && input instanceof Request) { + const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD' + return { + target, + effectiveInit: { + method: input.method, + headers: input.headers, + body: bodyAllowed ? await input.clone().arrayBuffer() : undefined, + signal: input.signal, + // Carry the Request's redirect mode so the pinned fetch honors `manual`/`error` + // instead of defaulting a `Request({ redirect: 'manual' })` to `follow`. + redirect: input.redirect, + ...init, + }, + } + } + return { target, effectiveInit: init ?? {} } +} + /** * SSRF-guarded `fetch` + its `Agent` for outbound requests to user-controlled * hosts: DNS resolves normally, and every socket connect validates the chosen @@ -903,21 +951,7 @@ export function createSsrfGuardedFetchWithDispatcher(options?: { maxResponseSize undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher) const guarded = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - // A Request input carries its own method/headers/body/signal; lift them into the - // init (explicit init fields win, per fetch semantics) so the manual redirect - // follower doesn't silently downgrade a guarded POST Request to a bare GET. - let effectiveInit: RequestInit = init ?? {} - if (typeof Request !== 'undefined' && input instanceof Request) { - const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD' - effectiveInit = { - method: input.method, - headers: input.headers, - body: bodyAllowed ? await input.clone().arrayBuffer() : undefined, - signal: input.signal, - ...init, - } - } + const { target, effectiveInit } = await liftFetchArgs(input, init) // double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ return followRedirectsGuarded(rawFetch, target, effectiveInit as unknown as UndiciRequestInit) } @@ -977,14 +1011,42 @@ export function createPinnedFetchWithDispatcher( ...(options?.maxResponseSize !== undefined ? { maxResponseSize: options.maxResponseSize } : {}), }) + const rawFetch = (url: string, init: UndiciRequestInit): Promise => + // double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime + undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher) + + // Requests go through `undici.request` (not `undici.fetch`) because fetch's streaming + // `response.body` never delivers under the Bun runtime the server runs on — the same bug + // {@link createSsrfGuardedFetchWithDispatcher} works around. Redirects are handled here (not + // by a caller's wrapper — the pinned fetch is passed straight to provider/A2A SDKs), honoring + // the request's `redirect` mode: `manual`/`error` must NOT transparently follow (e.g. + // `detectMcpAuthType` inspects the 3xx to classify auth). The default `follow` uses + // {@link followRedirectsGuarded}, which drops headers on cross-origin hops (so a redirect + // can't disclose a provider `api-key` to another origin) and stamps the final `response.url`. + // Every hop still dispatches through the pinned `Agent` (its `connect.lookup` forces + // `resolvedIP`), so a redirect can't escape to another address. const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - // double-cast-allowed: DOM RequestInfo/URL and undici fetch input types differ but are structurally compatible at runtime (Node's global fetch IS undici) - const undiciInput = input as unknown as Parameters[0] + const { target, effectiveInit } = await liftFetchArgs(input, init) + const mode = effectiveInit.redirect ?? 'follow' // double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ - const undiciInit: UndiciRequestInit = { ...(init as unknown as UndiciRequestInit), dispatcher } - const response = await undiciFetch(undiciInput, undiciInit) - // double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime - return response as unknown as Response + const undiciInit = effectiveInit as unknown as UndiciRequestInit + if (mode === 'manual') { + return rawFetch(target, undiciInit) + } + if (mode === 'error') { + const response = await rawFetch(target, undiciInit) + const location = response.headers.get('location') + if (response.status >= 300 && response.status < 400 && location) { + await response.body?.cancel().catch(() => {}) + throw new TypeError('Pinned fetch received an unexpected redirect (redirect: "error")') + } + return response + } + // Permit this pinned IP as a redirect/initial target even when it's private (the + // self-hosted MCP carve-out on a private/loopback IP, and same-host redirects that stay on + // it) — otherwise the guarded policy would block a self-hosted server reaching itself. Any + // OTHER private target (e.g. a redirect to the cloud metadata IP) is still blocked. + return followRedirectsGuarded(rawFetch, target, undiciInit, { allowRedirectToIp: resolvedIP }) } return { fetch: pinned, dispatcher } diff --git a/apps/sim/lib/core/security/pinned-fetch.server.test.ts b/apps/sim/lib/core/security/pinned-fetch.server.test.ts index 66c798ad556..a1eca0eb09e 100644 --- a/apps/sim/lib/core/security/pinned-fetch.server.test.ts +++ b/apps/sim/lib/core/security/pinned-fetch.server.test.ts @@ -1,39 +1,33 @@ /** * @vitest-environment node */ +import { Readable } from 'node:stream' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAgent, mockUndiciFetch, capturedAgentOptions, agentCloses } = vi.hoisted(() => { +const { mockAgent, mockUndiciRequest, capturedAgentOptions } = vi.hoisted(() => { const capturedAgentOptions: unknown[] = [] - const agentCloses: unknown[] = [] class MockAgent { constructor(options: unknown) { capturedAgentOptions.push(options) } close() { - agentCloses.push(this) + return Promise.resolve() + } + destroy() { return Promise.resolve() } } return { mockAgent: MockAgent, - mockUndiciFetch: vi.fn(), + mockUndiciRequest: vi.fn(), capturedAgentOptions, - agentCloses, } }) -vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch })) -/** - * Query-suffixed import gives this file a private instance of the module under - * test. Under `isolate: false` the worker's module graph is shared across test - * files, so the plain specifier may already be cached with the real `undici` - * binding (mocks never reach an already-evaluated module) — and evaluating it - * here under this file's mocks would poison it for later files. The suffixed id - * is unique to this file, so it always evaluates fresh with the mocks above. - */ +vi.mock('undici', () => ({ Agent: mockAgent, request: mockUndiciRequest })) + declare module '@/lib/core/security/input-validation.server?pinned-fetch-test' { - // biome-ignore lint/suspicious/noExportsInTest: ambient type re-declaration for the query-suffixed specifier, not a runtime export + // biome-ignore lint/suspicious/noExportsInTest: ambient re-declaration for the query-suffixed specifier export * from '@/lib/core/security/input-validation.server' } @@ -42,12 +36,22 @@ import { createPinnedFetch } from '@/lib/core/security/input-validation.server?p type LookupCallback = (err: Error | null, address: string, family: number) => void type PinnedLookup = (hostname: string, options: { all?: boolean }, callback: LookupCallback) => void +function byteStream(text: string): Readable { + const stream = new Readable({ read() {} }) + stream.push(Buffer.from(text)) + stream.push(null) + return stream +} + +function undiciReply(statusCode: number, headers: Record, body: Readable) { + return { statusCode, headers, body, trailers: {}, opaque: null, context: {} } +} + describe('createPinnedFetch', () => { beforeEach(() => { vi.clearAllMocks() capturedAgentOptions.length = 0 - agentCloses.length = 0 - mockUndiciFetch.mockResolvedValue(new Response('ok')) + mockUndiciRequest.mockResolvedValue(undiciReply(200, {}, byteStream('ok'))) }) it('builds an undici Agent whose pinned lookup always resolves to the validated IP', async () => { @@ -86,7 +90,7 @@ describe('createPinnedFetch', () => { expect(resolved).toEqual({ address: '2606:4700:4700::1111', family: 6 }) }) - it('forwards the pinned dispatcher on every call while preserving init options', async () => { + it('dispatches through the pinned Agent, preserving init', async () => { const pinned = createPinnedFetch('203.0.113.10') const controller = new AbortController() @@ -97,32 +101,117 @@ describe('createPinnedFetch', () => { signal: controller.signal, }) - expect(mockUndiciFetch).toHaveBeenCalledTimes(1) - const [url, init] = mockUndiciFetch.mock.calls[0] + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + const [url, options] = mockUndiciRequest.mock.calls[0] expect(url).toBe('https://myresource.openai.azure.com/openai/v1/responses') - const typedInit = init as RequestInit & { dispatcher?: unknown } - expect(typedInit.dispatcher).toBeInstanceOf(mockAgent) - expect(typedInit.method).toBe('POST') - expect(typedInit.headers).toEqual({ 'api-key': 'secret' }) - expect(typedInit.body).toBe('{}') - expect(typedInit.signal).toBe(controller.signal) + expect(options.dispatcher).toBeInstanceOf(mockAgent) + expect(options.method).toBe('POST') + expect(options.headers).toEqual({ 'api-key': 'secret' }) + expect(options.body).toBe('{}') + expect(options.signal).toBe(controller.signal) }) - it('handles an undefined init by still attaching the dispatcher', async () => { + it('honors redirect: "manual" — returns the 3xx without following (auth-type probe)', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply(302, { location: 'https://login.example.com/' }, byteStream('')) + ) const pinned = createPinnedFetch('203.0.113.10') - await pinned('https://example.com') - const init = mockUndiciFetch.mock.calls[0][1] as { dispatcher?: unknown } - expect(init.dispatcher).toBeInstanceOf(mockAgent) + + const response = await pinned('https://mcp.example.com/', { redirect: 'manual' }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('https://login.example.com/') + }) + + it('honors redirect mode carried on a Request input (not just init)', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply(302, { location: 'https://login.example.com/' }, byteStream('')) + ) + const pinned = createPinnedFetch('203.0.113.10') + + const response = await pinned(new Request('https://mcp.example.com/', { redirect: 'manual' })) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + expect(response.status).toBe(302) + }) + + it('follows redirects by default and DROPS headers on a cross-origin hop (no api-key leak)', async () => { + mockUndiciRequest + .mockResolvedValueOnce( + undiciReply(307, { location: 'https://other-origin.example/final' }, byteStream('')) + ) + .mockResolvedValueOnce(undiciReply(200, {}, byteStream('done'))) + const pinned = createPinnedFetch('203.0.113.10') + + const response = await pinned('https://azure.example.com/v1/responses', { + method: 'GET', + headers: { 'api-key': 'secret' }, + }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(2) + // Second (cross-origin) hop must not carry the provider credential — no headers forwarded. + const secondHopHeaders = (mockUndiciRequest.mock.calls[1][1].headers ?? {}) as Record< + string, + string + > + expect(secondHopHeaders['api-key']).toBeUndefined() + expect(Object.keys(secondHopHeaders)).toHaveLength(0) + expect(response.status).toBe(200) + expect(response.url).toBe('https://other-origin.example/final') + expect(response.redirected).toBe(true) + expect(await response.text()).toBe('done') + }) + + it('does NOT block a private IP-literal URL (self-hosted-private MCP carve-out)', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('mcp'))) + const pinned = createPinnedFetch('10.0.0.5') + + // A self-hosted MCP configured with a private IP-literal URL must still connect — the old + // undici.fetch path never ran the SSRF initial-target check that would otherwise block it. + const response = await pinned('http://10.0.0.5:3000/mcp', { method: 'POST', body: '{}' }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + expect(response.status).toBe(200) + expect(await response.text()).toBe('mcp') + }) + + it('follows a redirect that stays on the pinned private IP (self-hosted MCP alias)', async () => { + mockUndiciRequest + .mockResolvedValueOnce( + undiciReply(301, { location: 'http://10.0.0.5:3000/mcp/' }, byteStream('')) + ) + .mockResolvedValueOnce(undiciReply(200, {}, byteStream('mcp'))) + const pinned = createPinnedFetch('10.0.0.5') + + const response = await pinned('http://10.0.0.5:3000/mcp', { method: 'GET' }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(2) + expect(response.status).toBe(200) + expect(await response.text()).toBe('mcp') + }) + + it('STILL blocks a redirect to a different private IP (no metadata-IP escape)', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply(302, { location: 'http://169.254.169.254/latest/meta-data/' }, byteStream('')) + ) + const pinned = createPinnedFetch('10.0.0.5') + + await expect(pinned('http://10.0.0.5:3000/mcp', { method: 'GET' })).rejects.toThrow( + /private or reserved/ + ) + // The initial request happened; the redirect to the metadata IP was refused. + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) }) - it('reuses one captured dispatcher across all calls of a single instance', async () => { + it('reuses one dispatcher across all calls of a single instance', async () => { const pinned = createPinnedFetch('203.0.113.10') await pinned('https://example.com/a') await pinned('https://example.com/b') expect(capturedAgentOptions).toHaveLength(1) - const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher - const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher + const d1 = (mockUndiciRequest.mock.calls[0][1] as { dispatcher: unknown }).dispatcher + const d2 = (mockUndiciRequest.mock.calls[1][1] as { dispatcher: unknown }).dispatcher expect(d1).toBe(d2) }) @@ -133,13 +222,13 @@ describe('createPinnedFetch', () => { await b('https://example.com/b') expect(capturedAgentOptions).toHaveLength(2) - const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher - const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher + const d1 = (mockUndiciRequest.mock.calls[0][1] as { dispatcher: unknown }).dispatcher + const d2 = (mockUndiciRequest.mock.calls[1][1] as { dispatcher: unknown }).dispatcher expect(d1).not.toBe(d2) }) - it('returns the response produced by undici fetch', async () => { - mockUndiciFetch.mockResolvedValueOnce(new Response('pong', { status: 201 })) + it('returns a streaming Response built from the undici.request body', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(201, {}, byteStream('pong'))) const pinned = createPinnedFetch('203.0.113.10') const response = await pinned('https://example.com') expect(response.status).toBe(201) From 3796e9db4fae0abc55a0523ba4379f4e9697fe71 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 15:44:13 -0700 Subject: [PATCH 02/32] improvement(access-control): edit group details, filter by status, and colocate chat deploy auth (#5902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(access-control): editable group details, status filters, block tooltips, chat auth colocation - General tab gains editable Name and Description fields wired into the existing dirty buffer, so the header Save/Discard chips and the unsaved-changes guard cover them. Save only sends changed fields and surfaces the route's duplicate-name 409. - Blocks, Model Providers, and Platform tabs gain an All/Enabled/Disabled filter beside their search field, evaluated against the editing buffer. - Every block row carries an Info badge with the block's description. The badge sits outside the label/expand button so it never toggles the row. - The chat deploy toggle moves out of Deploy Tabs into the Chat section alongside its allowed-auth-modes dropdown, mirroring the Files section. * improvement(access-control): polish group detail filters, tooltips, and details fields Follows the cleanup review: - reconcile the post-save baseline from the server response instead of local values, matching the scope/default writes - pin the status-filter dropdown width so the search field stops resizing - restore flex-1 on the block-name button and move the row hover surface to the wrapper so Info badges align in a column - add empty states for filter-empty lists and neutralize Select All there - surface a 'Name is required' message next to the disabled Save - align hint text on the field-hint tokens and drop a redundant TSDoc * refactor(access-control): keep chat and files toggles in the platform registry The first pass pulled hideDeployChatbot out of the declarative platformFeatures array and hand-rolled a Chat section beside the existing bespoke Files one. That forfeited search, status filtering, Select All, category grouping and the Info hint, and the replacement platformSectionVisible re-implemented two of those with different semantics — searching 'deploy' or 'deployment' hid the very control named Deployment, and Select All silently skipped both toggles. Both toggles are now ordinary registry entries under their own Chat and Files categories, with an id-keyed featureExtras map supplying the nested auth-mode dropdown. Search, filtering, Select All, hints and the empty state are correct by construction, and the parallel filter pipeline is gone. Also from the review: - index the allow-lists into Sets so per-row membership checks are O(1) - split the search and status passes so the common 'all' filter returns the searched list by reference and a checkbox toggle no longer re-sorts ~180 rows - extract StatusFilterChip and AuthModeField instead of stamping out the dropdowns three and two times - derive nameChanged/descriptionChanged once instead of repeating the comparisons in the save payload - lock the config key-order invariant the dirty check depends on with a test * polish(access-control): apply the second cleanup round - indent the nested auth-mode field so it lines up under its toggle's label instead of reading as a sibling row, and label the dropdown for screen readers - order Chat right after Deploy Tabs so the three deploy targets stay adjacent - flush the trailing Select All chips on the providers and platform rows - drop the doubled margin on the name error (SettingRow already gaps it) - hoist PLATFORM_FEATURES and PLATFORM_CATEGORY_ORDER to module scope - drop the useCallback on the two save/discard handlers; nothing observes their identity and their deps changed on every keystroke anyway - fix a comment that still pointed at a 'Hide Chat' toggle that no longer exists * fix(access-control): keep the block disclosure chevron inside its toggle button Splitting the chevron out so the Info badge could sit beside the name left the chevron with no click handler — the visible expansion affordance did nothing. It goes back inside the button; Info stays outside it, since an Info trigger is itself a button and cannot nest. * chore(access-control): use structuredClone in the config key-order test check:utils bans JSON.parse(JSON.stringify(...)). The clone only needs to hand the schema a distinct object; structuredClone preserves key order the same way. * fix(access-control): trim descriptions so a padded value can't wedge the form descriptionChanged compared a trimmed draft against the raw saved description, so a group whose stored description carried padding opened dirty and could never be cleared — Discard restored the same padded string, and the unsaved-changes guard then blocked navigation until a save rewrote it. The contract now trims description on create and update, matching what name already did, and the dirty check trims both sides so existing padded rows behave too. * improvement(access-control): seed the description buffer trimmed Keeps the editing buffer and the dirty baseline normalized the same way, so a legacy row with padding no longer shows stray whitespace in the input and the buffer never round-trips padding a save would strip anyway. * fix(emcn): forward aria-label/aria-labelledby from ChipDropdown to its trigger ChipDropdown destructures only its known props, so an aria-labelledby passed by a consumer never reached the trigger button — AuthModeField's wiring to its visible label was silently dropped and the control had no accessible name. Both attributes are now explicit, typed props forwarded to the trigger. Kept as two named props rather than a rest spread so the component still owns its chrome and consumers can't smuggle arbitrary attributes onto the button. * fix(access-control): correct the nested field indent, platform row hover, and aria name - aria-labelledby REPLACES the content-derived name, so naming the auth-mode dropdown with its caption alone dropped the selected value from the accessible name — worse than no attribute. It now references caption + trigger, which needed ChipDropdown to forward id as well. - The nested field's pl-[30px] assumed a 14px checkbox; the default is 16px, so the caption sat 2px left of the label it hangs under while the dropdown (not flush, so mx-0.5) sat at 32. Both are pl-8 + flush now. - Platform feature rows kept their hover surface on the label, so the highlight stopped 20px short of the row edge while the Blocks tab ran flush. Moved to the wrapper, matching the core-blocks cell. - Split the platform search and status passes like the provider and block lists, so a toggle no longer recomputes three chained memos. - Dropped an unreachable allLabel and a comment that would go stale on merge. * fix(access-control): trim the name comparison the same way as description nameChanged compared a trimmed buffer against an untrimmed baseline — the exact asymmetry already fixed for description. A group stored before the name schema gained .trim() opens permanently dirty: Save/Discard visible and the unsaved-changes modal firing on back, until a save rewrites it. Both buffers now seed trimmed and compare trimmed on both sides. Also dims the Info badge along with the row it belongs to when the block is disallowed. --- .../components/group-detail.tsx | 772 +++++++++++------- .../api/contracts/permission-groups.test.ts | 57 ++ .../lib/api/contracts/permission-groups.ts | 4 +- .../chip-dropdown/chip-dropdown.tsx | 20 + 4 files changed, 563 insertions(+), 290 deletions(-) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 47a258f5f9a..1b70ad89a26 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' +import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react' import { Checkbox, Chip, @@ -37,6 +37,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -53,6 +54,7 @@ import { useRemovePermissionGroupMember, useUpdatePermissionGroup, } from '@/ee/access-control/hooks/permission-groups' +import { SettingRow } from '@/ee/components/setting-row' import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers' import { useOrganizationRoster } from '@/hooks/queries/organization' import { useProviderModels } from '@/hooks/queries/providers' @@ -90,6 +92,227 @@ const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTION (o) => o.value ) +type StatusFilter = 'all' | 'enabled' | 'disabled' + +const STATUS_FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: 'all', label: 'Show all' }, + { value: 'enabled', label: 'Show enabled' }, + { value: 'disabled', label: 'Show disabled' }, +] + +function matchesStatusFilter(filter: StatusFilter, enabled: boolean) { + return filter === 'all' || (filter === 'enabled') === enabled +} + +interface StatusFilterChipProps { + value: StatusFilter + onChange: (value: StatusFilter) => void + /** Set when the chip is the last control in its row, so it sits flush to the edge. */ + flush?: boolean +} + +/** The All/Enabled/Disabled narrowing control shared by the three list tabs. */ +function StatusFilterChip({ value, onChange, flush }: StatusFilterChipProps) { + return ( + onChange(next as StatusFilter)} + options={STATUS_FILTER_OPTIONS} + matchTriggerWidth={false} + flush={flush} + className='w-[140px] flex-shrink-0' + /> + ) +} + +interface AuthModeFieldProps { + label: string + value: ShareAuthType[] + onChange: (values: string[]) => void + options: { value: ShareAuthType; label: string }[] + disabled: boolean +} + +/** + * The allowed-auth-modes multi-select nested under a platform toggle. Dims and + * disables together with the toggle that owns it. The left padding lines both + * children up with the parent's label text — row gutter (8) + checkbox (16) + + * gap (8) = 32 — so the field reads as subordinate rather than as a sibling row. + * The dropdown is `flush` so its own `mx-0.5` doesn't push it 2px past the + * caption above it. + */ +function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFieldProps) { + const labelId = useId() + const triggerId = useId() + return ( +
+ + {label} + + +
+ ) +} + +/** Render order for the platform-feature category sections; unlisted ones follow. */ +const PLATFORM_CATEGORY_ORDER = [ + 'Sidebar', + 'Deploy Tabs', + 'Chat', + 'Collaboration', + 'Workflow Panel', + 'Tools', + 'Features', + 'Settings Tabs', + 'Logs', + 'Files', +] + +const PLATFORM_FEATURES = [ + { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + configKey: 'hideKnowledgeBaseTab' as const, + hint: 'Hide the Knowledge Base module from the sidebar.', + }, + { + id: 'hide-tables', + label: 'Tables', + category: 'Sidebar', + configKey: 'hideTablesTab' as const, + hint: 'Hide the Tables module from the sidebar.', + }, + { + id: 'hide-copilot', + label: 'Chat', + category: 'Workflow Panel', + configKey: 'hideCopilot' as const, + hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + }, + { + id: 'hide-integrations', + label: 'Integrations', + category: 'Settings Tabs', + configKey: 'hideIntegrationsTab' as const, + hint: 'Hide the Integrations settings tab (OAuth connections).', + }, + { + id: 'hide-secrets', + label: 'Secrets', + category: 'Settings Tabs', + configKey: 'hideSecretsTab' as const, + hint: 'Hide the Secrets (environment variables) settings tab.', + }, + { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + configKey: 'hideApiKeysTab' as const, + hint: 'Hide the API Keys settings tab.', + }, + { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + configKey: 'hideFilesTab' as const, + hint: 'Hide the Files settings tab.', + }, + { + id: 'hide-deploy-api', + label: 'API', + category: 'Deploy Tabs', + configKey: 'hideDeployApi' as const, + hint: 'Hide the API deployment option.', + }, + { + id: 'hide-deploy-mcp', + label: 'MCP', + category: 'Deploy Tabs', + configKey: 'hideDeployMcp' as const, + hint: 'Hide the MCP server deployment option.', + }, + { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + configKey: 'disableMcpTools' as const, + hint: 'Block agents from calling MCP tools.', + }, + { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + configKey: 'disableCustomTools' as const, + hint: 'Block agents from calling user-defined custom tools.', + }, + { + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + configKey: 'disableSkills' as const, + hint: 'Block agents from loading skills.', + }, + { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + configKey: 'hideTraceSpans' as const, + hint: 'Hide per-block trace spans in logs.', + }, + { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + configKey: 'disableInvitations' as const, + hint: 'Prevent users from inviting others to workspaces.', + }, + { + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Features', + configKey: 'hideInboxTab' as const, + hint: 'Hide the Sim Mailer inbox.', + }, + { + id: 'disable-public-api', + label: 'Public API', + category: 'Features', + configKey: 'disablePublicApi' as const, + hint: 'Disable public API access to deployed workflows.', + }, + // Chat and Files get a category of their own so their nested auth-mode + // dropdown (see `featureExtras`) reads as part of the toggle it qualifies. + { + id: 'hide-deploy-chatbot', + label: 'Deployment', + category: 'Chat', + configKey: 'hideDeployChatbot' as const, + hint: 'Hide the chat deployment option.', + }, + { + id: 'disable-public-file-sharing', + label: 'Public Sharing', + category: 'Files', + configKey: 'disablePublicFileSharing' as const, + hint: 'Disable public file-share links.', + }, +] + interface OrganizationMemberOption { userId: string user: { @@ -521,7 +744,7 @@ function BlockToolRow({ onClick={() => isBlockAllowed && isExpandable && setExpanded((prev) => !prev)} disabled={!isBlockAllowed || !isExpandable} className={cn( - 'flex flex-1 items-center gap-2 text-left', + 'flex min-w-0 flex-1 items-center gap-2 text-left', isBlockAllowed && isExpandable ? 'cursor-pointer' : 'cursor-default', !isBlockAllowed && 'opacity-60' )} @@ -541,6 +764,12 @@ function BlockToolRow({ /> )} + {/* Outside the button: an Info trigger is itself a button and cannot nest. */} + {block.description && ( + + {block.description} + + )} {expanded && isBlockAllowed && isExpandable && (
@@ -595,11 +824,15 @@ export function GroupDetail({ */ const [viewingGroup, setViewingGroup] = useState(group) const [editingConfig, setEditingConfig] = useState({ ...group.config }) + const [editingName, setEditingName] = useState(group.name.trim()) + const [editingDescription, setEditingDescription] = useState((group.description ?? '').trim()) const prevGroupIdRef = useRef(group.id) if (prevGroupIdRef.current !== group.id) { prevGroupIdRef.current = group.id setViewingGroup(group) setEditingConfig({ ...group.config }) + setEditingName(group.name.trim()) + setEditingDescription((group.description ?? '').trim()) } /** @@ -613,6 +846,9 @@ export function GroupDetail({ const [providerSearchTerm, setProviderSearchTerm] = useState('') const [integrationSearchTerm, setIntegrationSearchTerm] = useState('') const [platformSearchTerm, setPlatformSearchTerm] = useState('') + const [providerStatusFilter, setProviderStatusFilter] = useState('all') + const [blockStatusFilter, setBlockStatusFilter] = useState('all') + const [platformStatusFilter, setPlatformStatusFilter] = useState('all') const [showAddMembersModal, setShowAddMembersModal] = useState(false) const [addMembersError, setAddMembersError] = useState(null) @@ -676,141 +912,24 @@ export function GroupDetail({ return map }, [allBlocks]) - const platformFeatures = useMemo( - () => [ - { - id: 'hide-knowledge-base', - label: 'Knowledge Base', - category: 'Sidebar', - configKey: 'hideKnowledgeBaseTab' as const, - hint: 'Hide the Knowledge Base module from the sidebar.', - }, - { - id: 'hide-tables', - label: 'Tables', - category: 'Sidebar', - configKey: 'hideTablesTab' as const, - hint: 'Hide the Tables module from the sidebar.', - }, - { - id: 'hide-copilot', - label: 'Chat', - category: 'Workflow Panel', - configKey: 'hideCopilot' as const, - hint: 'Hide the Chat panel so users cannot build or edit with natural language.', - }, - { - id: 'hide-integrations', - label: 'Integrations', - category: 'Settings Tabs', - configKey: 'hideIntegrationsTab' as const, - hint: 'Hide the Integrations settings tab (OAuth connections).', - }, - { - id: 'hide-secrets', - label: 'Secrets', - category: 'Settings Tabs', - configKey: 'hideSecretsTab' as const, - hint: 'Hide the Secrets (environment variables) settings tab.', - }, - { - id: 'hide-api-keys', - label: 'API Keys', - category: 'Settings Tabs', - configKey: 'hideApiKeysTab' as const, - hint: 'Hide the API Keys settings tab.', - }, - { - id: 'hide-files', - label: 'Files', - category: 'Settings Tabs', - configKey: 'hideFilesTab' as const, - hint: 'Hide the Files settings tab.', - }, - { - id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - configKey: 'hideDeployApi' as const, - hint: 'Hide the API deployment option.', - }, - { - id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - configKey: 'hideDeployMcp' as const, - hint: 'Hide the MCP server deployment option.', - }, - { - id: 'hide-deploy-chatbot', - label: 'Chat', - category: 'Deploy Tabs', - configKey: 'hideDeployChatbot' as const, - hint: 'Hide the chatbot deployment option.', - }, - { - id: 'disable-mcp', - label: 'MCP Tools', - category: 'Tools', - configKey: 'disableMcpTools' as const, - hint: 'Block agents from calling MCP tools.', - }, - { - id: 'disable-custom-tools', - label: 'Custom Tools', - category: 'Tools', - configKey: 'disableCustomTools' as const, - hint: 'Block agents from calling user-defined custom tools.', - }, - { - id: 'disable-skills', - label: 'Skills', - category: 'Tools', - configKey: 'disableSkills' as const, - hint: 'Block agents from loading skills.', - }, - { - id: 'hide-trace-spans', - label: 'Trace Spans', - category: 'Logs', - configKey: 'hideTraceSpans' as const, - hint: 'Hide per-block trace spans in logs.', - }, - { - id: 'disable-invitations', - label: 'Invitations', - category: 'Collaboration', - configKey: 'disableInvitations' as const, - hint: 'Prevent users from inviting others to workspaces.', - }, - { - id: 'hide-inbox', - label: 'Sim Mailer', - category: 'Features', - configKey: 'hideInboxTab' as const, - hint: 'Hide the Sim Mailer inbox.', - }, - { - id: 'disable-public-api', - label: 'Public API', - category: 'Features', - configKey: 'disablePublicApi' as const, - hint: 'Disable public API access to deployed workflows.', - }, - ], - [] - ) + const searchedPlatformFeatures = useMemo(() => { + const search = platformSearchTerm.trim().toLowerCase() + if (!search) return PLATFORM_FEATURES + return PLATFORM_FEATURES.filter( + (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) + ) + }, [platformSearchTerm]) + /** Split from the search pass for the same reason as the provider and block lists. */ const filteredPlatformFeatures = useMemo(() => { - if (!platformSearchTerm.trim()) return platformFeatures - const search = platformSearchTerm.toLowerCase() - return platformFeatures.filter( - (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) + if (platformStatusFilter === 'all') return searchedPlatformFeatures + return searchedPlatformFeatures.filter((f) => + matchesStatusFilter(platformStatusFilter, !editingConfig[f.configKey]) ) - }, [platformFeatures, platformSearchTerm]) + }, [searchedPlatformFeatures, platformStatusFilter, editingConfig]) const platformCategories = useMemo(() => { - const categories: Record = {} + const categories: Record = {} for (const feature of filteredPlatformFeatures) { if (!categories[feature.category]) { categories[feature.category] = [] @@ -821,19 +940,9 @@ export function GroupDetail({ }, [filteredPlatformFeatures]) const platformCategorySections = useMemo(() => { - const order = [ - 'Sidebar', - 'Deploy Tabs', - 'Collaboration', - 'Workflow Panel', - 'Tools', - 'Features', - 'Settings Tabs', - 'Logs', - ] - const known = order.filter((c) => platformCategories[c]?.length) + const known = PLATFORM_CATEGORY_ORDER.filter((c) => platformCategories[c]?.length) const extras = Object.keys(platformCategories).filter( - (c) => c !== 'Files' && !order.includes(c) && platformCategories[c]?.length + (c) => !PLATFORM_CATEGORY_ORDER.includes(c) && platformCategories[c]?.length ) return [...known, ...extras].map((category) => ({ category, @@ -844,20 +953,82 @@ export function GroupDetail({ const hasConfigChanges = useMemo(() => { return JSON.stringify(viewingGroup.config) !== JSON.stringify(editingConfig) }, [viewingGroup.config, editingConfig]) - const guard = useSettingsUnsavedGuard({ isDirty: hasConfigChanges }) - const filteredProviders = useMemo(() => { - if (!providerSearchTerm.trim()) return allProviderIds - const query = providerSearchTerm.toLowerCase() + // Both buffers are seeded trimmed and compared against a trimmed baseline. The + // contract trims name and description on write, but a row stored before those + // schemas gained `.trim()` (or written straight to the API) can still carry + // padding — compared raw it would open dirty with no way to clear it, since + // Discard restores the same padded value. + const trimmedName = editingName.trim() + const trimmedDescription = editingDescription.trim() + const nameChanged = trimmedName !== viewingGroup.name.trim() + const descriptionChanged = trimmedDescription !== (viewingGroup.description ?? '').trim() + const hasChanges = hasConfigChanges || nameChanged || descriptionChanged + + const guard = useSettingsUnsavedGuard({ isDirty: hasChanges }) + + /** + * `null` means "everything allowed". Indexing the allow-lists once keeps the + * per-row membership checks O(1) — they run for every one of the ~200 block + * rows on each render, and again in the section-wide `every(...)` scans. + */ + const allowedIntegrationSet = useMemo( + () => + editingConfig.allowedIntegrations === null + ? null + : new Set(editingConfig.allowedIntegrations), + [editingConfig.allowedIntegrations] + ) + + const allowedProviderSet = useMemo( + () => + editingConfig.allowedModelProviders === null + ? null + : new Set(editingConfig.allowedModelProviders), + [editingConfig.allowedModelProviders] + ) + + const isIntegrationAllowed = useCallback( + (blockType: string) => allowedIntegrationSet === null || allowedIntegrationSet.has(blockType), + [allowedIntegrationSet] + ) + + const isProviderAllowed = useCallback( + (providerId: string) => allowedProviderSet === null || allowedProviderSet.has(providerId), + [allowedProviderSet] + ) + + const searchedProviders = useMemo(() => { + const query = providerSearchTerm.trim().toLowerCase() + if (!query) return allProviderIds return allProviderIds.filter((id) => id.toLowerCase().includes(query)) }, [allProviderIds, providerSearchTerm]) - const filteredBlocks = useMemo(() => { - if (!integrationSearchTerm.trim()) return visibleBlocks - const query = integrationSearchTerm.toLowerCase() + /** + * Split from the search pass so the common `all` case returns the searched + * list by reference — only the status pass depends on the allow-list, so a + * checkbox toggle no longer invalidates downstream consumers. + */ + const filteredProviders = useMemo(() => { + if (providerStatusFilter === 'all') return searchedProviders + return searchedProviders.filter((id) => + matchesStatusFilter(providerStatusFilter, isProviderAllowed(id)) + ) + }, [searchedProviders, providerStatusFilter, isProviderAllowed]) + + const searchedBlocks = useMemo(() => { + const query = integrationSearchTerm.trim().toLowerCase() + if (!query) return visibleBlocks return visibleBlocks.filter((b) => b.name.toLowerCase().includes(query)) }, [visibleBlocks, integrationSearchTerm]) + const filteredBlocks = useMemo(() => { + if (blockStatusFilter === 'all') return searchedBlocks + return searchedBlocks.filter((b) => + matchesStatusFilter(blockStatusFilter, isIntegrationAllowed(b.type)) + ) + }, [searchedBlocks, blockStatusFilter, isIntegrationAllowed]) + const filteredCoreBlocks = useMemo( () => filteredBlocks.filter((block) => block.category === 'blocks'), [filteredBlocks] @@ -886,13 +1057,6 @@ export function GroupDetail({ return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId)) }, [organizationMembers, members]) - const isIntegrationAllowed = useCallback( - (blockType: string) => - editingConfig.allowedIntegrations === null || - editingConfig.allowedIntegrations.includes(blockType), - [editingConfig.allowedIntegrations] - ) - /** * Drops denied tools whose integration is no longer allowed, keeping the * invariant that `deniedTools` only holds tools of currently-allowed blocks. @@ -1003,13 +1167,6 @@ export function GroupDetail({ return counts }, [editingConfig.deniedTools, allBlocks]) - const isProviderAllowed = useCallback( - (providerId: string) => - editingConfig.allowedModelProviders === null || - editingConfig.allowedModelProviders.includes(providerId), - [editingConfig.allowedModelProviders] - ) - const toggleProvider = useCallback( (providerId: string) => { setEditingConfig((prev) => { @@ -1130,7 +1287,7 @@ export function GroupDetail({ const setChatDeployAuthTypes = useCallback((values: string[]) => { // At least one mode must stay allowed while chat deploy is enabled — an empty // allow-list would silently block every chat deployment. To turn chat deploy - // off entirely, use the Hide Chat toggle instead. + // off entirely, uncheck Chat → Deployment instead. if (values.length === 0) return setEditingConfig((prev) => ({ ...prev, @@ -1139,26 +1296,66 @@ export function GroupDetail({ })) }, []) - /** Persists the editing buffer. */ - const handleSaveConfig = useCallback(async () => { + /** + * Nested controls rendered under a platform feature's checkbox, keyed by + * feature id. Kept out of `PLATFORM_FEATURES` so that array stays pure data. + */ + const featureExtras: Partial> = { + 'hide-deploy-chatbot': ( + + ), + 'disable-public-file-sharing': ( + + ), + } + + /** Persists the editing buffer — name/description are only sent when they changed. */ + const handleSaveConfig = async () => { + if (!trimmedName) return try { - await updatePermissionGroup.mutateAsync({ + const result = await updatePermissionGroup.mutateAsync({ id: viewingGroup.id, organizationId, - config: editingConfig, + ...(hasConfigChanges && { config: editingConfig }), + ...(nameChanged && { name: trimmedName }), + ...(descriptionChanged && { description: trimmedDescription || null }), }) - setViewingGroup((prev) => ({ ...prev, config: editingConfig })) + // Reconcile from the server's copy, like the scope/default writes do, so a + // server-side normalization can't leave the dirty check comparing against a + // baseline that was never persisted. Editing buffers are left alone so + // in-flight edits survive and correctly re-mark the form dirty. + const saved = result.permissionGroup + setViewingGroup((prev) => ({ + ...prev, + config: saved.config, + name: saved.name, + description: saved.description, + })) } catch (error) { - logger.error('Failed to update config', error) + logger.error('Failed to save permission group', error) toast.error("Couldn't save changes", { description: getErrorMessage(error, 'Please try again in a moment.'), }) } - }, [viewingGroup.id, editingConfig, organizationId, updatePermissionGroup]) + } - const handleDiscardConfig = useCallback(() => { + const handleDiscardConfig = () => { setEditingConfig({ ...viewingGroup.config }) - }, [viewingGroup.config]) + setEditingName(viewingGroup.name.trim()) + setEditingDescription((viewingGroup.description ?? '').trim()) + } const handleBack = useCallback(() => { guard.guardBack(onBack) @@ -1307,10 +1504,11 @@ export function GroupDetail({ description={viewingGroup.description ?? undefined} actions={[ ...saveDiscardActions({ - dirty: hasConfigChanges, + dirty: hasChanges, saving: updatePermissionGroup.isPending, onSave: handleSaveConfig, onDiscard: handleDiscardConfig, + saveDisabled: !trimmedName, }), { text: deletePermissionGroup.isPending ? 'Deleting...' : 'Delete', @@ -1330,6 +1528,31 @@ export function GroupDetail({ {configTab === 'general' && ( <> + +
+ + setEditingName(e.target.value)} + placeholder='e.g., Marketing Team' + maxLength={100} + error={!trimmedName} + /> + {!trimmedName && ( +

Name is required.

+ )} +
+ + setEditingDescription(e.target.value)} + placeholder='e.g., Limited access for marketing users' + maxLength={500} + /> + +
+
+
@@ -1457,27 +1680,36 @@ export function GroupDetail({ onChange={(e) => setProviderSearchTerm(e.target.value)} className='min-w-0 flex-1' /> + setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)} + disabled={filteredProviders.length === 0} > {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'}
-
- {filteredProviders.map((providerId) => ( - toggleProvider(providerId)} - deniedCount={deniedCountByProvider[providerId] ?? 0} - workspaceId={workspaceId} - isAllowed={isModelAllowed} - onToggle={toggleModel} - onSetDenied={setModelsDenied} - /> - ))} -
+ {filteredProviders.length === 0 ? ( + + No providers match your filters. + + ) : ( +
+ {filteredProviders.map((providerId) => ( + toggleProvider(providerId)} + deniedCount={deniedCountByProvider[providerId] ?? 0} + workspaceId={workspaceId} + isAllowed={isModelAllowed} + onToggle={toggleModel} + onSetDenied={setModelsDenied} + /> + ))} +
+ )}
)} @@ -1491,7 +1723,13 @@ export function GroupDetail({ onChange={(e) => setIntegrationSearchTerm(e.target.value)} className='min-w-0 flex-1' /> + + {filteredCoreBlocks.length === 0 && filteredToolBlocks.length === 0 && ( + + No blocks match your filters. + + )} {filteredCoreBlocks.length > 0 && ( - toggleIntegration(block.type)} - /> -
- {BlockIcon && } -
- {block.name} - + toggleIntegration(block.type)} + /> +
+ {BlockIcon && } +
+ {block.name} + + {block.description && ( + + {block.description} + + )} + ) })} @@ -1579,6 +1826,7 @@ export function GroupDetail({ onChange={(e) => setPlatformSearchTerm(e.target.value)} className='min-w-0 flex-1' /> + setEditingConfig((prev) => ({ @@ -1588,101 +1836,49 @@ export function GroupDetail({ ), })) } + flush + disabled={filteredPlatformFeatures.length === 0} > {platformAllVisible ? 'Deselect All' : 'Select All'} + {platformCategorySections.length === 0 && ( + + No features match your filters. + + )} {platformCategorySections.map(({ category, features }) => (
{features.map((feature) => ( -
- - {feature.hint} +
+
+ + + {feature.hint} + +
+ {featureExtras[feature.id]}
))}
))} - -
- - Auth modes chat deployments may use - - -
-
- -
- -
- - Auth modes public file-share links may use - - -
-
-
)} diff --git a/apps/sim/lib/api/contracts/permission-groups.test.ts b/apps/sim/lib/api/contracts/permission-groups.test.ts index 0b3bf3fb10f..e3460557529 100644 --- a/apps/sim/lib/api/contracts/permission-groups.test.ts +++ b/apps/sim/lib/api/contracts/permission-groups.test.ts @@ -4,8 +4,13 @@ import { describe, expect, it } from 'vitest' import { createPermissionGroupBodySchema, + permissionGroupFullConfigSchema, updatePermissionGroupBodySchema, } from '@/lib/api/contracts/permission-groups' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + parsePermissionGroupConfig, +} from '@/lib/permission-groups/types' describe('createPermissionGroupBodySchema', () => { it('accepts a name-only body (scope is resolved and validated server-side)', () => { @@ -80,3 +85,55 @@ describe('updatePermissionGroupBodySchema', () => { expect(result.success).toBe(false) }) }) + +/** + * The access-control group detail view decides whether its config buffer is + * dirty by comparing `JSON.stringify(savedConfig)` against + * `JSON.stringify(editingConfig)`, and reconciles the saved baseline from the + * update response. That only works while every config that reaches the client — + * from the list route and from the update route alike — carries the same key + * order, which holds because both pass through `parsePermissionGroupConfig` and + * then `permissionGroupFullConfigSchema`. If the two ever drift, the detail view + * would report unsaved changes forever after a successful save. + */ +describe('permissionGroupFullConfigSchema key order', () => { + it('matches parsePermissionGroupConfig so a saved config compares equal', () => { + const stored = parsePermissionGroupConfig({ + allowedIntegrations: ['slack'], + hideCopilot: true, + }) + const overWire = permissionGroupFullConfigSchema.parse(structuredClone(stored)) + expect(JSON.stringify(overWire)).toBe(JSON.stringify(stored)) + }) + + it('keeps an edited client buffer comparable to the server echo', () => { + const fromList = permissionGroupFullConfigSchema.parse( + structuredClone(parsePermissionGroupConfig(DEFAULT_PERMISSION_GROUP_CONFIG)) + ) + const edited = { ...fromList, hideDeployChatbot: true, deniedTools: ['slack_canvas'] } + const serverEcho = permissionGroupFullConfigSchema.parse( + structuredClone(parsePermissionGroupConfig(edited)) + ) + expect(JSON.stringify(serverEcho)).toBe(JSON.stringify(edited)) + }) +}) + +describe('permission group description trimming', () => { + it('trims a padded description on create', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Engineering', + description: ' padded ', + }) + expect(result.success && result.data.description).toBe('padded') + }) + + it('trims a padded description on update', () => { + const result = updatePermissionGroupBodySchema.safeParse({ description: ' padded ' }) + expect(result.success && result.data.description).toBe('padded') + }) + + it('still accepts a null description on update', () => { + const result = updatePermissionGroupBodySchema.safeParse({ description: null }) + expect(result.success && result.data.description).toBeNull() + }) +}) diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 273c47f874d..e3a531d8b43 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -148,7 +148,7 @@ function refineWorkspaceScope( export const createPermissionGroupBodySchema = z .object({ name: z.string().trim().min(1).max(100), - description: z.string().max(500).optional(), + description: z.string().trim().max(500).optional(), config: permissionGroupConfigSchema.optional(), isDefault: z.boolean().optional(), workspaceIds: workspaceIdsSchema.optional(), @@ -158,7 +158,7 @@ export const createPermissionGroupBodySchema = z export const updatePermissionGroupBodySchema = z .object({ name: z.string().trim().min(1).max(100).optional(), - description: z.string().max(500).nullable().optional(), + description: z.string().trim().max(500).nullable().optional(), config: permissionGroupConfigSchema.optional(), isDefault: z.boolean().optional(), workspaceIds: workspaceIdsSchema.optional(), diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx index 244b09b6585..67c4b7a8b02 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx @@ -71,6 +71,20 @@ interface ChipDropdownBaseProps extends VariantProps { leftIcon?: ChipIcon /** Forwarded class for the trigger button. */ className?: string + /** + * Accessible name for the trigger. Use when the visible label sits outside + * the component (a field label above it) rather than in the selected value. + */ + 'aria-label'?: string + /** + * Ids of the elements naming the trigger. Because `aria-labelledby` REPLACES + * the name derived from the trigger's contents, include the trigger's own id + * alongside the external label's — `\`${labelId} ${triggerId}\`` — or the + * selected value is dropped from the accessible name. + */ + 'aria-labelledby'?: string + /** Id for the trigger button. Needed to reference it from `aria-labelledby`. */ + id?: string } /** @@ -168,6 +182,9 @@ const ChipDropdown = forwardRef( active, fullWidth, flush, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + id, } = props const isMultiple = props.multiple === true @@ -285,8 +302,11 @@ const ChipDropdown = forwardRef( Branch in new chat From d24bc7eccb60cdf3b5828e98fbc2ce96ac53384a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:39:03 -0700 Subject: [PATCH 11/32] feat(agent-stream): thinking and tool streaming (#5671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-stream): add agent-events thinking/tool streaming for chat and canvas Ship the agent-events-v1 protocol with provider tool loops, dual-gated chat thinking, DeepSeek/Groq/OpenAI reasoning wiring, and ChatGPT-like thinking chrome. Co-authored-by: Cursor * fix(agent-stream): clear stuck streaming UI and format db snapshot Biome was failing CI on migrations/meta/0261_snapshot.json. Also settle assistant streaming/tool flags when SSE ends without a terminal frame, without clobbering Stop's finalized content. Co-authored-by: Cursor * fix(agent-stream): satisfy biome format and import order Auto-format the sim package for CI lint:check, and repair the Anthropic streaming tool-loop payload after an unsafe delete-to-undefined rewrite. Co-authored-by: Cursor * fix(agent-stream): keep drained answer on abort and update migration journal test Treat AbortError from reader.cancel as a cancelled pump result so soft-complete retains answerText. Point the workspace storage migration journal assertion at 0261_chat_include_thinking. Co-authored-by: Cursor * fix(chat): keep Stop notice when server emits cancel error Ignore terminal SSE error frames after the user aborts so "Client cancelled request" cannot overwrite "Response stopped by user". Co-authored-by: Cursor * improvement(chat): ChatGPT-style thinking shimmer and stick-to-bottom scroll Add left-to-right shimmer on live thinking label/body, keep scroll working by shimmering an inner node, and follow the answer only while near the bottom. Co-authored-by: Cursor * fix(agent-stream): stop pump on client disconnect; soft-complete agents only Abort the agent stream pump when the projected HTTP body is cancelled so provider work does not continue after disconnect. Limit AbortError soft-success to Agent blocks so Function/HTTP cancels still fail in logs. Co-authored-by: Cursor * fix(agent-stream): persist includeThinking across pause snapshots Paused chat runs with Include thinking enabled were dropping the flag when serializing the pause snapshot, so resume always rebuilt streams without thinking/tool SSE frames. Co-authored-by: Cursor * fix(agent-stream): keep drained answer text when stream times out Persist pump answerText onto the streaming execution before throwing on timeout, and carry that partial content into the failed block output so logs match what the client already saw. Co-authored-by: Cursor * improvement(chat): auto-collapse tools chrome when tool streaming ends Match thinking UX: open while tools run, collapse when finished, and keep the panel open only if the user manually reopens it. Co-authored-by: Cursor * fix(agent-stream): settle canvas stream chrome on failure paths Clear agentStreamActive and settle running tool chips when blocks error, timeouts cancel runs, or execution ends without stream:done so the output panel does not stay on live Thinking/Using tools chrome. Co-authored-by: Cursor * fix(lint): organize imports in terminal console store Co-authored-by: Cursor * fix(agent-stream): mark open tools cancelled on HITL pause Pause can interrupt a tool loop without tool end events; settling those chips as success incorrectly showed unfinished tools as complete. Co-authored-by: Cursor * chore(db): drop branch-local 0261 migration ahead of staging merge * chore(db): regenerate include_thinking migration as 0266 post staging merge * fix(providers): resolve type errors in streaming tool loop call sites * fix(agent-stream): gate agent events opt-in and correct provider loop behavior - streamToolCalls and provider thinking requests now require run-level agentEvents opt-in (canvas on, chat dual-gated, API off) so existing runs keep pre-agent-events behavior exactly - OpenAI reasoning summaries opt-in + strip-and-retry on unverified-org 400 - streaming loops run tool postProcess again (firecrawl/exa async results) - bedrock live loop falls back to silent path for responseFormat - deepseek: reasoning_content pass-back unconditional, 'none' sends disabled - groq: x_groq.usage fallback, reasoning params gated, qwen none disables - gemini: functionCall parts echoed verbatim, local ids only for events - truncated turns (max_tokens/length) no longer execute partial tool calls - MAX_TOOL_ITERATIONS exit flushes last turn text as final answer - iterations reports actual model calls; shared loop plumbing extracted * refactor(agent-stream): consolidate protocol, dedupe client/server plumbing, hygiene - canonical ChatStreamFrame union + type guards consumed by server emitters and the chat client; stream_error restored to legacy log-only handling - strip thinking/tool args from providerTiming on public final envelopes - shared tool-chip lifecycle module for chat, canvas, and console store - shared sink-to-execution-events forwarder replaces the copy-pasted adapter in the execute route and HITL manager; LIVE_ONLY event set shared - stream:thinking payload field renamed data->text; canvas thinking batched - abort reasons carried as AbortError DOMExceptions so raw fetch consumers classify correctly; thinking cap renamed to chars and scope-documented - kimi wired for agent events like the other compat providers - deleted dead exports/step-N comments; fixtures match real wire shapes; loop tests use explicit mocks instead of importOriginal * test(agent-stream): cover the dual-gated execution path and typed abort reasons - chat route tests assert agentEvents reaches executeWorkflow only when policy and protocol header agree - execution-limits tests assert AbortError-typed reasons - executor metadata type carries agentEvents * fix(deploy-modal): align include-thinking spacing with the modal's 6.5px rhythm * docs(agent-stream): autogenerate per-model thinking/tool stream support on the Agent block page - capabilities.thinking.streamed ('full' | 'summary' | 'none') on models.ts, explicit for the Anthropic family where visibility varies per generation; getThinkingStreamVisibility exposes the derivation for docs and UI alike - scripts/sync-agent-stream-docs.ts regenerates the support tables between markers in workflows/blocks/agent.mdx from the model registry and STREAMING_TOOL_CALL_PROVIDERS; --check fails on drift or missing metadata - wired agent-stream-docs:check into CI next to the other sync gates * feat(anthropic): request summarized thinking display for omitted-default Claude models The newest Claude generations (Fable 5, Sonnet 5, Opus 4.8/4.7) default thinking.display to omitted — empty thinking blocks, no deltas. On agent-events runs Sim now opts back in with display: 'summarized', driven by the registry's streamed metadata; legacy runs keep the exact pre-agent-events request shape. Registry, generated docs, and the family capability table updated accordingly. * docs(skills): cover thinking.streamed and agent-stream docs sync in model skills * chore(deps): upgrade @anthropic-ai/sdk to 0.114.0 and adopt official types - adaptive thinking, display, and output_config are now SDK-typed; the only remaining custom payload field is output_format (beta-header structured outputs, which the SDK models as output_config.format instead) - anthropic stream events narrow on the SDK's discriminated unions instead of anonymous casts; compat deltas type content/tool_calls from the OpenAI SDK with vendor reasoning fields as an explicit optional extension - @sim/auth exposes an explicit VerifyAuth contract so its declarations no longer reference better-auth's nested zod instance (TS2883 under fresh install layouts); realtime consumer aligned - docs app zod pinned to the repo's exact 4.3.6 so ai SDK types bind the same zod instance (docs type-check was latently broken) - knowledge embedding tests made hermetic against local .env keys and hosted rotation fallback * refactor(providers): replace legacy as-any stream casts with annotated typed casts * refactor(providers): finish provider audit — remove dead byte-stream helper, annotate remaining legacy casts Audit of all 26 providers for the agent-events feature confirmed every streaming execution declares agent-events-v1 and every adapter emits AgentStreamEvent objects. Cleanup from the audit: the unconsumed legacy createOpenAICompatibleStream byte helper is deleted, and the remaining streamResponse-as-any casts (xai, nvidia, kimi, meta, zai, sakana) are annotated typed casts matching the groq/deepseek fix. * feat(streaming): stream answer text live during tool loops via turn_end protocol The live tool loops buffered all answer text per model turn (classification of intermediate vs final is only known at turn end), so gated surfaces saw thinking stream, then dead air with the thinking chrome stuck open, then the whole answer at once. Loops now emit text deltas live as `turn: 'pending'` plus a `turn_end` event per turn. The pump buffers pending text and projects it to the byte path (answerText/logs/memory/legacy clients) only on a final turn_end, so all settled semantics are unchanged. Gated surfaces render the pending text as it streams and reconcile with a reset when a turn resolves to tools: - public chat: live `chunk` frames from the sink + dual-gated `chunk_reset`; byte-path frame emission is suppressed to avoid duplicates (kept for response-format transformed streams via clientStreamTransformed) - canvas: forwarder emits live `stream:chunk` + `stream:chunk_reset`; the execute route and HITL resume readers stop re-emitting byte chunks; panel chat tracks per-block segments and replaces content on flush - chat client: per-block text segments, chunk_reset handling, and thinking chrome now settles on tool start as well as first answer chunk * fix(streaming): address validated review findings across provider gating and reset reconciliation Three-reviewer pass over the branch, findings validated against staging: - agent-handler forwards agentEvents to executeProviderRequest — the flag was computed but dropped in the field-by-field copy, so provider-side thinking requests (OpenAI summaries, Gemini includeThoughts, Anthropic summarized display) never activated on opted-in runs - openai: restore summary:'auto' alongside explicit reasoning effort — staging always paired them; gating summary purely on agentEvents changed legacy payloads - gemini: Gemini 2 + tools + responseFormat falls back to the silent path; the live loop never applied the deferred responseSchema for AUTO tools - openai-compat loop: malformed tool-argument JSON fails the call instead of executing with defaulted {} args (staging parsed inside the execution try) - openai-compat parser: a vendor id arriving after a synthesized start no longer renames the call (start/end ids stayed consistent) - stream-pump: abort closes the byte projection so a drain blocked on backpressure cannot deadlock teardown - chunk_reset removes the block from the client text order (deployed chat + panel chat) so a reset block re-registers at arrival position — fixes separator/order corruption when parallel blocks stream around a reset - resume route echoes the negotiated X-Sim-Stream-Protocol response header (parity with the chat route); docs: [DONE] wire shape + final-vs-error terminal semantics corrected * chore(deps): exempt pinned @anthropic-ai/sdk 0.114.0 from the release-age gate CI's bun install --frozen-lockfile blocks 0.114.0 (published 2026-07-23, younger than the 7-day supply-chain gate). The pin is exact and was vetted for the agent-events streaming work; following the existing bunfig pattern, the exclusion ages out on 2026-07-30 and should be dropped then. * chore(providers): fix double-cast-allowed annotation placement for the strict boundary audit The audit only recognizes the annotation on the line directly above the cast; two annotations had drifted behind intervening code lines (groq stream params, deepseek loop messages) and the OpenAI reasoning-summary widening cast was never annotated. No behavior change. * fix(chat): settle straggler tool chips as error when final reports failure A failed run can still terminate with a `final` frame carrying success: false; running chips previously settled green regardless of the outcome. * fix(canvas): wire agent stream chrome into run-from-block Run-from-block executions emit the same live stream:thinking/stream:tool events as full runs but registered none of the handlers, so the terminal never showed thinking or tool chips on that path. The per-run chrome (batched thinking writes + tool chip lifecycle + settlement on stream done, block error, and every terminal execution state) is extracted into a shared createAgentStreamChrome factory consumed by both paths. --------- Co-authored-by: Bill Leoutsakos Co-authored-by: Cursor Co-authored-by: Vikhyath Mondreti --- .agents/skills/add-model/SKILL.md | 12 + .agents/skills/validate-model/SKILL.md | 1 + .claude/commands/add-model.md | 12 + .claude/commands/validate-model.md | 1 + .cursor/commands/add-model.md | 12 + .cursor/commands/validate-model.md | 1 + .github/workflows/test-build.yml | 3 + .../docs/en/workflows/blocks/agent.mdx | 25 + .../en/workflows/deployment/agent-events.mdx | 100 + .../docs/en/workflows/deployment/chat.mdx | 1 + .../docs/en/workflows/deployment/meta.json | 2 +- apps/docs/package.json | 2 +- apps/realtime/src/middleware/auth.ts | 2 +- .../(interfaces)/chat/[identifier]/chat.tsx | 72 +- .../chat/components/message/message.test.tsx | 167 +- .../chat/components/message/message.tsx | 53 +- .../chat/hooks/use-chat-streaming.test.tsx | 691 + .../chat/hooks/use-chat-streaming.ts | 340 +- .../app/api/chat/[identifier]/otp/route.ts | 2 + .../app/api/chat/[identifier]/route.test.ts | 83 + apps/sim/app/api/chat/[identifier]/route.ts | 29 +- apps/sim/app/api/chat/manage/[id]/route.ts | 6 + apps/sim/app/api/chat/route.ts | 2 + .../app/api/knowledge/search/utils.test.ts | 19 +- apps/sim/app/api/knowledge/utils.test.ts | 19 +- apps/sim/app/api/providers/route.ts | 6 +- .../[executionId]/[contextId]/route.ts | 13 +- .../workflows/[id]/chat/status/route.test.ts | 2 + .../api/workflows/[id]/chat/status/route.ts | 2 + .../app/api/workflows/[id]/execute/route.ts | 36 +- .../w/[workflowId]/components/chat/chat.tsx | 66 +- .../deploy-modal/components/chat/chat.tsx | 20 + .../components/output-panel/output-panel.tsx | 29 + .../hooks/use-workflow-execution.ts | 197 +- .../agent-stream/agent-stream-chrome.test.tsx | 286 + .../agent-stream/agent-stream-chrome.tsx | 246 + .../agent-stream/tool-call-lifecycle.ts | 117 + .../sim/components/ui/shimmer-text.module.css | 5 +- apps/sim/components/ui/shimmer-text.tsx | 20 +- .../executor/execution/block-executor.test.ts | 366 + apps/sim/executor/execution/block-executor.ts | 262 +- .../execution/snapshot-serializer.test.ts | 23 + .../executor/execution/snapshot-serializer.ts | 4 + apps/sim/executor/execution/types.ts | 12 + .../handlers/agent/agent-handler.test.ts | 80 + .../executor/handlers/agent/agent-handler.ts | 18 +- apps/sim/executor/types.ts | 31 + apps/sim/hooks/queries/chats.ts | 3 + apps/sim/hooks/use-execution-stream.test.ts | 55 + apps/sim/hooks/use-execution-stream.ts | 15 + .../contracts/chats.include-thinking.test.ts | 76 + apps/sim/lib/api/contracts/chats.ts | 11 +- apps/sim/lib/api/contracts/deployments.ts | 1 + .../tools/handlers/deployment/deploy.ts | 7 + .../tools/handlers/deployment/manage.ts | 2 + .../lib/copilot/tools/handlers/param-types.ts | 1 + .../lib/core/execution-limits/types.test.ts | 32 + apps/sim/lib/core/execution-limits/types.ts | 19 +- .../workflows/executor/execute-workflow.ts | 9 + .../workflows/executor/execution-events.ts | 69 + .../executor/human-in-the-loop-manager.ts | 26 +- .../workflows/orchestration/chat-deploy.ts | 5 + .../streaming/agent-stream-protocol.test.ts | 85 + .../streaming/agent-stream-protocol.ts | 188 + .../forward-agent-stream-events.test.ts | 174 + .../streaming/forward-agent-stream-events.ts | 117 + .../lib/workflows/streaming/streaming.test.ts | 526 + apps/sim/lib/workflows/streaming/streaming.ts | 304 +- apps/sim/package.json | 2 +- .../__fixtures__/anthropic/fixtures.test.ts | 194 + .../providers/__fixtures__/anthropic/index.ts | 17 + .../anthropic/redacted-thinking-signature.ts | 98 + .../anthropic/thinking-text-tool.ts | 118 + .../__fixtures__/openai-compat/index.ts | 69 + .../sim/providers/agent-events-smokes.test.ts | 67 + .../providers/anthropic/core.thinking.test.ts | 55 + apps/sim/providers/anthropic/core.ts | 132 +- .../anthropic/streaming-tool-loop.test.ts | 341 + .../anthropic/streaming-tool-loop.ts | 544 + apps/sim/providers/anthropic/utils.test.ts | 106 + apps/sim/providers/anthropic/utils.ts | 103 +- apps/sim/providers/azure-openai/index.ts | 2 + apps/sim/providers/azure-openai/utils.ts | 19 +- apps/sim/providers/baseten/index.ts | 2 + apps/sim/providers/baseten/utils.ts | 19 +- apps/sim/providers/bedrock/index.ts | 66 +- .../bedrock/streaming-tool-loop.test.ts | 143 + .../providers/bedrock/streaming-tool-loop.ts | 535 + .../providers/bedrock/utils.stream.test.ts | 49 + apps/sim/providers/bedrock/utils.ts | 12 +- apps/sim/providers/cerebras/index.ts | 2 + apps/sim/providers/cerebras/utils.ts | 18 +- apps/sim/providers/deepseek/index.test.ts | 99 + apps/sim/providers/deepseek/index.ts | 199 +- apps/sim/providers/deepseek/utils.ts | 18 +- apps/sim/providers/fireworks/index.ts | 2 + apps/sim/providers/fireworks/utils.ts | 19 +- apps/sim/providers/gemini/core.ts | 99 +- .../gemini/streaming-tool-loop.test.ts | 168 + .../providers/gemini/streaming-tool-loop.ts | 536 + .../sim/providers/google/utils.stream.test.ts | 77 + apps/sim/providers/google/utils.ts | 30 +- apps/sim/providers/groq/index.test.ts | 131 + apps/sim/providers/groq/index.ts | 190 +- apps/sim/providers/groq/utils.ts | 18 +- apps/sim/providers/kimi/index.ts | 83 +- apps/sim/providers/kimi/utils.ts | 18 +- apps/sim/providers/litellm/index.ts | 2 + apps/sim/providers/litellm/utils.ts | 18 +- apps/sim/providers/meta/index.ts | 83 +- apps/sim/providers/meta/utils.ts | 18 +- apps/sim/providers/mistral/index.ts | 2 + apps/sim/providers/mistral/utils.ts | 18 +- apps/sim/providers/models.ts | 121 +- apps/sim/providers/nvidia/index.ts | 83 +- apps/sim/providers/nvidia/utils.ts | 18 +- apps/sim/providers/ollama-cloud/utils.ts | 18 +- apps/sim/providers/ollama/core.ts | 7 +- apps/sim/providers/ollama/utils.ts | 18 +- .../openai-compat/stream-events.test.ts | 156 + .../providers/openai-compat/stream-events.ts | 221 + .../openai-compat/streaming-tool-loop.test.ts | 375 + .../openai-compat/streaming-tool-loop.ts | 486 + .../providers/openai/core.reasoning.test.ts | 165 + apps/sim/providers/openai/core.ts | 134 +- .../sim/providers/openai/utils.stream.test.ts | 75 + apps/sim/providers/openai/utils.ts | 41 +- apps/sim/providers/openrouter/index.ts | 2 + apps/sim/providers/openrouter/utils.ts | 15 +- apps/sim/providers/sakana/index.ts | 83 +- apps/sim/providers/sakana/utils.ts | 18 +- apps/sim/providers/stream-events.test.ts | 97 + apps/sim/providers/stream-events.ts | 142 + apps/sim/providers/stream-pump.test.ts | 665 + apps/sim/providers/stream-pump.ts | 505 + .../sim/providers/streaming-execution.test.ts | 39 + apps/sim/providers/streaming-execution.ts | 13 +- .../providers/streaming-tool-loop-shared.ts | 66 + apps/sim/providers/together/index.ts | 2 + apps/sim/providers/together/utils.ts | 19 +- apps/sim/providers/tool-call-id.ts | 27 + apps/sim/providers/types.ts | 12 + apps/sim/providers/utils.test.ts | 11 + apps/sim/providers/utils.ts | 59 - apps/sim/providers/vllm/index.ts | 2 + apps/sim/providers/vllm/utils.ts | 19 +- apps/sim/providers/xai/index.ts | 49 +- apps/sim/providers/xai/utils.ts | 19 +- apps/sim/providers/zai/index.ts | 83 +- apps/sim/providers/zai/utils.ts | 18 +- apps/sim/stores/chat/store.ts | 8 + apps/sim/stores/chat/types.ts | 2 + .../sim/stores/terminal/console/store.test.ts | 96 + apps/sim/stores/terminal/console/store.ts | 56 + apps/sim/stores/terminal/console/types.ts | 10 + bun.lock | 21 +- bunfig.toml | 4 + package.json | 2 + packages/auth/src/verify.ts | 65 +- .../db/migrations/0266_spotty_alex_power.sql | 1 + .../db/migrations/meta/0266_snapshot.json | 17413 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 7 + packages/testing/src/mocks/schema.mock.ts | 1 + scripts/sync-agent-stream-docs.ts | 185 + 165 files changed, 30702 insertions(+), 823 deletions(-) create mode 100644 apps/docs/content/docs/en/workflows/deployment/agent-events.mdx create mode 100644 apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx create mode 100644 apps/sim/components/agent-stream/agent-stream-chrome.test.tsx create mode 100644 apps/sim/components/agent-stream/agent-stream-chrome.tsx create mode 100644 apps/sim/components/agent-stream/tool-call-lifecycle.ts create mode 100644 apps/sim/lib/api/contracts/chats.include-thinking.test.ts create mode 100644 apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts create mode 100644 apps/sim/lib/workflows/streaming/agent-stream-protocol.ts create mode 100644 apps/sim/lib/workflows/streaming/forward-agent-stream-events.test.ts create mode 100644 apps/sim/lib/workflows/streaming/forward-agent-stream-events.ts create mode 100644 apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts create mode 100644 apps/sim/providers/__fixtures__/anthropic/index.ts create mode 100644 apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts create mode 100644 apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts create mode 100644 apps/sim/providers/__fixtures__/openai-compat/index.ts create mode 100644 apps/sim/providers/agent-events-smokes.test.ts create mode 100644 apps/sim/providers/anthropic/core.thinking.test.ts create mode 100644 apps/sim/providers/anthropic/streaming-tool-loop.test.ts create mode 100644 apps/sim/providers/anthropic/streaming-tool-loop.ts create mode 100644 apps/sim/providers/anthropic/utils.test.ts create mode 100644 apps/sim/providers/bedrock/streaming-tool-loop.test.ts create mode 100644 apps/sim/providers/bedrock/streaming-tool-loop.ts create mode 100644 apps/sim/providers/bedrock/utils.stream.test.ts create mode 100644 apps/sim/providers/deepseek/index.test.ts create mode 100644 apps/sim/providers/gemini/streaming-tool-loop.test.ts create mode 100644 apps/sim/providers/gemini/streaming-tool-loop.ts create mode 100644 apps/sim/providers/google/utils.stream.test.ts create mode 100644 apps/sim/providers/groq/index.test.ts create mode 100644 apps/sim/providers/openai-compat/stream-events.test.ts create mode 100644 apps/sim/providers/openai-compat/stream-events.ts create mode 100644 apps/sim/providers/openai-compat/streaming-tool-loop.test.ts create mode 100644 apps/sim/providers/openai-compat/streaming-tool-loop.ts create mode 100644 apps/sim/providers/openai/core.reasoning.test.ts create mode 100644 apps/sim/providers/openai/utils.stream.test.ts create mode 100644 apps/sim/providers/stream-events.test.ts create mode 100644 apps/sim/providers/stream-events.ts create mode 100644 apps/sim/providers/stream-pump.test.ts create mode 100644 apps/sim/providers/stream-pump.ts create mode 100644 apps/sim/providers/streaming-tool-loop-shared.ts create mode 100644 apps/sim/providers/tool-call-id.ts create mode 100644 packages/db/migrations/0266_spotty_alex_power.sql create mode 100644 packages/db/migrations/meta/0266_snapshot.json create mode 100644 scripts/sync-agent-stream-docs.ts diff --git a/.agents/skills/add-model/SKILL.md b/.agents/skills/add-model/SKILL.md index 34aea3031fb..5342b086f28 100644 --- a/.agents/skills/add-model/SKILL.md +++ b/.agents/skills/add-model/SKILL.md @@ -52,6 +52,7 @@ Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, | `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | | `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | | `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | +| `thinking.streamed` | Docs generator + `getThinkingStreamVisibility` (`models.ts`); `anthropic/core.ts` uses `'summary'` to request `display: 'summarized'` on agent-events runs | **Mandatory on Anthropic-family thinking models** (`agent-stream-docs:check` fails without it); other families fall back to provider defaults | | `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | | `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | | `computerUse` | `anthropic/core.ts` | Dead elsewhere | @@ -146,6 +147,15 @@ If anything matches, run the affected provider tests and update assertions as ne The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). +### Thinking/reasoning models: `streamed` visibility + generated docs + +If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: + +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`) — visibility varies per Claude generation. Verify against the provider's thinking/display docs (e.g. Anthropic's "controlling thinking display" page and per-model "what's new" notes): generations that default `thinking.display` to `omitted` (Opus 4.7+, Sonnet 5, Fable 5) are `'summary'` — Sim opts back in with `display: 'summarized'` on agent-events runs; older generations that return full thinking deltas are `'full'`. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries, Bedrock → none, OpenAI-compat vendors → full raw CoT). Set it explicitly only when the model deviates from its family. +- After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. +- Include the `streamed` value (with its source URL) in the verification report when set. + ### Wrong family entirely? - **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. @@ -155,6 +165,7 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b ```bash bun run lint +bun run agent-stream-docs:generate # only when the entry has thinking/reasoningEffort ``` Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. @@ -201,6 +212,7 @@ Omitting a field is **not the same as verifying it**. Any field you cannot confi - ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) - ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) - ❌ Setting `thinking` on non-Anthropic/non-Gemini providers +- ❌ Adding an Anthropic-family thinking model without `thinking.streamed` (CI `agent-stream-docs:check` fails), or skipping `bun run agent-stream-docs:generate` after adding any thinking/reasoning model - ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x - ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date - ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) diff --git a/.agents/skills/validate-model/SKILL.md b/.agents/skills/validate-model/SKILL.md index d40f58d9eee..d7d9cc88c6f 100644 --- a/.agents/skills/validate-model/SKILL.md +++ b/.agents/skills/validate-model/SKILL.md @@ -89,6 +89,7 @@ For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, - [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag. - [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs - [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs +- [ ] `thinking.streamed` — REQUIRED on Anthropic-family thinking models (`'full'` for generations returning full thinking deltas, `'summary'` for omitted-display generations like Opus 4.7+/Sonnet 5/Fable 5 where Sim requests `display: 'summarized'`); verify against the provider's thinking-display docs. After any change, run `bun run agent-stream-docs:generate` so the Agent block docs table stays in sync (CI diffs it) - [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model - [ ] `toolUsageControl` — provider supports `tool_choice` semantics - [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU diff --git a/.claude/commands/add-model.md b/.claude/commands/add-model.md index b10b57f6409..b1366813d3f 100644 --- a/.claude/commands/add-model.md +++ b/.claude/commands/add-model.md @@ -51,6 +51,7 @@ Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, | `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | | `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | | `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | +| `thinking.streamed` | Docs generator + `getThinkingStreamVisibility` (`models.ts`); `anthropic/core.ts` uses `'summary'` to request `display: 'summarized'` on agent-events runs | **Mandatory on Anthropic-family thinking models** (`agent-stream-docs:check` fails without it); other families fall back to provider defaults | | `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | | `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | | `computerUse` | `anthropic/core.ts` | Dead elsewhere | @@ -145,6 +146,15 @@ If anything matches, run the affected provider tests and update assertions as ne The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). +### Thinking/reasoning models: `streamed` visibility + generated docs + +If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: + +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`) — visibility varies per Claude generation. Verify against the provider's thinking/display docs (e.g. Anthropic's "controlling thinking display" page and per-model "what's new" notes): generations that default `thinking.display` to `omitted` (Opus 4.7+, Sonnet 5, Fable 5) are `'summary'` — Sim opts back in with `display: 'summarized'` on agent-events runs; older generations that return full thinking deltas are `'full'`. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries, Bedrock → none, OpenAI-compat vendors → full raw CoT). Set it explicitly only when the model deviates from its family. +- After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. +- Include the `streamed` value (with its source URL) in the verification report when set. + ### Wrong family entirely? - **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. @@ -154,6 +164,7 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b ```bash bun run lint +bun run agent-stream-docs:generate # only when the entry has thinking/reasoningEffort ``` Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. @@ -200,6 +211,7 @@ Omitting a field is **not the same as verifying it**. Any field you cannot confi - ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) - ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) - ❌ Setting `thinking` on non-Anthropic/non-Gemini providers +- ❌ Adding an Anthropic-family thinking model without `thinking.streamed` (CI `agent-stream-docs:check` fails), or skipping `bun run agent-stream-docs:generate` after adding any thinking/reasoning model - ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x - ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date - ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) diff --git a/.claude/commands/validate-model.md b/.claude/commands/validate-model.md index 64e589baff4..c49f2bb3ece 100644 --- a/.claude/commands/validate-model.md +++ b/.claude/commands/validate-model.md @@ -88,6 +88,7 @@ For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, - [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag. - [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs - [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs +- [ ] `thinking.streamed` — REQUIRED on Anthropic-family thinking models (`'full'` for generations returning full thinking deltas, `'summary'` for omitted-display generations like Opus 4.7+/Sonnet 5/Fable 5 where Sim requests `display: 'summarized'`); verify against the provider's thinking-display docs. After any change, run `bun run agent-stream-docs:generate` so the Agent block docs table stays in sync (CI diffs it) - [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model - [ ] `toolUsageControl` — provider supports `tool_choice` semantics - [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU diff --git a/.cursor/commands/add-model.md b/.cursor/commands/add-model.md index d123d2a2291..d542c6a872a 100644 --- a/.cursor/commands/add-model.md +++ b/.cursor/commands/add-model.md @@ -46,6 +46,7 @@ Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, | `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming | | `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere | | `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere | +| `thinking.streamed` | Docs generator + `getThinkingStreamVisibility` (`models.ts`); `anthropic/core.ts` uses `'summary'` to request `display: 'summarized'` on agent-events runs | **Mandatory on Anthropic-family thinking models** (`agent-stream-docs:check` fails without it); other families fall back to provider defaults | | `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras | | `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap | | `computerUse` | `anthropic/core.ts` | Dead elsewhere | @@ -140,6 +141,15 @@ If anything matches, run the affected provider tests and update assertions as ne The Consumption Matrix (Step 2) tells you which capability *flags* are honored by existing provider code. But if the new model needs **net-new** request handling that the provider doesn't implement yet — a new beta header (e.g. Anthropic's `anthropic-beta` structured-outputs header in `anthropic/index.ts`), a new thinking/reasoning encoding, a Responses-API quirk — you must edit `apps/sim/providers//core.ts` / `index.ts`. Setting a flag whose behavior isn't implemented is a silent no-op. When you do edit provider code, reuse the shared helpers rather than hand-rolling: streaming responses are assembled via `createStreamingExecution` (`@/providers/streaming-execution`) and tool schemas via `adaptOpenAIChatToolSchema` / `adaptAnthropicToolSchema` (`@/providers/tool-schema-adapter`). +### Thinking/reasoning models: `streamed` visibility + generated docs + +If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: + +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`) — visibility varies per Claude generation. Verify against the provider's thinking/display docs (e.g. Anthropic's "controlling thinking display" page and per-model "what's new" notes): generations that default `thinking.display` to `omitted` (Opus 4.7+, Sonnet 5, Fable 5) are `'summary'` — Sim opts back in with `display: 'summarized'` on agent-events runs; older generations that return full thinking deltas are `'full'`. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries, Bedrock → none, OpenAI-compat vendors → full raw CoT). Set it explicitly only when the model deviates from its family. +- After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. +- Include the `streamed` value (with its source URL) in the verification report when set. + ### Wrong family entirely? - **Embedding or rerank model** → it does NOT go in the `models[]` array. Use `EMBEDDING_MODEL_PRICING` / `RERANK_MODEL_PRICING` in `models.ts` instead. @@ -149,6 +159,7 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b ```bash bun run lint +bun run agent-stream-docs:generate # only when the entry has thinking/reasoningEffort ``` Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing. @@ -195,6 +206,7 @@ Omitting a field is **not the same as verifying it**. Any field you cannot confi - ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only) - ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it) - ❌ Setting `thinking` on non-Anthropic/non-Gemini providers +- ❌ Adding an Anthropic-family thinking model without `thinking.streamed` (CI `agent-stream-docs:check` fails), or skipping `bun run agent-stream-docs:generate` after adding any thinking/reasoning model - ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x - ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date - ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number) diff --git a/.cursor/commands/validate-model.md b/.cursor/commands/validate-model.md index 6485b5fd683..e5434725fca 100644 --- a/.cursor/commands/validate-model.md +++ b/.cursor/commands/validate-model.md @@ -83,6 +83,7 @@ For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, - [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag. - [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs - [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs +- [ ] `thinking.streamed` — REQUIRED on Anthropic-family thinking models (`'full'` for generations returning full thinking deltas, `'summary'` for omitted-display generations like Opus 4.7+/Sonnet 5/Fable 5 where Sim requests `display: 'summarized'`); verify against the provider's thinking-display docs. After any change, run `bun run agent-stream-docs:generate` so the Agent block docs table stays in sync (CI diffs it) - [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model - [ ] `toolUsageControl` — provider supports `tool_choice` semantics - [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 8e7ff3e9eab..a7e3659060b 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -140,6 +140,9 @@ jobs: - name: Verify skill projections are in sync run: bun run skills:check + - name: Verify agent stream capability docs are in sync + run: bun run agent-stream-docs:check + - name: Migration safety (zero-downtime) audit run: | if [ "${{ github.event_name }}" = "pull_request" ]; then diff --git a/apps/docs/content/docs/en/workflows/blocks/agent.mdx b/apps/docs/content/docs/en/workflows/blocks/agent.mdx index 98be73e53c3..ccfc2d8fbe8 100644 --- a/apps/docs/content/docs/en/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/agent.mdx @@ -96,6 +96,31 @@ After the agent runs, later blocks read its result by name: When a response format is set, its fields are readable directly, like ``. +## Streamed thinking and tool calls + +While an agent runs, Sim can stream its thinking and tool lifecycle live — the canvas terminal always shows them, and a [deployed chat](/docs/workflows/deployment/agent-events) shows them when its **Include thinking** setting and the client's protocol opt-in agree. What actually streams depends on the model: models marked with full deltas or summaries stream thinking when a Thinking level or Reasoning effort is set (DeepSeek reasoner models always reason); a model marked "Not streamed" thinks internally but its provider withholds the text. + +{/* agent-stream-capabilities:begin — generated by `bun run agent-stream-docs:generate`; do not edit between markers */} + +Live tool-call chips stream for **Anthropic, Azure Anthropic, Google, Vertex AI, DeepSeek, Groq, AWS Bedrock** models. Other providers run tools without live chips — tool results still appear in the block output when the run completes. + +| Provider | Streamed thinking | Models | +|----------|-------------------|--------| +| OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | +| Anthropic | Full thinking deltas | `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-opus-4-1`, `claude-sonnet-4-5`, `claude-haiku-4-5` | +| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5`, `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7` | +| Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | +| Azure Anthropic | Full thinking deltas | `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | +| Google | Summaries only | `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Vertex AI | Summaries only | `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | +| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-chat`, `deepseek-reasoner` | +| Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.6-27b` | +| Meta | Full thinking deltas | `muse-spark-1.1` | +| Kimi | Full thinking deltas | `kimi-k2.6` | +| Z.ai | Full thinking deltas | `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | + +{/* agent-stream-capabilities:end */} + ## Example A workflow that reads an incoming customer message and classifies it: diff --git a/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx new file mode 100644 index 00000000000..f3110dc995d --- /dev/null +++ b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx @@ -0,0 +1,100 @@ +--- +title: Agent stream events +description: Protocol for streaming thinking and tool lifecycle from Agent blocks +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Agent blocks can emit more than answer text while they run: **provider-exposed thinking** (or reasoning summaries) and a **tool-call lifecycle** (name + status). Sim delivers those as typed events on an opt-in stream protocol. + + +Sim does **not** invent thinking for providers that do not stream it. Bedrock Converse, many OpenAI-compat models, and non-reasoning chat models stay text-only (plus tools when a live tool loop is wired). + + +## Dual gate (public chat / simple SSE) + +Thinking and tool frames leave the public chat or workflow simple-SSE path only when **both** are true: + +1. Deployment policy: chat `includeThinking` is enabled (default **off**). +2. Request opts in with header: + +```http +X-Sim-Stream-Protocol: agent-events-v1 +``` + +Legacy clients that omit the header keep today’s text-only SSE even if the deployment has thinking enabled. The hosted chat UI always sends the header for its own deployments. + +## Simple SSE frame shapes + +Answer text stays on `chunk`. Thinking and tools never reuse `chunk` (so older clients that append every `chunk` into the answer cannot leak thinking). + +| Frame | Meaning | +|-------|---------| +| `{ "blockId", "chunk": "…" }` | Answer text. Legacy clients receive settled final-turn text; opted-in clients receive it live as the model generates (see below) | +| `{ "blockId", "event": "chunk_reset" }` | Opted-in only: discard the block’s streamed answer text — it belonged to a turn that resolved to tool calls | +| `{ "blockId", "event": "thinking", "data": "…" }` | Thinking / reasoning summary delta | +| `{ "blockId", "event": "tool", "phase": "start"\|"end", "id", "name", "status?" }` | Tool lifecycle (no args / results) | +| `{ "event": "final", "data": … }` | Terminal result envelope for a settled execution. `data.success` may be `false` with `data.error` when the workflow itself failed | +| `{ "event": "error", "error": "…" }` | Terminal stream failure (timeout, client abort, processing error) — followed by `[DONE]`, never by `final` | +| `{ "event": "stream_error", "blockId?", "error" }` | Non-terminal mid-block read issue; the stream keeps going | +| `data: "[DONE]"` | Stream closed (JSON-encoded sentinel; always follows the terminal `final` or `error` frame) | + +### Live answer text and intermediate turns + +During a live tool loop, the model can’t be classified mid-turn: text it emits may turn out to be the final answer or preamble before a tool call (the stop reason arrives only at turn end). + +- **Opted-in clients** (protocol header + `includeThinking`) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content. +- **Legacy clients** (no header) never see provisional text: only settled final-turn text is emitted as `chunk`, delivered when the turn completes. + +Logs, memory, and the block’s `content` output always contain final-turn text only — intermediate preamble is never persisted. + +### Abort + +Client disconnect or Stop aborts the provider stream. In-flight tools settle as `cancelled`. Cancel is distinct from execution timeout. + +### Reconnect + +Canvas execution-events `stream:chunk`, `stream:chunk_reset`, `stream:thinking`, and `stream:tool` are **live-only** (not buffered for reconnect replay), same as answer chunks. Guaranteed `seq` replay is out of scope. + +## Canvas (draft Run) + +When you click **Run** in the builder, the execution-events SSE path forwards the same sink. The canvas is always opted in — it does not send (or need) the `X-Sim-Stream-Protocol` header; the dual gate applies only to the public chat / simple SSE surface: + +- `stream:thinking` — `{ blockId, text }` +- `stream:tool` — `{ blockId, phase, id, name, status? }` +- `stream:chunk` — answer text, live on agent-events runs +- `stream:chunk_reset` — `{ blockId }`; discard the block’s streamed text (intermediate turn) + +The terminal output panel shows Thinking / Tools chrome above the block output when those events arrive. PII redaction on block output still disables live forwarding (executor rule). + +## Chat deployment toggle + +In **Deploy → Chat**, enable **Include thinking** so public chat can expose thinking/tool frames (still requires the protocol header). Redeploy / update the chat after changing Agent models or tools. + +## Capability honesty (high level) + +Per-model support is generated from the model registry on the [Agent block page](/docs/workflows/blocks/agent#streamed-thinking-and-tool-calls); the table below summarizes by provider family. + +| Family | Thinking | Live tools | +|--------|----------|------------| +| Anthropic / Azure Anthropic | Yes (incl. redacted blocks in traces). The newest Claude generations omit full thinking; Sim requests summarized thinking for them on streaming runs | Yes | +| Gemini / Vertex | Yes when a thinking level is set (thought summaries requested on agent-events runs) | Yes | +| OpenAI Responses | Reasoning **summaries** when streamed (requires OpenAI organization verification; unverified orgs fall back to no summaries) | Silent tool loop today (answer streams; chips may be absent) | +| OpenAI-compat (Groq, DeepSeek, …) | Only if vendor streams `reasoning` / `reasoning_content` | Live loop where wired (e.g. Groq, DeepSeek) | +| Bedrock | Not invented | Yes when streaming tool loop is used | + +## Example (public chat) + + + +```bash +curl -N -X POST 'http://localhost:3000/api/chat/your-slug' \ + -H 'Content-Type: application/json' \ + -H 'X-Sim-Stream-Protocol: agent-events-v1' \ + -d '{"input":"Think briefly, then say hi"}' +``` + + + +See also [Chat deployment](/docs/workflows/deployment/chat) for access control and the Include thinking setting. diff --git a/apps/docs/content/docs/en/workflows/deployment/chat.mdx b/apps/docs/content/docs/en/workflows/deployment/chat.mdx index e08a32cd01e..ffe2ac2c661 100644 --- a/apps/docs/content/docs/en/workflows/deployment/chat.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/chat.mdx @@ -32,6 +32,7 @@ Configure the following fields, then click **Launch Chat**: | **Output** | Output fields from your workflow blocks returned as the chat response. At least one must be selected. | | **Welcome Message** | Greeting shown before the user sends their first message. Defaults to `"Hi there! How can I help you today?"`. | | **Access Control** | Controls who can access the chat. See [Access Control](#access-control) below. | +| **Include thinking** | When enabled, the hosted chat can show provider-exposed thinking and tool lifecycle (name + status). Requires the chat client to send `X-Sim-Stream-Protocol: agent-events-v1` (the hosted UI always does). Default is off. See [Agent stream events](/docs/workflows/deployment/agent-events). | ### Output Selection diff --git a/apps/docs/content/docs/en/workflows/deployment/meta.json b/apps/docs/content/docs/en/workflows/deployment/meta.json index 9d84bce6cff..037c70ba33a 100644 --- a/apps/docs/content/docs/en/workflows/deployment/meta.json +++ b/apps/docs/content/docs/en/workflows/deployment/meta.json @@ -1,4 +1,4 @@ { "title": "Deployment", - "pages": ["index", "api", "chat", "mcp"] + "pages": ["index", "api", "chat", "agent-events", "mcp"] } diff --git a/apps/docs/package.json b/apps/docs/package.json index 9fde4625a2a..8581c46ea31 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -41,7 +41,7 @@ "tailwind-merge": "^3.0.2", "reactflow": "^11.11.4", "framer-motion": "^12.5.0", - "zod": "^4.3.6" + "zod": "4.3.6" }, "devDependencies": { "@sim/tsconfig": "workspace:*", diff --git a/apps/realtime/src/middleware/auth.ts b/apps/realtime/src/middleware/auth.ts index 91cdb71b4b6..6ea22f02d91 100644 --- a/apps/realtime/src/middleware/auth.ts +++ b/apps/realtime/src/middleware/auth.ts @@ -69,7 +69,7 @@ export async function authenticateSocket(socket: AuthenticatedSocket, next: (err // Store user info in socket for later use socket.userId = session.user.id socket.userName = session.user.name || session.user.email || 'Unknown User' - socket.userEmail = session.user.email + socket.userEmail = session.user.email ?? undefined socket.userImage = session.user.image || null socket.activeOrganizationId = session.session.activeOrganizationId || undefined diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 49f71d79272..b8e5382b0f0 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -4,6 +4,10 @@ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } fro import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { noop } from '@/lib/core/utils/request' +import { + AGENT_STREAM_PROTOCOL_HEADER, + AGENT_STREAM_PROTOCOL_V1, +} from '@/lib/workflows/streaming/agent-stream-protocol' import { ChatErrorState, ChatHeader, @@ -95,8 +99,9 @@ export default function ChatClient({ identifier }: { identifier: string }) { const [conversationId] = useState(() => generateId()) const [showScrollButton, setShowScrollButton] = useState(false) - const [userHasScrolled, setUserHasScrolled] = useState(false) - const isUserScrollingRef = useRef(false) + /** ChatGPT-style: follow new tokens only while the viewport is near the bottom. */ + const stickToBottomRef = useRef(true) + const ignoreScrollRef = useRef(false) const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false) @@ -131,10 +136,31 @@ export default function ChatClient({ identifier }: { identifier: string }) { const audioContextRef = useRef(null) const { isPlayingAudio, streamTextToAudio, stopAudio } = useAudioStreaming(audioContextRef) - const scrollToBottom = useCallback(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + const NEAR_BOTTOM_THRESHOLD_PX = 100 + + /** + * ChatGPT-style scroll. Without `force`, no-ops when the user has scrolled away. + * With `force` (jump button), re-pins to bottom. + */ + const scrollToBottom = useCallback((options?: { behavior?: ScrollBehavior; force?: boolean }) => { + const behavior = options?.behavior ?? 'smooth' + const force = options?.force === true + if (!force && !stickToBottomRef.current) return + if (!messagesEndRef.current) return + + if (force) { + stickToBottomRef.current = true + setShowScrollButton(false) } + + ignoreScrollRef.current = true + messagesEndRef.current.scrollIntoView({ behavior }) + window.setTimeout( + () => { + ignoreScrollRef.current = false + }, + behavior === 'smooth' ? 400 : 50 + ) }, []) const scrollToMessage = useCallback( @@ -165,39 +191,23 @@ export default function ChatClient({ identifier }: { identifier: string }) { [messagesContainerRef] ) - const isStreamingResponseRef = useRef(isStreamingResponse) - isStreamingResponseRef.current = isStreamingResponse - useEffect(() => { const container = messagesContainerRef.current if (!container) return const handleScroll = () => { + if (ignoreScrollRef.current) return const { scrollTop, scrollHeight, clientHeight } = container const distanceFromBottom = scrollHeight - scrollTop - clientHeight - setShowScrollButton(distanceFromBottom > 100) - - if (isStreamingResponseRef.current && !isUserScrollingRef.current) { - setUserHasScrolled(true) - } + const nearBottom = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_PX + stickToBottomRef.current = nearBottom + setShowScrollButton(!nearBottom) } container.addEventListener('scroll', handleScroll, { passive: true }) return () => container.removeEventListener('scroll', handleScroll) }, [chatConfig, isVoiceFirstMode, authRequired]) - useEffect(() => { - if (isStreamingResponse) { - setUserHasScrolled(false) - - isUserScrollingRef.current = true - const timeoutId = setTimeout(() => { - isUserScrollingRef.current = false - }, 1000) - return () => clearTimeout(timeoutId) - } - }, [isStreamingResponse]) - const handleSendMessage = async ( messageParam?: string, isVoiceInput = false, @@ -220,7 +230,8 @@ export default function ChatClient({ identifier }: { identifier: string }) { filesCount: files?.length, }) - setUserHasScrolled(false) + stickToBottomRef.current = true + setShowScrollButton(false) const userMessage: ChatMessage = { id: generateId(), @@ -244,7 +255,9 @@ export default function ChatClient({ identifier }: { identifier: string }) { scrollToMessage(userMessage.id, true) }, 100) + // One AbortController for fetch + SSE body reads so Stop cancels server work too. const abortController = new AbortController() + abortControllerRef.current = abortController const timeoutId = setTimeout(() => { abortController.abort() }, CHAT_REQUEST_TIMEOUT_MS) @@ -282,6 +295,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', + [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1, }, body: JSON.stringify(payload), credentials: 'same-origin', @@ -316,8 +330,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { response, setMessages, setIsLoading, - scrollToBottom, - userHasScrolled, + () => scrollToBottom({ behavior: 'auto' }), { voiceSettings: { isVoiceEnabled: shouldPlayAudio, @@ -326,6 +339,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { }, audioStreamHandler: audioHandler, outputConfigs: chatConfig?.outputConfigs, + abortController, } ) } catch (error) { @@ -437,7 +451,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { showScrollButton={showScrollButton} messagesContainerRef={messagesContainerRef as RefObject} messagesEndRef={messagesEndRef as RefObject} - scrollToBottom={scrollToBottom} + scrollToBottom={() => scrollToBottom({ behavior: 'smooth', force: true })} scrollToMessage={scrollToMessage} chatConfig={chatConfig} /> diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx index a48517a75f1..43daf5cd018 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx @@ -1,11 +1,24 @@ /** - * @vitest-environment node + * @vitest-environment jsdom */ -import { describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/emcn', () => ({ + Button: ({ children, ...props }: { children?: React.ReactNode; [key: string]: unknown }) => ( + + ), Duplicate: () => null, - Tooltip: {}, + Tooltip: { + Provider: ({ children }: { children?: React.ReactNode }) => <>{children}, + Root: ({ children }: { children?: React.ReactNode }) => <>{children}, + Trigger: ({ children }: { children?: React.ReactNode }) => <>{children}, + Content: ({ children }: { children?: React.ReactNode }) => <>{children}, + }, + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', () => ({ @@ -14,10 +27,14 @@ vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', ( })) vi.mock('@/app/(interfaces)/chat/components/message/components/markdown-renderer', () => ({ - default: () => null, + default: ({ content }: { content: string }) =>
{content}
, })) -import { escapeHtml } from '@/app/(interfaces)/chat/components/message/message' +import { + type ChatMessage, + ClientChatMessage, + escapeHtml, +} from '@/app/(interfaces)/chat/components/message/message' describe('escapeHtml', () => { it('escapes all five HTML-significant characters', () => { @@ -41,3 +58,143 @@ describe('escapeHtml', () => { expect(escapeHtml('')).toBe('') }) }) + +function renderMessage(message: ChatMessage): { container: HTMLDivElement; unmount: () => void } { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + act(() => { + root.render() + }) + return { + container, + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('ClientChatMessage thinking chrome (Step 6)', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('does not show thinking chrome when thinking is absent or empty', () => { + const without = renderMessage({ + id: '1', + type: 'assistant', + content: 'Hello', + timestamp: new Date(), + }) + mounts.push(without.unmount) + expect(without.container.textContent).not.toContain('Thinking') + + const empty = renderMessage({ + id: '2', + type: 'assistant', + content: 'Hello', + thinking: '', + timestamp: new Date(), + }) + mounts.push(empty.unmount) + expect(empty.container.textContent).not.toContain('Thinking') + }) + + it('shows collapsible thinking chrome above the answer after first thinking', () => { + const { container, unmount } = renderMessage({ + id: '3', + type: 'assistant', + content: 'Answer text', + thinking: 'Internal reasoning', + isThinkingStreaming: true, + timestamp: new Date(), + }) + mounts.push(unmount) + + expect(container.textContent).toContain('Thinking…') + expect(container.textContent).toContain('Internal reasoning') + expect(container.textContent).toContain('Answer text') + }) + + it('labels completed thinking as Thought for a moment', () => { + const { container, unmount } = renderMessage({ + id: '4', + type: 'assistant', + content: 'Answer text', + thinking: 'Internal reasoning', + isThinkingStreaming: false, + timestamp: new Date(), + }) + mounts.push(unmount) + + expect(container.textContent).toContain('Thought for a moment') + expect(container.textContent).toContain('Answer text') + }) +}) + +describe('ClientChatMessage tool chrome (Step 8)', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('does not show tool chrome when toolCalls are absent or empty', () => { + const without = renderMessage({ + id: '1', + type: 'assistant', + content: 'Hello', + timestamp: new Date(), + }) + mounts.push(without.unmount) + expect(without.container.textContent).not.toContain('Tools') + expect(without.container.textContent).not.toContain('Using tools') + + const empty = renderMessage({ + id: '2', + type: 'assistant', + content: 'Hello', + toolCalls: [], + timestamp: new Date(), + }) + mounts.push(empty.unmount) + expect(empty.container.textContent).not.toContain('Tools') + }) + + it('shows humanized tool names only (no args) while tools are running', () => { + const { container, unmount } = renderMessage({ + id: '3', + type: 'assistant', + content: 'Answer', + isToolStreaming: true, + toolCalls: [ + { + key: 'agent-1:toolu_1', + blockId: 'agent-1', + id: 'toolu_1', + name: 'http_request', + displayName: 'Http Request', + status: 'running', + }, + ], + timestamp: new Date(), + }) + mounts.push(unmount) + + expect(container.textContent).toContain('Using tools…') + expect(container.textContent).toContain('Http Request') + expect(container.textContent).not.toContain('toolu_1') + expect(container.textContent).not.toContain('args') + expect(container.textContent).toContain('Answer') + }) +}) diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.tsx index f5ec5b623c8..fe7e940bee6 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.tsx @@ -3,6 +3,14 @@ import { memo, useState } from 'react' import { Button, cn, Duplicate, Tooltip } from '@sim/emcn' import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react' +import { + AgentStreamThinkingChrome, + AgentStreamToolCallsChrome, +} from '@/components/agent-stream/agent-stream-chrome' +import type { + AgentStreamToolCall, + AgentStreamToolStatus, +} from '@/components/agent-stream/tool-call-lifecycle' import { ChatFileDownload, ChatFileDownloadAll, @@ -27,6 +35,15 @@ export interface ChatFile { context?: string } +/** Lifecycle status for a tool chip (agent-events-v1). No args/results. */ +export type ChatToolCallStatus = AgentStreamToolStatus + +/** Chat surface tool chip — the shared lifecycle chip plus its block id. */ +export interface ChatToolCall extends AgentStreamToolCall { + blockId: string + displayName: string +} + export interface ChatMessage { id: string content: string | Record @@ -34,6 +51,14 @@ export interface ChatMessage { timestamp: Date isInitialMessage?: boolean isStreaming?: boolean + /** Model thinking text (agent-events-v1). Chrome only when non-empty. */ + thinking?: string + /** True while thinking deltas are still arriving (before first answer chunk / final). */ + isThinkingStreaming?: boolean + /** Tool lifecycle chips (name + status only). Chrome only when non-empty. */ + toolCalls?: ChatToolCall[] + /** True while any tool chip is still `running`. */ + isToolStreaming?: boolean attachments?: ChatAttachment[] files?: ChatFile[] } @@ -81,15 +106,21 @@ function openAttachmentPreview(name: string, dataUrl: string): void { setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000) } +function toolCallsFingerprint(toolCalls: ChatToolCall[] | undefined): string { + if (!toolCalls?.length) return '' + return toolCalls.map((t) => `${t.key}:${t.status}`).join('|') +} + export const ClientChatMessage = memo( function ClientChatMessage({ message }: { message: ChatMessage }) { const [isCopied, setIsCopied] = useState(false) const isJsonObject = typeof message.content === 'object' && message.content !== null - // Since tool calls are now handled via SSE events and stored in message.toolCalls, - // we can use the content directly without parsing + // Answer text is streamed separately from thinking / tool lifecycle events. const cleanTextContent = message.content + const hasThinking = typeof message.thinking === 'string' && message.thinking.length > 0 + const hasToolCalls = Array.isArray(message.toolCalls) && message.toolCalls.length > 0 const content = message.type === 'user' ? ( @@ -207,8 +238,19 @@ export const ClientChatMessage = memo(
- {/* Direct content rendering - tool calls are now handled via SSE events */}
+ {hasThinking && ( + + )} + {hasToolCalls && ( + + )}
{isJsonObject ? (
@@ -274,7 +316,12 @@ export const ClientChatMessage = memo(
     return (
       prevProps.message.id === nextProps.message.id &&
       prevProps.message.content === nextProps.message.content &&
+      prevProps.message.thinking === nextProps.message.thinking &&
       prevProps.message.isStreaming === nextProps.message.isStreaming &&
+      prevProps.message.isThinkingStreaming === nextProps.message.isThinkingStreaming &&
+      prevProps.message.isToolStreaming === nextProps.message.isToolStreaming &&
+      toolCallsFingerprint(prevProps.message.toolCalls) ===
+        toolCallsFingerprint(nextProps.message.toolCalls) &&
       prevProps.message.isInitialMessage === nextProps.message.isInitialMessage &&
       prevProps.message.files?.length === nextProps.message.files?.length
     )
diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx
new file mode 100644
index 00000000000..34c01db49e5
--- /dev/null
+++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx
@@ -0,0 +1,691 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockReadSSEEvents } = vi.hoisted(() => ({
+  mockReadSSEEvents: vi.fn(),
+}))
+
+vi.mock('@/lib/core/utils/sse', () => ({
+  readSSEEvents: mockReadSSEEvents,
+}))
+
+vi.mock('@sim/utils/id', () => ({
+  generateId: () => 'msg-assistant-1',
+}))
+
+import { isChatChunkFrame } from '@/lib/workflows/streaming/agent-stream-protocol'
+import type { ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
+import { useChatStreaming } from '@/app/(interfaces)/chat/hooks/use-chat-streaming'
+
+describe('isChatChunkFrame', () => {
+  it('accepts plain answer chunks without an event type', () => {
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: 'hello' })).toBe(true)
+  })
+
+  it('rejects thinking / stream_error / tool frames even if chunk is present', () => {
+    expect(
+      isChatChunkFrame({ blockId: 'a1', chunk: 'leak', event: 'thinking', data: 'thought' })
+    ).toBe(false)
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: 'x', event: 'stream_error' })).toBe(false)
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: 'x', event: 'tool' })).toBe(false)
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: 'x', event: 'final' })).toBe(false)
+  })
+
+  it('rejects frames missing blockId or empty chunk', () => {
+    expect(isChatChunkFrame({ chunk: 'hello' })).toBe(false)
+    expect(isChatChunkFrame({ blockId: 'a1', chunk: '' })).toBe(false)
+  })
+})
+
+interface HookHandle {
+  latest: () => ReturnType
+  unmount: () => void
+}
+
+function renderStreamingHook(): HookHandle {
+  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+  const container = document.createElement('div')
+  const root: Root = createRoot(container)
+  let latest!: ReturnType
+
+  function Probe() {
+    latest = useChatStreaming()
+    return null
+  }
+
+  act(() => {
+    root.render()
+  })
+
+  return {
+    latest: () => latest,
+    unmount: () => {
+      act(() => {
+        root.unmount()
+      })
+    },
+  }
+}
+
+function makeSseResponse(): Response {
+  return {
+    body: new ReadableStream(),
+  } as Response
+}
+
+async function flushUiBatch() {
+  await act(async () => {
+    await new Promise((resolve) => {
+      requestAnimationFrame(() => resolve())
+    })
+    await new Promise((resolve) => {
+      setTimeout(resolve, 60)
+    })
+  })
+}
+
+describe('useChatStreaming thinking + abort', () => {
+  let handle: HookHandle
+  let messages: ChatMessage[]
+  let setMessages: React.Dispatch>
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    messages = []
+    setMessages = ((updater: React.SetStateAction) => {
+      messages = typeof updater === 'function' ? updater(messages) : updater
+    }) as React.Dispatch>
+    handle = renderStreamingHook()
+
+    // Run rAF immediately so UI batching is deterministic in tests.
+    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+      cb(performance.now())
+      return 1
+    })
+  })
+
+  afterEach(() => {
+    handle.unmount()
+    vi.restoreAllMocks()
+  })
+
+  it('routes thinking to message.thinking and answer chunks to content only', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'Let me reason. ',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'More thought.',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Final answer.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toBe('Let me reason. More thought.')
+    expect(assistant?.content).toBe('Final answer.')
+    expect(assistant?.isStreaming).toBe(false)
+    expect(assistant?.isThinkingStreaming).toBe(false)
+  })
+
+  it('clears a block’s live text on chunk_reset and keeps the re-streamed final turn', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      // Turn 1: live preamble, then tools follow → reset.
+      await options.onEvent({ blockId: 'agent-1', chunk: 'Let me check the weather…' })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 't1',
+        name: 'get_weather',
+      })
+      await options.onEvent({ blockId: 'agent-1', event: 'chunk_reset' })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 't1',
+        name: 'get_weather',
+        status: 'success',
+      })
+      // Turn 2: final answer streams live.
+      await options.onEvent({ blockId: 'agent-1', chunk: 'It is ' })
+      await options.onEvent({ blockId: 'agent-1', chunk: '68°F.' })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('It is 68°F.')
+    expect(assistant?.content).not.toContain('Let me check')
+  })
+
+  it('re-registers a reset block at the end so multi-block order matches arrival', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      // Agent A streams provisional text, then resets (tools follow).
+      await options.onEvent({ blockId: 'agent-a', chunk: 'Checking the weather…' })
+      await options.onEvent({ blockId: 'agent-a', event: 'chunk_reset' })
+      // Another block streams while A's tools run.
+      await options.onEvent({ blockId: 'block-b', chunk: 'B output' })
+      // A's final turn re-streams; the server bakes in the cross-block separator.
+      await options.onEvent({ blockId: 'agent-a', chunk: '\n\nIt is 68°F.' })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('B output\n\nIt is 68°F.')
+  })
+
+  it('settles thinking chrome when a tool starts', async () => {
+    let midStreamThinking: boolean | undefined
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({ blockId: 'agent-1', event: 'thinking', data: 'planning…' })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 't1',
+        name: 'get_weather',
+      })
+      // UI flush is synchronous in tests (rAF mocked) — capture mid-stream state.
+      midStreamThinking = messages.find((m) => m.type === 'assistant')?.isThinkingStreaming
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 't1',
+        name: 'get_weather',
+        status: 'success',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    expect(midStreamThinking).toBe(false)
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toBe('planning…')
+  })
+
+  it('ignores non-terminal stream_error frames and keeps streaming', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'Working…',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'stream_error',
+        error: 'partial provider glitch',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Recovered answer.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    // Error text never pollutes the thinking lane (legacy parity: log-only).
+    expect(assistant?.thinking).toBe('Working…')
+    expect(assistant?.content).toBe('Recovered answer.')
+    expect(assistant?.isStreaming).toBe(false)
+  })
+
+  it('clears streaming flags when SSE ends without a terminal final/error frame', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'Halfway…',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Partial answer',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 't1',
+        name: 'search',
+      })
+      // Stream closes abruptly — no final or error event.
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('Partial answer')
+    expect(assistant?.thinking).toBe('Halfway…')
+    expect(assistant?.isStreaming).toBe(false)
+    expect(assistant?.isThinkingStreaming).toBe(false)
+    expect(assistant?.isToolStreaming).toBe(false)
+    expect(assistant?.toolCalls?.some((t) => t.status === 'error')).toBe(true)
+  })
+
+  it('does not append thinking payload into answer when mislabeled as chunk', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        chunk: 'SHOULD_NOT_APPEND',
+        data: 'real thought',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'ok',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('ok')
+    expect(assistant?.thinking).toBe('real thought')
+  })
+
+  it('TTS audioStreamHandler receives answer text only', async () => {
+    const audioStreamHandler = vi.fn().mockResolvedValue(undefined)
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'secret internal monologue that must not be spoken.',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Hello world.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle
+        .latest()
+        .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), {
+          voiceSettings: {
+            isVoiceEnabled: true,
+            voiceId: 'voice-1',
+            autoPlayResponses: true,
+          },
+          audioStreamHandler,
+        })
+    })
+    await flushUiBatch()
+
+    expect(audioStreamHandler).toHaveBeenCalled()
+    for (const call of audioStreamHandler.mock.calls) {
+      expect(String(call[0])).not.toContain('secret')
+      expect(String(call[0])).not.toContain('monologue')
+    }
+    expect(audioStreamHandler.mock.calls.some((c) => String(c[0]).includes('Hello'))).toBe(true)
+  })
+
+  it('stopStreaming preserves thinking and aborts the shared controller', async () => {
+    const abortController = new AbortController()
+    let resolveStream!: () => void
+    const streamDone = new Promise((resolve) => {
+      resolveStream = resolve
+    })
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'partial thought',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'partial answer',
+      })
+      // Hold the stream open until Stop aborts.
+      await new Promise((resolve) => {
+        options.signal?.addEventListener('abort', () => resolve(), { once: true })
+        // Also allow test cleanup if abort never fires.
+        streamDone.then(() => resolve())
+      })
+    })
+
+    const streamPromise = act(async () => {
+      await handle
+        .latest()
+        .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), {
+          abortController,
+        })
+    })
+
+    await flushUiBatch()
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.thinking).toBe('partial thought')
+
+    act(() => {
+      handle.latest().stopStreaming(setMessages)
+    })
+    resolveStream()
+    await streamPromise
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(abortController.signal.aborted).toBe(true)
+    expect(assistant?.thinking).toBe('partial thought')
+    expect(String(assistant?.content)).toContain('partial answer')
+    expect(String(assistant?.content)).toContain('Response stopped by user')
+    expect(assistant?.isStreaming).toBe(false)
+    expect(assistant?.isThinkingStreaming).toBe(false)
+  })
+
+  it('does not replace Stop notice with server Client cancelled request error', async () => {
+    const abortController = new AbortController()
+    let resolveStream!: () => void
+    const streamDone = new Promise((resolve) => {
+      resolveStream = resolve
+    })
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'partial answer',
+      })
+      await new Promise((resolve) => {
+        options.signal?.addEventListener(
+          'abort',
+          () => {
+            // Server still emits terminal cancel error while the reader finishes.
+            void options.onEvent({
+              event: 'error',
+              error: 'Client cancelled request',
+            })
+            resolve()
+          },
+          { once: true }
+        )
+        streamDone.then(() => resolve())
+      })
+    })
+
+    const streamPromise = act(async () => {
+      await handle
+        .latest()
+        .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), {
+          abortController,
+        })
+    })
+
+    await flushUiBatch()
+
+    act(() => {
+      handle.latest().stopStreaming(setMessages)
+    })
+    resolveStream()
+    await streamPromise
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(String(assistant?.content)).toContain('partial answer')
+    expect(String(assistant?.content)).toContain('Response stopped by user')
+    expect(String(assistant?.content)).not.toContain('Client cancelled request')
+    expect(assistant?.isStreaming).toBe(false)
+  })
+
+  it('leaves thinking undefined when no thinking events arrive', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'just text',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toBeUndefined()
+    expect(assistant?.content).toBe('just text')
+  })
+})
+
+describe('useChatStreaming tool lifecycle', () => {
+  let handle: HookHandle
+  let messages: ChatMessage[]
+  let setMessages: React.Dispatch>
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    messages = []
+    setMessages = ((updater: React.SetStateAction) => {
+      messages = typeof updater === 'function' ? updater(messages) : updater
+    }) as React.Dispatch>
+    handle = renderStreamingHook()
+    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+      cb(performance.now())
+      return 1
+    })
+  })
+
+  afterEach(() => {
+    handle.unmount()
+    vi.restoreAllMocks()
+  })
+
+  it('maps tool start/end into keyed chips without touching answer content', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_1',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 'toolu_1',
+        name: 'http_request',
+        status: 'success',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'https://httpbin.org/get',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('https://httpbin.org/get')
+    expect(assistant?.toolCalls).toEqual([
+      {
+        key: 'agent-1:toolu_1',
+        blockId: 'agent-1',
+        id: 'toolu_1',
+        name: 'http_request',
+        displayName: 'Http Request',
+        status: 'success',
+      },
+    ])
+    expect(assistant?.toolCalls?.[0]).not.toHaveProperty('args')
+    expect(assistant?.toolCalls?.[0]).not.toHaveProperty('result')
+    expect(assistant?.isToolStreaming).toBe(false)
+  })
+
+  it('tracks parallel tools and cancels running chips on Stop', async () => {
+    const abortController = new AbortController()
+    let resolveStream!: () => void
+    const streamDone = new Promise((resolve) => {
+      resolveStream = resolve
+    })
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_1',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_2',
+        name: 'function_execute',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 'toolu_2',
+        name: 'function_execute',
+        status: 'success',
+      })
+      await new Promise((resolve) => {
+        options.signal?.addEventListener('abort', () => resolve(), { once: true })
+        streamDone.then(() => resolve())
+      })
+    })
+
+    const streamPromise = act(async () => {
+      await handle
+        .latest()
+        .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), {
+          abortController,
+        })
+    })
+
+    await flushUiBatch()
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls).toHaveLength(2)
+
+    act(() => {
+      handle.latest().stopStreaming(setMessages)
+    })
+    resolveStream()
+    await streamPromise
+
+    const tools = messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls
+    expect(tools?.find((t) => t.id === 'toolu_1')?.status).toBe('cancelled')
+    expect(tools?.find((t) => t.id === 'toolu_2')?.status).toBe('success')
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.isToolStreaming).toBe(false)
+  })
+
+  it('settles straggler running tools to success on final', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_open',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls?.[0]?.status).toBe('success')
+  })
+
+  it('settles straggler running tools to error when final reports failure', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_open',
+        name: 'http_request',
+      })
+      // Failed runs can still terminate with `final` carrying success: false.
+      await options.onEvent({
+        event: 'final',
+        data: { success: false, error: 'Workflow failed', output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
+    })
+    await flushUiBatch()
+
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls?.[0]?.status).toBe('error')
+  })
+})
diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
index be4b0b2e1ae..b7fdb139d67 100644
--- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
+++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
@@ -3,9 +3,29 @@
 import { useRef, useState } from 'react'
 import { createLogger } from '@sim/logger'
 import { generateId } from '@sim/utils/id'
+import {
+  anyToolCallRunning,
+  applyToolCallPhase,
+  settleRunningToolCalls,
+  snapshotToolCalls,
+  toolCallKey,
+} from '@/components/agent-stream/tool-call-lifecycle'
 import { readSSEEvents } from '@/lib/core/utils/sse'
 import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
-import type { ChatFile, ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
+import {
+  isChatChunkFrame,
+  isChatChunkResetFrame,
+  isChatErrorFrame,
+  isChatFinalFrame,
+  isChatStreamErrorFrame,
+  isChatThinkingFrame,
+  isChatToolFrame,
+} from '@/lib/workflows/streaming/agent-stream-protocol'
+import type {
+  ChatFile,
+  ChatMessage,
+  ChatToolCall,
+} from '@/app/(interfaces)/chat/components/message/message'
 import { CHAT_ERROR_MESSAGES } from '@/app/(interfaces)/chat/constants'
 
 const logger = createLogger('UseChatStreaming')
@@ -64,23 +84,40 @@ export interface StreamingOptions {
   onAudioEnd?: () => void
   audioStreamHandler?: (text: string) => Promise
   outputConfigs?: Array<{ blockId: string; path?: string }>
+  /**
+   * Shared AbortController for fetch + SSE body reads. When provided (preferred),
+   * Stop aborts the in-flight request server-side as well as the reader.
+   */
+  abortController?: AbortController
+}
+
+/** Client-side view of the `final` frame's opaque `data` payload. */
+interface ChatFinalData {
+  success?: boolean
+  error?: string | { message?: string }
+  output?: Record>
 }
 
 export function useChatStreaming() {
   const [isStreamingResponse, setIsStreamingResponse] = useState(false)
   const abortControllerRef = useRef(null)
   const accumulatedTextRef = useRef('')
+  const accumulatedThinkingRef = useRef('')
+  const accumulatedToolCallsRef = useRef([])
   const lastStreamedPositionRef = useRef(0)
   const audioStreamingActiveRef = useRef(false)
-  const lastDisplayedPositionRef = useRef(0) // Track displayed text in synced mode
+  const lastDisplayedPositionRef = useRef(0)
 
   const stopStreaming = (setMessages: React.Dispatch>) => {
     if (abortControllerRef.current) {
-      // Abort the fetch request
       abortControllerRef.current.abort()
       abortControllerRef.current = null
 
       const latestContent = accumulatedTextRef.current
+      const latestThinking = accumulatedThinkingRef.current
+      const latestTools = accumulatedToolCallsRef.current.map((tool) =>
+        tool.status === 'running' ? { ...tool, status: 'cancelled' as const } : tool
+      )
 
       setMessages((prev) => {
         const lastMessage = prev[prev.length - 1]
@@ -92,7 +129,16 @@ export function useChatStreaming() {
 
           return [
             ...prev.slice(0, -1),
-            { ...lastMessage, content: updatedContent, isStreaming: false },
+            {
+              ...lastMessage,
+              content: updatedContent,
+              // Preserve any thinking / tools received before Stop.
+              thinking: latestThinking || lastMessage.thinking,
+              toolCalls: latestTools.length > 0 ? latestTools : lastMessage.toolCalls,
+              isStreaming: false,
+              isThinkingStreaming: false,
+              isToolStreaming: false,
+            },
           ]
         }
 
@@ -101,6 +147,8 @@ export function useChatStreaming() {
 
       setIsStreamingResponse(false)
       accumulatedTextRef.current = ''
+      accumulatedThinkingRef.current = ''
+      accumulatedToolCallsRef.current = []
       lastStreamedPositionRef.current = 0
       lastDisplayedPositionRef.current = 0
       audioStreamingActiveRef.current = false
@@ -112,15 +160,18 @@ export function useChatStreaming() {
     setMessages: React.Dispatch>,
     setIsLoading: React.Dispatch>,
     scrollToBottom: () => void,
-    userHasScrolled?: boolean,
     streamingOptions?: StreamingOptions
   ) => {
     logger.info('[useChatStreaming] handleStreamedResponse called')
-    // Set streaming state
     setIsStreamingResponse(true)
-    abortControllerRef.current = new AbortController()
 
-    // Check if we should stream audio
+    // Prefer a shared controller from the caller (fetch + reader). Otherwise create one.
+    if (streamingOptions?.abortController) {
+      abortControllerRef.current = streamingOptions.abortController
+    } else if (!abortControllerRef.current) {
+      abortControllerRef.current = new AbortController()
+    }
+
     const shouldPlayAudio =
       streamingOptions?.voiceSettings?.isVoiceEnabled &&
       streamingOptions?.voiceSettings?.autoPlayResponses &&
@@ -132,8 +183,28 @@ export function useChatStreaming() {
       return
     }
 
+    /**
+     * Answer text tracked per block so a `chunk_reset` (dual-gated streams:
+     * a live-streamed turn resolved to tool calls) can clear one block's
+     * contribution. `accumulatedText` is re-derived on every mutation —
+     * cross-block separators arrive baked into the chunks.
+     */
+    const blockTextOrder: string[] = []
+    const blockTextSegments = new Map()
     let accumulatedText = ''
+    const recomputeAccumulatedText = () => {
+      accumulatedText = blockTextOrder.map((id) => blockTextSegments.get(id) ?? '').join('')
+      accumulatedTextRef.current = accumulatedText
+    }
+    let accumulatedThinking = ''
+    let isThinkingStreaming = false
     let lastAudioPosition = 0
+    const toolCallsMap = new Map()
+    const toolCallOrder: string[] = []
+
+    const syncToolCallsRef = () => {
+      accumulatedToolCallsRef.current = snapshotToolCalls(toolCallOrder, toolCallsMap) ?? []
+    }
 
     const messageIdMap = new Map()
     const messageId = generateId()
@@ -156,14 +227,29 @@ export function useChatStreaming() {
       if (!uiDirty) return
       uiDirty = false
       lastUIFlush = performance.now()
-      const snapshot = accumulatedText
+      const contentSnapshot = accumulatedText
+      const thinkingSnapshot = accumulatedThinking
+      const thinkingStreamingSnapshot = isThinkingStreaming
+      const toolCallsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
+      const toolStreamingSnapshot = anyToolCallRunning(toolCallsMap)
       setMessages((prev) =>
         prev.map((msg) => {
           if (msg.id !== messageId) return msg
           if (!msg.isStreaming) return msg
-          return { ...msg, content: snapshot }
+          return {
+            ...msg,
+            content: contentSnapshot,
+            thinking: thinkingSnapshot || undefined,
+            isThinkingStreaming: thinkingStreamingSnapshot,
+            toolCalls: toolCallsSnapshot,
+            isToolStreaming: toolStreamingSnapshot,
+          }
         })
       )
+      // Caller supplies a stick-to-bottom-aware scroller (no-ops if user scrolled away).
+      requestAnimationFrame(() => {
+        scrollToBottom()
+      })
     }
 
     const scheduleUIFlush = () => {
@@ -192,35 +278,59 @@ export function useChatStreaming() {
     setIsLoading(false)
 
     let terminated = false
+    // Capture before Stop nulls abortControllerRef; needed when the reader
+    // resolves on abort instead of throwing AbortError.
+    const streamAbortSignal = abortControllerRef.current!.signal
 
     try {
-      await readSSEEvents<{
-        blockId?: string
-        chunk?: string
-        event?: string
-        error?: string
-        data?: {
-          success: boolean
-          error?: string | { message?: string }
-          output?: Record>
-        }
-      }>(response.body, {
-        signal: abortControllerRef.current.signal,
+      await readSSEEvents>(response.body, {
+        signal: streamAbortSignal,
         onParseError: (_data, parseError) => {
           logger.error('Error parsing stream data:', parseError)
         },
         onEvent: async (json) => {
-          const { blockId, chunk: contentChunk, event: eventType } = json
+          if (isChatErrorFrame(json)) {
+            // User Stop aborts the fetch; the server often still emits a terminal
+            // `{ event: 'error', error: 'Client cancelled request' }` before the
+            // SSE reader finishes. Do not overwrite the stop notice.
+            if (streamAbortSignal.aborted) {
+              settleRunningToolCalls(toolCallsMap, 'cancelled')
+              syncToolCallsRef()
+              const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
+              setMessages((prev) =>
+                prev.map((msg) =>
+                  msg.id === messageId
+                    ? {
+                        ...msg,
+                        isStreaming: false,
+                        isThinkingStreaming: false,
+                        isToolStreaming: false,
+                        thinking: accumulatedThinking || msg.thinking,
+                        toolCalls: toolsSnapshot ?? msg.toolCalls,
+                      }
+                    : msg
+                )
+              )
+              setIsLoading(false)
+              terminated = true
+              return true
+            }
 
-          if (eventType === 'error' || json.event === 'error') {
             const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR
+            settleRunningToolCalls(toolCallsMap, 'error')
+            syncToolCallsRef()
+            const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
             setMessages((prev) =>
               prev.map((msg) =>
                 msg.id === messageId
                   ? {
                       ...msg,
                       content: errorMessage,
+                      thinking: accumulatedThinking || msg.thinking,
+                      toolCalls: toolsSnapshot ?? msg.toolCalls,
                       isStreaming: false,
+                      isThinkingStreaming: false,
+                      isToolStreaming: false,
                       type: 'assistant' as const,
                     }
                   : msg
@@ -231,9 +341,70 @@ export function useChatStreaming() {
             return true
           }
 
-          if (eventType === 'final' && json.data) {
+          if (isChatStreamErrorFrame(json)) {
+            // Non-terminal mid-block read issue: keep streaming. The legacy
+            // client ignored these frames; log only — never repurpose the
+            // thinking lane for error text.
+            logger.warn('[useChatStreaming] Non-terminal stream_error', {
+              blockId: json.blockId,
+              error: json.error || 'A streaming error occurred',
+            })
+            return false
+          }
+
+          if (isChatThinkingFrame(json)) {
+            if (!messageIdMap.has(json.blockId)) {
+              messageIdMap.set(json.blockId, messageId)
+            }
+            accumulatedThinking += json.data
+            accumulatedThinkingRef.current = accumulatedThinking
+            isThinkingStreaming = true
+            uiDirty = true
+            scheduleUIFlush()
+            return false
+          }
+
+          if (isChatToolFrame(json)) {
+            const { blockId } = json
+            if (!messageIdMap.has(blockId)) {
+              messageIdMap.set(blockId, messageId)
+            }
+            // Tools starting means the turn's thinking phase is over — settle
+            // the thinking chrome (it re-opens if more thinking streams later).
+            if (json.phase === 'start' && isThinkingStreaming) {
+              isThinkingStreaming = false
+            }
+            applyToolCallPhase(
+              toolCallsMap,
+              toolCallOrder,
+              {
+                key: toolCallKey(blockId, json.id),
+                id: json.id,
+                name: json.name,
+                phase: json.phase,
+                status: json.status,
+              },
+              (tool): ChatToolCall => ({
+                ...tool,
+                blockId,
+                displayName: tool.displayName ?? tool.name,
+              })
+            )
+            syncToolCallsRef()
+            uiDirty = true
+            scheduleUIFlush()
+            return false
+          }
+
+          if (isChatFinalFrame(json)) {
             flushUI()
-            const finalData = json.data
+            const finalData = json.data as ChatFinalData
+            isThinkingStreaming = false
+            // A failed run can still terminate with `final` (success: false) —
+            // straggler running chips must not settle green in that case.
+            settleRunningToolCalls(toolCallsMap, finalData.success === false ? 'error' : 'success')
+            syncToolCallsRef()
+            const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
 
             const outputConfigs = streamingOptions?.outputConfigs
             const formattedOutputs: string[] = []
@@ -358,7 +529,11 @@ export function useChatStreaming() {
                   ? {
                       ...msg,
                       isStreaming: false,
+                      isThinkingStreaming: false,
+                      isToolStreaming: false,
                       content: finalContent ?? msg.content,
+                      thinking: accumulatedThinking || msg.thinking,
+                      toolCalls: toolsSnapshot ?? msg.toolCalls,
                       files: extractedFiles.length > 0 ? extractedFiles : undefined,
                     }
                   : msg
@@ -366,6 +541,8 @@ export function useChatStreaming() {
             )
 
             accumulatedTextRef.current = ''
+            accumulatedThinkingRef.current = ''
+            accumulatedToolCallsRef.current = []
             lastStreamedPositionRef.current = 0
             lastDisplayedPositionRef.current = 0
             audioStreamingActiveRef.current = false
@@ -374,13 +551,46 @@ export function useChatStreaming() {
             return true
           }
 
-          if (blockId && contentChunk) {
+          if (isChatChunkResetFrame(json)) {
+            // The block's live-streamed text belonged to an intermediate turn
+            // (tool calls follow); drop it — the final turn re-streams after.
+            // Remove the block from the order too: its re-streamed text
+            // re-registers at the end, keeping render order = arrival order
+            // (the server re-computes the cross-block separator on re-stream).
+            const { blockId } = json
+            if (blockTextSegments.has(blockId)) {
+              blockTextSegments.delete(blockId)
+              const orderIndex = blockTextOrder.indexOf(blockId)
+              if (orderIndex !== -1) {
+                blockTextOrder.splice(orderIndex, 1)
+              }
+              recomputeAccumulatedText()
+              // Spoken audio cannot be unplayed; clamp so slicing stays valid.
+              lastAudioPosition = Math.min(lastAudioPosition, accumulatedText.length)
+              uiDirty = true
+              scheduleUIFlush()
+            }
+            return false
+          }
+
+          // Answer text only — never append thinking/tool/unknown chunk frames blindly.
+          if (isChatChunkFrame(json)) {
+            const { blockId, chunk: contentChunk } = json
             if (!messageIdMap.has(blockId)) {
               messageIdMap.set(blockId, messageId)
             }
 
-            accumulatedText += contentChunk
-            accumulatedTextRef.current = accumulatedText
+            // First answer chunk settles thinking chrome (still visible, no longer “live”).
+            if (isThinkingStreaming) {
+              isThinkingStreaming = false
+            }
+
+            if (!blockTextSegments.has(blockId)) {
+              blockTextOrder.push(blockId)
+              blockTextSegments.set(blockId, '')
+            }
+            blockTextSegments.set(blockId, blockTextSegments.get(blockId)! + contentChunk)
+            recomputeAccumulatedText()
             logger.debug('[useChatStreaming] Received chunk', {
               blockId,
               chunkLength: contentChunk.length,
@@ -416,17 +626,47 @@ export function useChatStreaming() {
                 }
               }
             }
-          } else if (blockId && eventType === 'end') {
-            setMessages((prev) =>
-              prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
-            )
           }
         },
       })
 
       if (!terminated) {
         flushUI()
+        // Stream closed without a terminal final/error frame (abrupt disconnect,
+        // or only non-terminal stream_error). Clear live chrome so the UI does not
+        // stay stuck in a streaming/loading state.
+        const wasAborted = streamAbortSignal.aborted
+        settleRunningToolCalls(toolCallsMap, wasAborted ? 'cancelled' : 'error')
+        syncToolCallsRef()
+        isThinkingStreaming = false
+        const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
+        setMessages((prev) =>
+          prev.map((msg) => {
+            if (msg.id !== messageId) return msg
+            // stopStreaming already wrote the stop notice into content; do not clobber it.
+            if (wasAborted) {
+              return {
+                ...msg,
+                isStreaming: false,
+                isThinkingStreaming: false,
+                isToolStreaming: false,
+                thinking: accumulatedThinking || msg.thinking,
+                toolCalls: toolsSnapshot ?? msg.toolCalls,
+              }
+            }
+            return {
+              ...msg,
+              isStreaming: false,
+              isThinkingStreaming: false,
+              isToolStreaming: false,
+              content: accumulatedText || msg.content,
+              thinking: accumulatedThinking || msg.thinking,
+              toolCalls: toolsSnapshot ?? msg.toolCalls,
+            }
+          })
+        )
         if (
+          !wasAborted &&
           shouldPlayAudio &&
           streamingOptions?.audioStreamHandler &&
           accumulatedText.length > lastAudioPosition
@@ -442,10 +682,31 @@ export function useChatStreaming() {
         }
       }
     } catch (error) {
-      logger.error('Error processing stream:', error)
+      // Stop / timeout abort the shared fetch controller; body read then throws AbortError.
+      // Match chat.tsx + use-audio-streaming: expected cancel, not a hard failure.
+      if (error instanceof Error && error.name === 'AbortError') {
+        logger.info('Stream aborted by user or timeout')
+        settleRunningToolCalls(toolCallsMap, 'cancelled')
+      } else {
+        logger.error('Error processing stream:', error)
+        settleRunningToolCalls(toolCallsMap, 'error')
+      }
+      syncToolCallsRef()
       flushUI()
+      const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
       setMessages((prev) =>
-        prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
+        prev.map((msg) =>
+          msg.id === messageId
+            ? {
+                ...msg,
+                isStreaming: false,
+                isThinkingStreaming: false,
+                isToolStreaming: false,
+                thinking: accumulatedThinking || msg.thinking,
+                toolCalls: toolsSnapshot ?? msg.toolCalls,
+              }
+            : msg
+        )
       )
     } finally {
       if (uiRAF !== null) cancelAnimationFrame(uiRAF)
@@ -453,11 +714,10 @@ export function useChatStreaming() {
       setIsStreamingResponse(false)
       abortControllerRef.current = null
 
-      if (!userHasScrolled) {
-        setTimeout(() => {
-          scrollToBottom()
-        }, 300)
-      }
+      // Stick-to-bottom-aware; no-ops if the user scrolled away mid-stream.
+      setTimeout(() => {
+        scrollToBottom()
+      }, 300)
 
       if (shouldPlayAudio) {
         streamingOptions?.onAudioEnd?.()
diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts
index 248fcfa84ab..99dfa6069c2 100644
--- a/apps/sim/app/api/chat/[identifier]/otp/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts
@@ -160,6 +160,7 @@ export const PUT = withRouteHandler(
           authType: chat.authType,
           password: chat.password,
           outputConfigs: chat.outputConfigs,
+          includeThinking: chat.includeThinking,
         })
         .from(chat)
         .where(
@@ -209,6 +210,7 @@ export const PUT = withRouteHandler(
         customizations: deployment.customizations,
         authType: deployment.authType,
         outputConfigs: deployment.outputConfigs,
+        includeThinking: deployment.includeThinking ?? false,
       })
       setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
 
diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts
index 82f3def0220..928cba060ab 100644
--- a/apps/sim/app/api/chat/[identifier]/route.test.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.test.ts
@@ -36,6 +36,7 @@ function createMockNextRequest(
     method,
     headers: headersObj,
     nextUrl: parsedUrl,
+    signal: AbortSignal.timeout(60_000),
     cookies: {
       get: vi.fn().mockReturnValue(undefined),
     },
@@ -106,6 +107,7 @@ vi.mock('@/lib/uploads', () => ({
 
 vi.mock('@/lib/workflows/streaming/streaming', () => ({
   createStreamingResponse: vi.fn().mockImplementation(async () => createMockStream()),
+  agentStreamProtocolResponseHeaders: vi.fn().mockReturnValue({}),
 }))
 
 vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
@@ -124,6 +126,7 @@ vi.mock('@/lib/core/utils/sse', () => ({
 vi.mock('@/lib/core/security/encryption', () => encryptionMock)
 
 import { preprocessExecution } from '@/lib/execution/preprocessing'
+import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
 import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
 import { GET, POST } from '@/app/api/chat/[identifier]/route'
 
@@ -142,6 +145,7 @@ describe('Chat Identifier API Route', () => {
         primaryColor: '#000000',
       },
       outputConfigs: [{ blockId: 'block-1', path: 'output' }],
+      includeThinking: false,
     },
   ]
 
@@ -395,14 +399,93 @@ describe('Chat Identifier API Route', () => {
       expect(createStreamingResponse).toHaveBeenCalledWith(
         expect.objectContaining({
           executeFn: expect.any(Function),
+          requestSignal: expect.any(AbortSignal),
+          requestHeaders: expect.anything(),
           streamConfig: expect.objectContaining({
             isSecureMode: true,
             workflowTriggerType: 'chat',
+            includeThinking: false,
           }),
         })
       )
     }, 10000)
 
+    it('enables agent events for the execution only when policy and protocol header agree', async () => {
+      const thinkingChatResult = [{ ...mockChatResult[0], includeThinking: true }]
+      dbChainMockFns.select.mockImplementation((fields: Record) => {
+        if (fields && fields.isDeployed !== undefined) {
+          return {
+            from: vi.fn().mockReturnValue({
+              where: vi.fn().mockReturnValue({
+                limit: vi.fn().mockReturnValue(mockWorkflowResult),
+              }),
+            }),
+          }
+        }
+        return {
+          from: vi.fn().mockReturnValue({
+            where: vi.fn().mockReturnValue({
+              limit: vi.fn().mockReturnValue(thinkingChatResult),
+            }),
+          }),
+        }
+      })
+
+      const req = createMockNextRequest(
+        'POST',
+        { input: 'Hello world' },
+        { 'X-Sim-Stream-Protocol': 'agent-events-v1' }
+      )
+      const response = await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) })
+      expect(response.status).toBe(200)
+
+      const options = vi.mocked(createStreamingResponse).mock.calls[0][0]
+      expect(options.streamConfig).toMatchObject({ includeThinking: true })
+
+      await options.executeFn({
+        onStream: vi.fn(),
+        onBlockComplete: vi.fn(),
+        abortSignal: new AbortController().signal,
+      })
+      const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4]
+      expect(executeOptions).toMatchObject({ includeThinking: true, agentEvents: true })
+    }, 10000)
+
+    it('keeps agent events off when the protocol header is missing, even with policy on', async () => {
+      const thinkingChatResult = [{ ...mockChatResult[0], includeThinking: true }]
+      dbChainMockFns.select.mockImplementation((fields: Record) => {
+        if (fields && fields.isDeployed !== undefined) {
+          return {
+            from: vi.fn().mockReturnValue({
+              where: vi.fn().mockReturnValue({
+                limit: vi.fn().mockReturnValue(mockWorkflowResult),
+              }),
+            }),
+          }
+        }
+        return {
+          from: vi.fn().mockReturnValue({
+            where: vi.fn().mockReturnValue({
+              limit: vi.fn().mockReturnValue(thinkingChatResult),
+            }),
+          }),
+        }
+      })
+
+      const req = createMockNextRequest('POST', { input: 'Hello world' })
+      const response = await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) })
+      expect(response.status).toBe(200)
+
+      const options = vi.mocked(createStreamingResponse).mock.calls[0][0]
+      await options.executeFn({
+        onStream: vi.fn(),
+        onBlockComplete: vi.fn(),
+        abortSignal: new AbortController().signal,
+      })
+      const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4]
+      expect(executeOptions).toMatchObject({ includeThinking: true, agentEvents: false })
+    }, 10000)
+
     it('should handle streaming response body correctly', async () => {
       const req = createMockNextRequest('POST', { input: 'Hello world' })
       const params = Promise.resolve({ identifier: 'test-chat' })
diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts
index 578be93c189..f04b8ffd881 100644
--- a/apps/sim/app/api/chat/[identifier]/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.ts
@@ -27,6 +27,7 @@ interface ChatConfigSource {
   customizations: unknown
   authType: string | null
   outputConfigs: unknown
+  includeThinking?: boolean | null
 }
 
 function toChatConfigResponse(deployment: ChatConfigSource) {
@@ -37,6 +38,7 @@ function toChatConfigResponse(deployment: ChatConfigSource) {
     customizations: deployment.customizations,
     authType: deployment.authType,
     outputConfigs: deployment.outputConfigs,
+    includeThinking: deployment.includeThinking ?? false,
   }
 }
 
@@ -80,6 +82,7 @@ export const POST = withRouteHandler(
           password: chat.password,
           allowedEmails: chat.allowedEmails,
           outputConfigs: chat.outputConfigs,
+          includeThinking: chat.includeThinking,
         })
         .from(chat)
         .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
@@ -213,7 +216,12 @@ export const POST = withRouteHandler(
           }
         }
 
-        const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming')
+        const { createStreamingResponse, agentStreamProtocolResponseHeaders } = await import(
+          '@/lib/workflows/streaming/streaming'
+        )
+        const { shouldEmitAgentStreamEvents } = await import(
+          '@/lib/workflows/streaming/agent-stream-protocol'
+        )
         const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
         const { SSE_HEADERS } = await import('@/lib/core/utils/sse')
 
@@ -269,17 +277,25 @@ export const POST = withRouteHandler(
           variables: (workflowRecord?.variables as Record) ?? undefined,
         }
 
+        const includeThinking = deployment.includeThinking ?? false
+        const agentEvents = shouldEmitAgentStreamEvents({
+          includeThinking,
+          requestHeaders: request.headers,
+        })
         const stream = await createStreamingResponse({
           requestId,
           streamConfig: {
             selectedOutputs,
             isSecureMode: true,
             workflowTriggerType: 'chat',
+            includeThinking,
           },
           executionId,
           workspaceId,
           workflowId: deployment.workflowId,
           userId: resolvedActorUserId,
+          requestSignal: request.signal,
+          requestHeaders: request.headers,
           executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
             executeWorkflow(
               workflowForExecution,
@@ -297,6 +313,8 @@ export const POST = withRouteHandler(
                 abortSignal,
                 executionMode: 'stream',
                 billingAttribution,
+                includeThinking,
+                agentEvents,
               },
               executionId
             ),
@@ -304,7 +322,13 @@ export const POST = withRouteHandler(
 
         const streamResponse = new NextResponse(stream, {
           status: 200,
-          headers: SSE_HEADERS,
+          headers: {
+            ...SSE_HEADERS,
+            ...agentStreamProtocolResponseHeaders({
+              includeThinking,
+              requestHeaders: request.headers,
+            }),
+          },
         })
         return streamResponse
       } catch (error: any) {
@@ -341,6 +365,7 @@ export const GET = withRouteHandler(
           password: chat.password,
           allowedEmails: chat.allowedEmails,
           outputConfigs: chat.outputConfigs,
+          includeThinking: chat.includeThinking,
         })
         .from(chat)
         .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts
index 08acae6daa0..df98a1a7a14 100644
--- a/apps/sim/app/api/chat/manage/[id]/route.ts
+++ b/apps/sim/app/api/chat/manage/[id]/route.ts
@@ -117,6 +117,7 @@ export const PATCH = withRouteHandler(
         password,
         allowedEmails,
         outputConfigs,
+        includeThinking,
       } = validatedData
 
       if (workflowId && workflowId !== existingChat[0].workflowId) {
@@ -250,6 +251,10 @@ export const PATCH = withRouteHandler(
         updateData.outputConfigs = outputConfigs
       }
 
+      if (includeThinking !== undefined) {
+        updateData.includeThinking = includeThinking
+      }
+
       const emailCount = Array.isArray(updateData.allowedEmails)
         ? updateData.allowedEmails.length
         : undefined
@@ -263,6 +268,7 @@ export const PATCH = withRouteHandler(
         hasPassword: updateData.password !== undefined,
         emailCount,
         outputConfigsCount,
+        includeThinking: updateData.includeThinking,
       })
 
       await db.update(chat).set(updateData).where(eq(chat.id, chatId))
diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts
index 03f4e5377af..b5a4ece32ea 100644
--- a/apps/sim/app/api/chat/route.ts
+++ b/apps/sim/app/api/chat/route.ts
@@ -68,6 +68,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
       password,
       allowedEmails = [],
       outputConfigs = [],
+      includeThinking = false,
     } = parsed.data.body
 
     if (authType === 'password' && !password) {
@@ -127,6 +128,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
       password,
       allowedEmails,
       outputConfigs,
+      includeThinking,
       workspaceId: workflowRecord.workspaceId,
     })
 
diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts
index 0c0d4128d09..4a9c5e1f0e5 100644
--- a/apps/sim/app/api/knowledge/search/utils.test.ts
+++ b/apps/sim/app/api/knowledge/search/utils.test.ts
@@ -308,10 +308,23 @@ describe('Knowledge Search Utils', () => {
     it('should throw error when no API configuration provided', async () => {
       const { env } = await import('@/lib/core/config/env')
       Object.keys(env).forEach((key) => delete (env as any)[key])
+      // The env object lazily reads process.env, so a developer's local .env
+      // keys survive the deletion above — stub the direct key empty and fail
+      // the hosted rotation fallback for hermeticity on any machine.
+      vi.stubEnv('OPENAI_API_KEY', '')
+      const apiKeysModule = await import('@/lib/core/config/api-keys')
+      const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => {
+        throw new Error('No rotation keys configured')
+      })
 
-      await expect(generateSearchEmbedding('test query')).rejects.toThrow(
-        'OPENAI_API_KEY is not configured'
-      )
+      try {
+        await expect(generateSearchEmbedding('test query')).rejects.toThrow(
+          'OPENAI_API_KEY is not configured'
+        )
+      } finally {
+        rotationSpy.mockRestore()
+        vi.unstubAllEnvs()
+      }
     })
 
     it('should handle Azure OpenAI API errors properly', async () => {
diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts
index f7a0297ef52..3ec06bc2e55 100644
--- a/apps/sim/app/api/knowledge/utils.test.ts
+++ b/apps/sim/app/api/knowledge/utils.test.ts
@@ -345,10 +345,23 @@ describe('Knowledge Utils', () => {
     it('should throw error when no API configuration provided', async () => {
       const { env } = await import('@/lib/core/config/env')
       Object.keys(env).forEach((key) => delete (env as any)[key])
+      // The env object lazily reads process.env, so a developer's local .env
+      // keys survive the deletion above — stub the direct key empty and fail
+      // the hosted rotation fallback for hermeticity on any machine.
+      vi.stubEnv('OPENAI_API_KEY', '')
+      const apiKeysModule = await import('@/lib/core/config/api-keys')
+      const rotationSpy = vi.spyOn(apiKeysModule, 'getRotatingApiKey').mockImplementation(() => {
+        throw new Error('No rotation keys configured')
+      })
 
-      await expect(generateEmbeddings(['test text'])).rejects.toThrow(
-        'OPENAI_API_KEY is not configured'
-      )
+      try {
+        await expect(generateEmbeddings(['test text'])).rejects.toThrow(
+          'OPENAI_API_KEY is not configured'
+        )
+      } finally {
+        rotationSpy.mockRestore()
+        vi.unstubAllEnvs()
+      }
     })
   })
 })
diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts
index dbb87f071c9..0794be56c31 100644
--- a/apps/sim/app/api/providers/route.ts
+++ b/apps/sim/app/api/providers/route.ts
@@ -29,6 +29,7 @@ import {
 } from '@/ee/access-control/utils/permission-check'
 import type { StreamingExecution } from '@/executor/types'
 import { executeProviderRequest } from '@/providers'
+import { projectStreamingExecutionToByteStream } from '@/providers/stream-pump'
 
 const logger = createLogger('ProvidersAPI')
 
@@ -274,8 +275,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
       logger.info(`[${requestId}] Received StreamingExecution from provider`)
 
       // Extract the stream and execution data
-      const stream = streamingExec.stream
       const executionData = streamingExec.execution
+      // agent-events-v1 is an object stream — project final-turn answer bytes for HTTP.
+      const byteStream = projectStreamingExecutionToByteStream(streamingExec)
 
       // Attach the execution data as a custom header
       // We need to safely serialize the execution data to avoid circular references
@@ -324,7 +326,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
       }
 
       // Return the stream with execution data in a header
-      return new Response(stream, {
+      return new Response(byteStream, {
         headers: {
           'Content-Type': 'text/event-stream',
           'Cache-Control': 'no-cache',
diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
index 6ea631652f6..eae14fecd23 100644
--- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
+++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
@@ -20,7 +20,10 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
 import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
 import { preprocessExecution } from '@/lib/execution/preprocessing'
 import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
-import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
+import {
+  agentStreamProtocolResponseHeaders,
+  createStreamingResponse,
+} from '@/lib/workflows/streaming/streaming'
 import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
 import type { ResumeExecutionPayload } from '@/background/resume-execution'
 import { ExecutionSnapshot } from '@/executor/execution/snapshot'
@@ -257,12 +260,15 @@ export const POST = withRouteHandler(
           streamConfig: {
             selectedOutputs: persistedSnapshot.selectedOutputs,
             timeoutMs: preprocessResult.executionTimeout?.sync,
+            includeThinking: persistedSnapshot.metadata.includeThinking === true,
           },
           executionId: enqueueResult.resumeExecutionId,
           workspaceId: workflow.workspaceId || undefined,
           workflowId,
           userId: enqueueResult.userId,
           allowLargeValueWorkflowScope: true,
+          requestSignal: request.signal,
+          requestHeaders: request.headers,
           executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
             PauseResumeManager.startResumeExecution({
               ...resumeArgs,
@@ -275,6 +281,11 @@ export const POST = withRouteHandler(
         return new NextResponse(stream, {
           headers: {
             ...SSE_HEADERS,
+            // Echo the negotiated stream protocol (same as the public chat route).
+            ...agentStreamProtocolResponseHeaders({
+              includeThinking: persistedSnapshot.metadata.includeThinking === true,
+              requestHeaders: request.headers,
+            }),
             'X-Execution-Id': enqueueResult.resumeExecutionId,
           },
         })
diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts
index d94b3abcc2a..dae95ff2a1a 100644
--- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts
+++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts
@@ -74,6 +74,7 @@ describe('Workflow Chat Status Route', () => {
         authType: 'public',
         allowedEmails: [],
         outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
+        includeThinking: false,
         password: 'secret',
         isActive: true,
       },
@@ -88,5 +89,6 @@ describe('Workflow Chat Status Route', () => {
     expect(data.deployment.id).toBe('chat-1')
     expect(data.deployment.hasPassword).toBe(true)
     expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }])
+    expect(data.deployment.includeThinking).toBe(false)
   })
 })
diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts
index 49d555b813d..764f052f8c6 100644
--- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts
+++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts
@@ -52,6 +52,7 @@ export const GET = withRouteHandler(
           authType: chat.authType,
           allowedEmails: chat.allowedEmails,
           outputConfigs: chat.outputConfigs,
+          includeThinking: chat.includeThinking,
           password: chat.password,
           isActive: chat.isActive,
         })
@@ -71,6 +72,7 @@ export const GET = withRouteHandler(
               authType: deploymentResults[0].authType,
               allowedEmails: deploymentResults[0].allowedEmails,
               outputConfigs: deploymentResults[0].outputConfigs,
+              includeThinking: deploymentResults[0].includeThinking ?? false,
               hasPassword: Boolean(deploymentResults[0].password),
             }
           : null
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index 49f50b3ae21..d5a5b5d0aaf 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -71,7 +71,11 @@ import {
 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'
+import {
+  type ExecutionEvent,
+  encodeSSEEvent,
+  LIVE_ONLY_EXECUTION_EVENT_TYPES,
+} from '@/lib/workflows/executor/execution-events'
 import {
   claimExecutionId,
   type ExecutionIdClaim,
@@ -84,6 +88,10 @@ import {
   loadWorkflowDeploymentVersionState,
   loadWorkflowFromNormalizedTables,
 } from '@/lib/workflows/persistence/utils'
+import {
+  forwardAgentStreamToExecutionEvents,
+  shouldForwardAnswerTextFromSink,
+} from '@/lib/workflows/streaming/forward-agent-stream-events'
 import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
 import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
 import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
@@ -1437,6 +1445,8 @@ async function handleExecutePost(
           includeFileBase64,
           base64MaxBytes,
           timeoutMs: preprocessResult.executionTimeout?.sync,
+          // Workflow API has no chat includeThinking policy — thinking frames stay off.
+          includeThinking: false,
         },
         executionId,
         largeValueExecutionIds,
@@ -1446,6 +1456,8 @@ async function handleExecutePost(
         workflowId,
         userId: actorUserId,
         allowLargeValueWorkflowScope,
+        requestSignal: req.signal,
+        requestHeaders: req.headers,
         executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
           executeWorkflow(
             streamWorkflow,
@@ -1518,7 +1530,7 @@ async function handleExecutePost(
           event: ExecutionEvent,
           terminalStatus?: TerminalExecutionStreamStatus
         ) => {
-          const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done'
+          const isBuffered = !LIVE_ONLY_EXECUTION_EVENT_TYPES.has(event.type)
           let eventToSend = event
           if (isBuffered) {
             try {
@@ -1716,6 +1728,21 @@ async function handleExecutePost(
           const onStream = async (streamingExec: StreamingExecution) => {
             const blockId = (streamingExec.execution as any).blockId
 
+            // Live answer text rides the sink when available (pending deltas
+            // stream as the model generates; chunk_reset clears intermediate
+            // turns). The byte stream is then drained without re-emitting
+            // chunks — its text is the same final-turn content.
+            const answerTextFromSink = shouldForwardAnswerTextFromSink(streamingExec)
+
+            // Sync window: attach sink before first await so pump delivers thinking/tools.
+            const unsubscribe = forwardAgentStreamToExecutionEvents(streamingExec, {
+              blockId,
+              executionId,
+              workflowId,
+              sendEvent,
+              forwardAnswerText: answerTextFromSink,
+            })
+
             const reader = streamingExec.stream.getReader()
             const decoder = new TextDecoder()
             const cancelReader = () => {
@@ -1732,6 +1759,8 @@ async function handleExecutePost(
                 if (timeoutController.signal.aborted || isStreamClosed) break
                 if (done) break
 
+                if (answerTextFromSink) continue
+
                 const chunk = decoder.decode(value, { stream: true })
                 await sendEvent({
                   type: 'stream:chunk',
@@ -1756,6 +1785,7 @@ async function handleExecutePost(
                 reqLogger.error('Error streaming block content:', error)
               }
             } finally {
+              unsubscribe()
               timeoutController.signal.removeEventListener('abort', cancelReader)
               try {
                 await reader.cancel().catch(() => {})
@@ -1785,6 +1815,8 @@ async function handleExecutePost(
             allowLargeValueWorkflowScope,
             callChain,
             executionMode: 'sync',
+            // Canvas execution-events runs are the primary agent-events surface.
+            agentEvents: true,
           }
 
           const sseExecutionVariables = cachedWorkflowData?.variables ?? workflow.variables ?? {}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
index f82386f7e72..b002bc41896 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx
@@ -238,6 +238,7 @@ export function Chat() {
     selectedWorkflowOutputs,
     setSelectedWorkflowOutput,
     appendMessageContent,
+    setMessageContent,
     finalizeMessageStream,
     getConversationId,
     clearChat,
@@ -256,6 +257,7 @@ export function Chat() {
       selectedWorkflowOutputs: s.selectedWorkflowOutputs,
       setSelectedWorkflowOutput: s.setSelectedWorkflowOutput,
       appendMessageContent: s.appendMessageContent,
+      setMessageContent: s.setMessageContent,
       finalizeMessageStream: s.finalizeMessageStream,
       getConversationId: s.getConversationId,
       clearChat: s.clearChat,
@@ -495,10 +497,21 @@ export function Chat() {
     async (stream: ReadableStream, responseMessageId: string) => {
       const reader = stream.getReader()
       streamReaderRef.current = reader
+
+      /**
+       * Answer text tracked per block so a `chunk_reset` frame (a live-streamed
+       * turn resolved to tool calls) can drop one block's contribution. Each
+       * flush replaces the message content with the joined segments.
+       */
+      const blockOrder: string[] = []
+      const blockSegments = new Map()
       let accumulatedContent = ''
+      const recomputeContent = () => {
+        accumulatedContent = blockOrder.map((id) => blockSegments.get(id) ?? '').join('')
+      }
 
       const BATCH_MAX_MS = 50
-      let pendingChunks = ''
+      let contentDirty = false
       let batchRAF: number | null = null
       let batchTimer: ReturnType | null = null
       let lastFlush = 0
@@ -512,9 +525,9 @@ export function Chat() {
           clearTimeout(batchTimer)
           batchTimer = null
         }
-        if (pendingChunks) {
-          appendMessageContent(responseMessageId, pendingChunks)
-          pendingChunks = ''
+        if (contentDirty) {
+          setMessageContent(responseMessageId, accumulatedContent)
+          contentDirty = false
         }
         lastFlush = performance.now()
       }
@@ -534,12 +547,17 @@ export function Chat() {
 
       let finalError: string | null = null
       try {
-        await readSSEEvents<{ event?: string; data?: ExecutionResult; chunk?: string }>(reader, {
+        await readSSEEvents<{
+          event?: string
+          data?: ExecutionResult
+          chunk?: string
+          blockId?: string
+        }>(reader, {
           onParseError: (_data, e) => {
             logger.error('Error parsing stream data:', e)
           },
           onEvent: (json) => {
-            const { event, data: eventData, chunk: contentChunk } = json
+            const { event, data: eventData, chunk: contentChunk, blockId } = json
 
             if (event === 'final' && eventData) {
               if ('success' in eventData && !eventData.success) {
@@ -548,9 +566,32 @@ export function Chat() {
               return true
             }
 
+            if (event === 'chunk_reset' && blockId) {
+              // Drop the block's provisional text and its order slot — the
+              // final turn re-registers at the end, keeping render order =
+              // arrival order (separators are recomputed on re-stream).
+              if (blockSegments.has(blockId)) {
+                blockSegments.delete(blockId)
+                const orderIndex = blockOrder.indexOf(blockId)
+                if (orderIndex !== -1) {
+                  blockOrder.splice(orderIndex, 1)
+                }
+                recomputeContent()
+                contentDirty = true
+                scheduleFlush()
+              }
+              return
+            }
+
             if (contentChunk) {
-              accumulatedContent += contentChunk
-              pendingChunks += contentChunk
+              const segmentKey = blockId ?? ''
+              if (!blockSegments.has(segmentKey)) {
+                blockOrder.push(segmentKey)
+                blockSegments.set(segmentKey, '')
+              }
+              blockSegments.set(segmentKey, blockSegments.get(segmentKey)! + contentChunk)
+              recomputeContent()
+              contentDirty = true
               scheduleFlush()
             }
           },
@@ -578,7 +619,14 @@ export function Chat() {
         focusInput(100)
       }
     },
-    [appendMessageContent, finalizeMessageStream, focusInput, selectedOutputs, activeWorkflowId]
+    [
+      appendMessageContent,
+      setMessageContent,
+      finalizeMessageStream,
+      focusInput,
+      selectedOutputs,
+      activeWorkflowId,
+    ]
   )
 
   /**
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
index 2d2f1d44b14..d6efc583c74 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
@@ -11,6 +11,7 @@ import {
   Label,
   Loader,
   Skeleton,
+  Switch,
   TagInput,
   type TagItem,
   Textarea,
@@ -84,6 +85,7 @@ const initialFormData: ChatFormData = {
   emails: [],
   welcomeMessage: 'Hi there! How can I help you today?',
   selectedOutputBlocks: [],
+  includeThinking: false,
 }
 
 export function ChatDeploy({
@@ -194,6 +196,7 @@ export function ChatDeploy({
               (config: { blockId: string; path: string }) => `${config.blockId}_${config.path}`
             )
           : [],
+        includeThinking: existingChat.includeThinking ?? false,
       })
 
       if (existingChat.customizations?.imageUrl) {
@@ -369,6 +372,23 @@ export function ChatDeploy({
             )}
           
+
+
+ +

+ Allow this chat to stream model thinking when the client opts in. Off by default. +

+
+ updateField('includeThinking', checked)} + aria-label='Include thinking' + /> +
+ + {!showInput && + (selectedEntry.agentStreamThinking || + (selectedEntry.agentStreamToolCalls && + selectedEntry.agentStreamToolCalls.length > 0)) && ( +
+ {selectedEntry.agentStreamThinking ? ( + + ) : null} + {selectedEntry.agentStreamToolCalls && + selectedEntry.agentStreamToolCalls.length > 0 ? ( + t.status === 'running') + )} + /> + ) : null} +
+ )} {shouldShowCodeDisplay ? ( | return Object.keys(testInput).length > 0 ? testInput : undefined } +/** + * Thinking deltas arrive per token; batch console writes (same cadence as the + * chat surface) so the terminal does not re-render per delta. + */ +const AGENT_STREAM_THINKING_FLUSH_MS = 50 + +type UpdateConsoleFn = ReturnType<(typeof useTerminalConsoleStore)['getState']>['updateConsole'] + +interface AgentStreamChromeOptions { + executionIdRef: { current: string } + updateConsole: UpdateConsoleFn +} + +/** + * Per-run terminal chrome for live agent stream events: accumulates thinking + * text (batched) and tool chips per block, and settles running chips when a + * block's stream ends, a block errors, or the execution terminates. Shared by + * the full-run and run-from-block paths so both render identical chrome. + */ +function createAgentStreamChrome({ executionIdRef, updateConsole }: AgentStreamChromeOptions) { + const thinkingByBlock = new Map() + const toolCallsByBlock = new Map>() + const toolOrderByBlock = new Map() + const thinkingFlushTimers = new Map>() + + const flushThinking = (blockId: string) => { + const timer = thinkingFlushTimers.get(blockId) + if (timer !== undefined) { + clearTimeout(timer) + thinkingFlushTimers.delete(blockId) + } + const thinking = thinkingByBlock.get(blockId) + if (thinking === undefined) return + updateConsole( + blockId, + { agentStreamThinking: thinking, agentStreamActive: true }, + executionIdRef.current + ) + } + + const settleBlock = (blockId: string, status: 'success' | 'error' | 'cancelled') => { + flushThinking(blockId) + const map = toolCallsByBlock.get(blockId) + const order = toolOrderByBlock.get(blockId) + if (map && order) { + settleRunningToolCalls(map, status) + updateConsole( + blockId, + { + agentStreamActive: false, + agentStreamToolCalls: snapshotToolCalls(order, map), + }, + executionIdRef.current + ) + } else { + updateConsole(blockId, { agentStreamActive: false }, executionIdRef.current) + } + } + + const settleAll = (status: 'success' | 'error' | 'cancelled') => { + const blockIds = new Set([...thinkingByBlock.keys(), ...toolCallsByBlock.keys()]) + for (const blockId of blockIds) { + settleBlock(blockId, status) + } + } + + const onStreamThinking = (data: StreamThinkingData) => { + const prev = thinkingByBlock.get(data.blockId) ?? '' + thinkingByBlock.set(data.blockId, prev + data.text) + if (!thinkingFlushTimers.has(data.blockId)) { + thinkingFlushTimers.set( + data.blockId, + setTimeout(() => flushThinking(data.blockId), AGENT_STREAM_THINKING_FLUSH_MS) + ) + } + } + + const onStreamTool = (data: StreamToolData) => { + if (!toolCallsByBlock.has(data.blockId)) { + toolCallsByBlock.set(data.blockId, new Map()) + toolOrderByBlock.set(data.blockId, []) + } + const map = toolCallsByBlock.get(data.blockId)! + const order = toolOrderByBlock.get(data.blockId)! + + applyToolCallPhase( + map, + order, + { + key: toolCallKey(data.blockId, data.id), + id: data.id, + name: data.name, + phase: data.phase, + status: data.status, + }, + (tool) => tool + ) + + updateConsole( + data.blockId, + { + agentStreamToolCalls: snapshotToolCalls(order, map), + agentStreamActive: true, + }, + executionIdRef.current + ) + } + + const onStreamDone = (data: StreamDoneData) => { + logger.info('Stream done for block:', data.blockId) + settleBlock(data.blockId, 'success') + } + + return { flushThinking, settleBlock, settleAll, onStreamThinking, onStreamTool, onStreamDone } +} + export function useWorkflowExecution() { const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>() const hydrationWorkspaceId = useWorkflowRegistry((s) => s.hydration.workspaceId) @@ -625,6 +753,19 @@ export function useWorkflowExecution() { streamReadingPromises.push(promise) } + /** + * Intermediate-turn reconciliation: drop the block's streamed text + * (chunk_reset frame) and remove its bookkeeping entirely so + * separator counting ignores it and the final turn (or, if none + * re-streams, onBlockComplete's output fallback) starts clean. + */ + const onStreamReset = (blockId: string) => { + if (!streamedChunks.has(blockId)) return + streamedChunks.delete(blockId) + processedFirstChunk.delete(blockId) + safeEnqueue(encodeSSE({ blockId, event: 'chunk_reset' })) + } + // Handle non-streaming blocks (like Function blocks) const onBlockComplete = async (blockId: string, output: any) => { // Skip if this block already had streaming content (avoid duplicates) @@ -682,7 +823,9 @@ export function useWorkflowExecution() { onStream, executionId, onBlockComplete, - 'chat' + 'chat', + undefined, + onStreamReset ) // Check if execution was cancelled @@ -846,7 +989,8 @@ export function useWorkflowExecution() { executionId?: string, onBlockComplete?: (blockId: string, output: any) => Promise, overrideTriggerType?: 'chat' | 'manual' | 'api', - stopAfterBlockId?: string + stopAfterBlockId?: string, + onStreamReset?: (blockId: string) => void ): Promise => { // Use diff workflow for execution when available, regardless of canvas view state const executionWorkflowState = null as { @@ -1063,6 +1207,9 @@ export function useWorkflowExecution() { const activeBlocksSet = new Set() const activeBlockRefCounts = new Map() const streamedChunks = new Map() + const agentStreamChrome = createAgentStreamChrome({ executionIdRef, updateConsole }) + const settleAgentStreamChrome = agentStreamChrome.settleBlock + const settleAllAgentStreamChrome = agentStreamChrome.settleAll const accumulatedBlockLogs: BlockLog[] = [] const accumulatedBlockStates = new Map() const executedBlockIds = new Set() @@ -1134,7 +1281,11 @@ export function useWorkflowExecution() { onBlockStarted: blockHandlers.onBlockStarted, onBlockCompleted: blockHandlers.onBlockCompleted, - onBlockError: blockHandlers.onBlockError, + onBlockError: (data) => { + // Failures often skip stream:done — settle thinking/tool chrome here. + settleAgentStreamChrome(data.blockId, 'error') + blockHandlers.onBlockError(data) + }, onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted, onStreamChunk: (data) => { @@ -1167,10 +1318,19 @@ export function useWorkflowExecution() { } }, - onStreamDone: (data) => { - logger.info('Stream done for block:', data.blockId) + onStreamChunkReset: (data) => { + // Live-streamed text belonged to an intermediate turn (tools + // follow); the final turn re-streams as regular chunks. + streamedChunks.delete(data.blockId) + if (onStreamReset && isExecutingFromChat) { + onStreamReset(data.blockId) + } }, + onStreamThinking: agentStreamChrome.onStreamThinking, + onStreamTool: agentStreamChrome.onStreamTool, + onStreamDone: agentStreamChrome.onStreamDone, + onExecutionCompleted: (data) => { executionFinished = true if ( @@ -1181,6 +1341,8 @@ export function useWorkflowExecution() { ) return + settleAllAgentStreamChrome(data.success ? 'success' : 'error') + if (activeWorkflowId) { setCurrentExecutionId(activeWorkflowId, null) reconcileFinalBlockLogs( @@ -1278,6 +1440,9 @@ export function useWorkflowExecution() { ) return + // HITL pause mid tool-loop — open tools never got an end event. + settleAllAgentStreamChrome('cancelled') + if (activeWorkflowId) { setCurrentExecutionId(activeWorkflowId, null) reconcileFinalBlockLogs( @@ -1322,6 +1487,8 @@ export function useWorkflowExecution() { ) return + settleAllAgentStreamChrome('error') + if (activeWorkflowId) { setCurrentExecutionId(activeWorkflowId, null) } @@ -1364,6 +1531,8 @@ export function useWorkflowExecution() { ) return + settleAllAgentStreamChrome('cancelled') + if (activeWorkflowId) { setCurrentExecutionId(activeWorkflowId, null) } @@ -1807,6 +1976,7 @@ export function useWorkflowExecution() { const executedBlockIds = new Set() const activeBlocksSet = new Set() const activeBlockRefCounts = new Map() + const agentStreamChrome = createAgentStreamChrome({ executionIdRef, updateConsole }) const isCurrentRunFromBlockExecution = () => { return ( Boolean(executionIdRef.current) && @@ -1860,11 +2030,20 @@ export function useWorkflowExecution() { onBlockStarted: blockHandlers.onBlockStarted, onBlockCompleted: blockHandlers.onBlockCompleted, - onBlockError: blockHandlers.onBlockError, + onBlockError: (data) => { + // Failures often skip stream:done — settle thinking/tool chrome here. + agentStreamChrome.settleBlock(data.blockId, 'error') + blockHandlers.onBlockError(data) + }, onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted, + onStreamThinking: agentStreamChrome.onStreamThinking, + onStreamTool: agentStreamChrome.onStreamTool, + onStreamDone: agentStreamChrome.onStreamDone, + onExecutionCompleted: (data) => { if (!isCurrentRunFromBlockExecution()) return + agentStreamChrome.settleAll(data.success ? 'success' : 'error') const executionId = executionIdRef.current reconcileFinalBlockLogs(updateConsole, workflowId, executionId, data.finalBlockLogs) finishRunningEntries(workflowId, executionId) @@ -1899,6 +2078,8 @@ export function useWorkflowExecution() { onExecutionPaused: (data) => { if (!isCurrentRunFromBlockExecution()) return + // HITL pause mid tool-loop — open tools never got an end event. + agentStreamChrome.settleAll('cancelled') const executionId = executionIdRef.current reconcileFinalBlockLogs(updateConsole, workflowId, executionId, data.finalBlockLogs) finishRunningEntries(workflowId, executionId) @@ -1918,6 +2099,7 @@ export function useWorkflowExecution() { onExecutionError: (data) => { if (!isCurrentRunFromBlockExecution()) return + agentStreamChrome.settleAll('error') const executionId = executionIdRef.current const isWorkflowModified = data.error?.includes('Block not found in workflow') || @@ -1944,6 +2126,7 @@ export function useWorkflowExecution() { onExecutionCancelled: (data) => { if (!isCurrentRunFromBlockExecution()) return + agentStreamChrome.settleAll('cancelled') const executionId = executionIdRef.current handleExecutionCancelledConsole({ workflowId, diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx new file mode 100644 index 00000000000..30b5984b96b --- /dev/null +++ b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx @@ -0,0 +1,286 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/lib/copilot/tools/tool-display', () => ({ + humanizeToolName: (name: string) => name, +})) + +vi.mock('@/components/ui', () => ({ + ShimmerText: ({ + as: Comp = 'span', + children, + className, + ...props + }: { + as?: 'span' | 'div' + children: React.ReactNode + className?: string + [key: string]: unknown + }) => { + const Tag = Comp + return ( + + {children} + + ) + }, +})) + +import { + AgentStreamThinkingChrome, + AgentStreamToolCallsChrome, +} from '@/components/agent-stream/agent-stream-chrome' +import type { AgentStreamToolCall } from '@/components/agent-stream/tool-call-lifecycle' + +function renderChrome(props: { thinking: string; isStreaming?: boolean }): { + container: HTMLDivElement + rerender: (next: { thinking: string; isStreaming?: boolean }) => void + unmount: () => void +} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + + const mount = (p: { thinking: string; isStreaming?: boolean }) => { + act(() => { + root.render() + }) + } + + mount(props) + + return { + container, + rerender: (next) => mount(next), + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('AgentStreamThinkingChrome', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('opens while streaming with Thinking… label and scrollable body', () => { + const { container, unmount } = renderChrome({ + thinking: 'step one', + isStreaming: true, + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement + + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Thinking…') + expect( + container + .querySelector('[data-testid="agent-stream-thinking-label"]') + ?.getAttribute('data-shimmer') + ).toBe('true') + expect(body.className).toContain('max-h-40') + expect(body.className).toContain('overflow-y-auto') + expect(body.getAttribute('data-shimmer')).toBeNull() + expect( + container + .querySelector('[data-testid="agent-stream-thinking-shimmer"]') + ?.getAttribute('data-shimmer') + ).toBe('true') + expect(body.textContent).toContain('step one') + }) + + it('auto-collapses when streaming ends and shows Thought for a moment', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'long internal chain', + isStreaming: true, + }) + mounts.push(unmount) + + rerender({ thinking: 'long internal chain', isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement + + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toContain('Thought for a moment') + expect( + container + .querySelector('[data-testid="agent-stream-thinking-label"]') + ?.getAttribute('data-shimmer') + ).toBeNull() + expect(container.querySelector('[data-testid="agent-stream-thinking-shimmer"]')).toBeNull() + expect(body.className).toContain('text-[var(--text-muted)]') + }) + + it('stays open after manual reopen once collapsed', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'reason\n'.repeat(40), + isStreaming: true, + }) + mounts.push(unmount) + + rerender({ thinking: 'reason\n'.repeat(40), isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement + Object.defineProperty(body, 'scrollTop', { value: 80, writable: true, configurable: true }) + + act(() => { + toggle.click() + }) + + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(body.scrollTop).toBe(0) + expect(body.textContent).toContain('reason') + + // Re-render with same done state should not force-close a user pin. + rerender({ thinking: 'reason\n'.repeat(40), isStreaming: false }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) + + it('re-opens when a new streaming phase starts', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'first', + isStreaming: false, + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + // Initial non-streaming starts closed. + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + rerender({ thinking: 'first then more', isStreaming: true }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Thinking…') + }) +}) + +const sampleTools: AgentStreamToolCall[] = [ + { + key: 'agent-1:t1', + id: 't1', + name: 'http_request', + displayName: 'Http Request', + status: 'success', + }, +] + +function renderToolsChrome(props: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }): { + container: HTMLDivElement + rerender: (next: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }) => void + unmount: () => void +} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + + const mount = (p: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }) => { + act(() => { + root.render( + + ) + }) + } + + mount(props) + + return { + container, + rerender: (next) => mount(next), + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('AgentStreamToolCallsChrome', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('opens while tools are streaming and auto-collapses when they finish', () => { + const { container, rerender, unmount } = renderToolsChrome({ + isStreaming: true, + toolCalls: [{ ...sampleTools[0], status: 'running' }], + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-tools-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Using tools…') + expect(container.textContent).toContain('Http Request') + + rerender({ isStreaming: false, toolCalls: sampleTools }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toContain('Tools') + expect(container.textContent).not.toContain('Http Request') + }) + + it('stays open after manual reopen once collapsed', () => { + const { container, rerender, unmount } = renderToolsChrome({ isStreaming: true }) + mounts.push(unmount) + + rerender({ isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-tools-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + act(() => { + toggle.click() + }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(container.textContent).toContain('Http Request') + + rerender({ isStreaming: false }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) +}) diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx new file mode 100644 index 00000000000..5c34c1dd25a --- /dev/null +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { cn } from '@sim/emcn' +import { Check, ChevronDown, Circle, Square, X } from 'lucide-react' +import type { + AgentStreamToolCall, + AgentStreamToolStatus, +} from '@/components/agent-stream/tool-call-lifecycle' +import { ShimmerText } from '@/components/ui' +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' + +/** Distance from bottom (px) within which we keep following new thinking text. */ +const STICK_TO_BOTTOM_THRESHOLD_PX = 24 + +/** + * Open / pinned-open / auto-collapse state shared by both chrome panels: + * streaming forces the panel open, the panel auto-collapses when streaming + * ends unless the user pinned it open, and manual toggles while idle pin it. + */ +function useAutoCollapseOpen(isStreaming: boolean, onOpen?: (streaming: boolean) => void) { + const [open, setOpen] = useState(!!isStreaming) + const [userPinnedOpen, setUserPinnedOpen] = useState(false) + const wasStreamingRef = useRef(!!isStreaming) + + useEffect(() => { + const wasStreaming = wasStreamingRef.current + wasStreamingRef.current = !!isStreaming + + if (isStreaming) { + setOpen(true) + setUserPinnedOpen(false) + return + } + + if (wasStreaming && !isStreaming && !userPinnedOpen) { + setOpen(false) + } + }, [isStreaming, userPinnedOpen]) + + const toggle = () => { + setOpen((prev) => { + const next = !prev + if (!isStreaming) { + setUserPinnedOpen(next) + } else { + setUserPinnedOpen(false) + } + if (next) { + onOpen?.(!!isStreaming) + } + return next + }) + } + + return { open, toggle } +} + +export interface AgentStreamThinkingChromeProps { + thinking: string + isStreaming?: boolean +} + +export function AgentStreamThinkingChrome({ + thinking, + isStreaming = false, +}: AgentStreamThinkingChromeProps) { + const [stickToBottom, setStickToBottom] = useState(true) + const [overflowing, setOverflowing] = useState(false) + const scrollRef = useRef(null) + /** After a manual reopen of completed thoughts, jump to the top once. */ + const reopenFromTopRef = useRef(false) + + const { open, toggle } = useAutoCollapseOpen(isStreaming, (streaming) => { + if (streaming) { + setStickToBottom(true) + } else { + // ChatGPT-style: reopen completed thoughts from the top. + setStickToBottom(false) + reopenFromTopRef.current = true + } + }) + + useEffect(() => { + if (isStreaming) { + setStickToBottom(true) + } + }, [isStreaming]) + + useLayoutEffect(() => { + const el = scrollRef.current + if (!el || !open) return + + setOverflowing(el.scrollHeight > el.clientHeight + 1) + + if (reopenFromTopRef.current && !isStreaming) { + el.scrollTop = 0 + reopenFromTopRef.current = false + return + } + + if (isStreaming && stickToBottom) { + el.scrollTop = el.scrollHeight + } + }, [thinking, open, isStreaming, stickToBottom]) + + const handleScroll = () => { + const el = scrollRef.current + if (!el) return + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight + const nearBottom = distanceFromBottom <= STICK_TO_BOTTOM_THRESHOLD_PX + setStickToBottom(nearBottom) + setOverflowing(el.scrollHeight > el.clientHeight + 1) + } + + const label = isStreaming ? 'Thinking…' : 'Thought for a moment' + + return ( +
+ + +
+
+
+
+ {/* Shimmer on an inner node — never on the scroll shell. background-clip:text + on overflow-y-auto breaks scroll/overflow in Chromium. */} + {isStreaming ? ( + + {thinking} + + ) : ( + thinking + )} +
+ {open && isStreaming && overflowing && ( +
+ )} +
+
+
+
+ ) +} + +function ToolStatusIcon({ status }: { status: AgentStreamToolStatus }) { + if (status === 'success') { + return + } + if (status === 'error') { + return + } + if (status === 'cancelled') { + return + } + return +} + +export interface AgentStreamToolCallsChromeProps { + toolCalls: AgentStreamToolCall[] + isStreaming?: boolean +} + +export function AgentStreamToolCallsChrome({ + toolCalls, + isStreaming, +}: AgentStreamToolCallsChromeProps) { + const { open, toggle } = useAutoCollapseOpen(!!isStreaming) + + return ( +
+ + {open && ( +
    + {toolCalls.map((tool) => ( +
  • + + + {tool.displayName || humanizeToolName(tool.name)} + {tool.status === 'running' ? '…' : ''} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/apps/sim/components/agent-stream/tool-call-lifecycle.ts b/apps/sim/components/agent-stream/tool-call-lifecycle.ts new file mode 100644 index 00000000000..a338d8d63a5 --- /dev/null +++ b/apps/sim/components/agent-stream/tool-call-lifecycle.ts @@ -0,0 +1,117 @@ +/** + * Shared client-side reducer for agent-stream tool chips. + * + * The public chat hook, the canvas execution hook, and the terminal console + * store all consume the same tool lifecycle (keyed ordered upsert on + * start/end, settle-running-on-terminal). This module is the single + * implementation so the three surfaces cannot drift. + */ + +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' + +export type AgentStreamToolStatus = 'running' | 'success' | 'error' | 'cancelled' + +export interface AgentStreamToolCall { + key: string + id: string + name: string + displayName?: string + status: AgentStreamToolStatus +} + +/** Terminal statuses a running chip can settle to. */ +export type AgentStreamToolTerminalStatus = Exclude + +/** Canonical chip key — unique per block and tool call within an execution. */ +export function toolCallKey(blockId: string, id: string): string { + return `${blockId}:${id}` +} + +/** Normalizes a wire `status` into a terminal chip status (default success). */ +export function resolveToolCallEndStatus(status?: string): AgentStreamToolTerminalStatus { + return status === 'error' || status === 'cancelled' ? status : 'success' +} + +/** + * Applies a tool lifecycle phase to a keyed map + insertion-order list. + * `extend` lets callers add surface-specific fields (e.g. chat's `blockId`). + */ +export function applyToolCallPhase( + map: Map, + order: string[], + event: { key: string; id: string; name: string; phase: 'start' | 'end'; status?: string }, + extend: (tool: AgentStreamToolCall) => T +): void { + const { key } = event + if (event.phase === 'start') { + if (!map.has(key)) { + order.push(key) + } + map.set( + key, + extend({ + key, + id: event.id, + name: event.name, + displayName: humanizeToolName(event.name), + status: 'running', + }) + ) + return + } + + const endStatus = resolveToolCallEndStatus(event.status) + const existing = map.get(key) + if (!existing) { + order.push(key) + map.set( + key, + extend({ + key, + id: event.id, + name: event.name, + displayName: humanizeToolName(event.name), + status: endStatus, + }) + ) + return + } + map.set(key, { ...existing, status: endStatus }) +} + +/** Settles every still-running chip in the map to a terminal status. */ +export function settleRunningToolCalls( + map: Map, + status: AgentStreamToolTerminalStatus +): void { + for (const [key, tool] of map) { + if (tool.status === 'running') { + map.set(key, { ...tool, status }) + } + } +} + +/** List variant of {@link settleRunningToolCalls} for immutable store entries. */ +export function settleRunningToolCallList( + toolCalls: T[] | undefined, + status: AgentStreamToolTerminalStatus +): T[] | undefined { + return toolCalls?.map((tool) => (tool.status === 'running' ? { ...tool, status } : tool)) +} + +/** Ordered snapshot of the map, or undefined when no chips exist. */ +export function snapshotToolCalls( + order: string[], + map: Map +): T[] | undefined { + if (order.length === 0) return undefined + return order.map((key) => map.get(key)).filter((tool): tool is T => Boolean(tool)) +} + +/** True while any chip is still running. */ +export function anyToolCallRunning(map: Map): boolean { + for (const tool of map.values()) { + if (tool.status === 'running') return true + } + return false +} diff --git a/apps/sim/components/ui/shimmer-text.module.css b/apps/sim/components/ui/shimmer-text.module.css index 53637471b1a..d99f8740a04 100644 --- a/apps/sim/components/ui/shimmer-text.module.css +++ b/apps/sim/components/ui/shimmer-text.module.css @@ -21,8 +21,11 @@ } @keyframes shimmer-sweep { + from { + background-position: 100% 0; + } to { - background-position: 200% 0; + background-position: -100% 0; } } diff --git a/apps/sim/components/ui/shimmer-text.tsx b/apps/sim/components/ui/shimmer-text.tsx index 6e3f7fea8f6..56f2b37662c 100644 --- a/apps/sim/components/ui/shimmer-text.tsx +++ b/apps/sim/components/ui/shimmer-text.tsx @@ -1,10 +1,12 @@ +import type { ComponentPropsWithoutRef, ElementType } from 'react' import { cn } from '@sim/emcn' import styles from '@/components/ui/shimmer-text.module.css' -interface ShimmerTextProps { +type ShimmerTextProps = { + as?: T children: React.ReactNode className?: string -} +} & Omit, 'as' | 'children' | 'className'> /** * Sweeping-highlight shimmer over a text phrase — the same treatment as the @@ -12,6 +14,16 @@ interface ShimmerTextProps { * Size and weight come from the consumer's className; the gradient replaces * the text color, so color classes are ignored while shimmering. */ -export function ShimmerText({ children, className }: ShimmerTextProps) { - return {children} +export function ShimmerText({ + as, + children, + className, + ...props +}: ShimmerTextProps) { + const Comp = as ?? 'span' + return ( + + {children} + + ) } diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 94d291a18c5..fe4bb0e624d 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -26,6 +26,14 @@ vi.mock('@/lib/uploads', () => ({ }, })) +vi.mock('@/lib/logs/execution/pii-redaction', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + redactObjectStrings: vi.fn(actual.redactObjectStrings), + } +}) + function createBlock(): SerializedBlock { return { id: 'function-block-1', @@ -379,4 +387,362 @@ describe('BlockExecutor', () => { ) expect(state.getBlockOutput(block.id)).toEqual(output) }) + + it('does not soft-succeed non-agent blocks on user AbortError', async () => { + const block = createBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const abortController = new AbortController() + const handler: BlockHandler = { + canHandle: () => true, + execute: async () => { + abortController.abort('user') + throw new DOMException('The operation was aborted.', 'AbortError') + }, + } + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(/abort/i) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeTruthy() + expect(output).not.toEqual({ content: '' }) + }) +}) + +describe('BlockExecutor streaming pump', () => { + function createAgentBlock(): SerializedBlock { + return { + id: 'agent-block-1', + metadata: { id: BlockType.AGENT, name: 'Agent' }, + position: { x: 0, y: 0 }, + config: { tool: BlockType.AGENT, params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + } + + function createExecutor(handler: BlockHandler) { + const block = createAgentBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + return { executor, block, state } + } + + function createAgentEventsStreamingHandler(options: { + events: Array> + attachThinkingOnDrain?: string + failAfterText?: string + onFullContent?: (content: string) => void | Promise + }): BlockHandler { + return { + canHandle: () => true, + execute: async () => { + const timeSegment: Record = { + type: 'model', + name: 'claude-test', + startTime: Date.now(), + endTime: Date.now(), + duration: 1, + } + const output = { + content: '', + model: 'claude-test', + tokens: { input: 1, output: 2, total: 3 }, + providerTiming: { + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + duration: 1, + timeSegments: [timeSegment], + }, + cost: { input: 0, output: 0, total: 0 }, + } + + const stream = new ReadableStream({ + start(controller) { + if (options.failAfterText) { + controller.enqueue({ + type: 'text_delta', + text: options.failAfterText, + turn: 'final', + }) + controller.error(new Error('provider reset')) + return + } + for (const event of options.events) { + controller.enqueue(event) + } + if (options.attachThinkingOnDrain) { + timeSegment.thinkingContent = options.attachThinkingOnDrain + } + controller.close() + }, + }) + + return { + stream, + streamFormat: 'agent-events-v1' as const, + execution: { + success: true, + output, + logs: [], + metadata: { + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + duration: 1, + }, + }, + onFullContent: options.onFullContent, + } + }, + } + } + + it('projects answer text to onStream and content; sink gets full timeline', async () => { + const onFullContent = vi.fn() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'thinking_delta', text: 'hmm ' }, + { type: 'thinking_delta', text: 'yes' }, + { type: 'text_delta', text: 'Hello ', turn: 'final' }, + { type: 'text_delta', text: 'world', turn: 'final' }, + ], + attachThinkingOnDrain: 'hmm yes', + onFullContent, + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + const forwarded: string[] = [] + const sinkEvents: Array> = [] + + ctx.onStream = async (streamingExec) => { + expect(streamingExec.streamFormat).toBe('text') + streamingExec.subscribe?.({ + onEvent: async (event) => { + sinkEvents.push(event as Record) + }, + }) + const reader = streamingExec.stream.getReader() + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done) break + forwarded.push(decoder.decode(value, { stream: true })) + } + } + + await executor.execute(ctx, createNode(block), block) + + expect(forwarded.join('')).toBe('Hello world') + expect(state.getBlockOutput(block.id)?.content).toBe('Hello world') + expect(onFullContent).toHaveBeenCalledWith('Hello world') + expect(sinkEvents).toEqual([ + { type: 'thinking_delta', text: 'hmm ' }, + { type: 'thinking_delta', text: 'yes' }, + { type: 'text_delta', text: 'Hello ', turn: 'final' }, + { type: 'text_delta', text: 'world', turn: 'final' }, + ]) + expect(state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent).toBe( + 'hmm yes' + ) + }) + + it('drains without onStream and still persists answer content', async () => { + const handler = createAgentEventsStreamingHandler({ + events: [{ type: 'text_delta', text: 'offline answer', turn: 'final' }], + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + + await executor.execute(ctx, createNode(block), block) + + expect(state.getBlockOutput(block.id)?.content).toBe('offline answer') + }) + + it('throws on mid-stream provider error (no truncated success)', async () => { + const handler = createAgentEventsStreamingHandler({ + failAfterText: 'partial', + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.onStream = async (streamingExec) => { + const reader = streamingExec.stream.getReader() + try { + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // consumer may see the error; block must still fail + } + } + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow('provider reset') + expect(state.getBlockOutput(block.id)?.content).not.toBe('partial') + }) + + it('soft-completes on user abort with drained answer text (no failed block)', async () => { + const abortController = new AbortController() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'text_delta', text: 'partial answer', turn: 'final' }, + { type: 'thinking_delta', text: 'more' }, + ], + }) + + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + ctx.onStream = async (streamingExec) => { + streamingExec.subscribe?.({ onEvent: async () => {} }) + const reader = streamingExec.stream.getReader() + try { + // Drain the first projected answer chunk, then Stop — pump must keep it. + const first = await reader.read() + expect(first.done).toBe(false) + abortController.abort('user') + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // abort may cancel the text stream + } + } + + await executor.execute(ctx, createNode(block), block) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeUndefined() + // Soft-complete must keep text already projected before Stop — not empty content. + expect(output?.content).toBe('partial answer') + expect(output).not.toMatchObject({ error: expect.any(String) }) + }) + + it('fails on timeout but keeps drained answer text in block output', async () => { + const abortController = new AbortController() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'text_delta', text: 'partial before timeout', turn: 'final' }, + { type: 'thinking_delta', text: 'more' }, + ], + }) + + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + ctx.onStream = async (streamingExec) => { + streamingExec.subscribe?.({ onEvent: async () => {} }) + const reader = streamingExec.stream.getReader() + try { + const first = await reader.read() + expect(first.done).toBe(false) + abortController.abort('timeout') + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // timeout may cancel the text stream + } + } + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(/timed out/i) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeTruthy() + expect(output?.content).toBe('partial before timeout') + }) + + it('with PII redaction: no live forward and strips thinking from traces', async () => { + const { redactObjectStrings } = await import('@/lib/logs/execution/pii-redaction') + vi.mocked(redactObjectStrings).mockImplementation(async (value) => { + if (typeof value === 'string') { + return `[masked]${value}` as never + } + // Object walk is exercised elsewhere; keep streaming-stage string mask as-is. + return value as never + }) + + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'thinking_delta', text: 'secret thought' }, + { type: 'text_delta', text: 'alice@example.com said hi', turn: 'final' }, + ], + attachThinkingOnDrain: 'secret thought', + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + const onStream = vi.fn() + ctx.onStream = onStream + ctx.piiBlockOutputRedaction = { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', + } + + await executor.execute(ctx, createNode(block), block) + + expect(onStream).not.toHaveBeenCalled() + expect(state.getBlockOutput(block.id)?.content).toBe('[masked]alice@example.com said hi') + expect( + state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent + ).toBeUndefined() + }) }) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index b5591983d05..f4b12617509 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -1,5 +1,6 @@ import { createLogger, type Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types' import { redactApiKeys } from '@/lib/core/security/redaction' import { normalizeStringArray } from '@/lib/core/utils/arrays' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -59,6 +60,7 @@ import { FUNCTION_BLOCK_DISPLAY_CODE_KEY, type VariableResolver, } from '@/executor/variables/resolver' +import { createAgentStreamPump } from '@/providers/stream-pump' import type { SerializedBlock } from '@/serializer/types' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' @@ -176,6 +178,7 @@ export class BlockExecutor { } cleanupSelfReference?.() + let streamingPartialOutput: Record | undefined try { const output = handler.executeWithNode ? await handler.executeWithNode(ctx, block, resolvedInputs, nodeMetadata) @@ -188,21 +191,24 @@ export class BlockExecutor { if (isStreamingExecution) { const streamingExec = output as StreamingExecution - // The stream must still be drained to populate `execution.output`, but - // forwarding raw chunks to the client (or persisting them to memory) - // before redaction would leak PII. When block-output redaction is on we - // drain in buffer-only mode (no `onStream`, content masked before it's - // stored); the masked final output reaches the client via block-complete. - if (ctx.onStream) { + // Always drain via the agent stream pump (tokens/cost/timing callbacks), + // even with no `onStream`. When block-output redaction is on we do not + // live-forward chunks; content is masked before persist and the masked + // final output reaches the client via block-complete. + try { await this.handleStreamingExecution( ctx, node, block, streamingExec, resolvedInputs, - normalizeStringArray(ctx.selectedOutputs), - !ctx.piiBlockOutputRedaction?.enabled + normalizeStringArray(ctx.selectedOutputs) ) + } catch (streamError) { + // Timeout / drain failures may still have projected answer text — keep it + // for the failed block output so logs match what the client already saw. + streamingPartialOutput = streamingExec.execution?.output + throw streamError } normalizedOutput = this.normalizeOutput( @@ -315,7 +321,8 @@ export class BlockExecutor { blockLog, inputsForLog, isSentinel, - 'execution' + 'execution', + streamingPartialOutput ) } } @@ -372,7 +379,8 @@ export class BlockExecutor { blockLog: BlockLog | undefined, inputsForLog: Record, isSentinel: boolean, - phase: 'input_resolution' | 'execution' + phase: 'input_resolution' | 'execution', + streamingPartialOutput?: Record ): Promise { const endedAt = new Date().toISOString() const duration = performance.now() - startTime @@ -383,10 +391,65 @@ export class BlockExecutor { ? inputsForLog : ((block.config?.params as Record | undefined) ?? {}) + // Routine user Stop on Agent streams: don't paint a failed agent block + // (workflow is already cancelled). Timeouts abort with reason `'timeout'`. + // Non-agent blocks (HTTP, Function, etc.) still fail normally on AbortError + // so logs don't show a green empty success. + const isAbort = + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + const isTimeout = isTimeoutAbortReason(ctx.abortSignal?.reason) + const isAgentBlock = block.metadata?.id === BlockType.AGENT + if (isAbort && !isTimeout && ctx.abortSignal?.aborted && isAgentBlock) { + const softOutput: NormalizedBlockOutput = { + content: '', + } + + this.setNodeOutput(node, softOutput, duration) + + if (blockLog) { + blockLog.endedAt = endedAt + blockLog.durationMs = duration + blockLog.success = true + blockLog.error = undefined + blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) + } + + this.execLogger.info('Block stream aborted by client; soft-completing', { + blockId: node.id, + blockType: block.metadata?.id, + }) + + if (!isSentinel && blockLog) { + this.fireBlockCompleteCallback( + blockStartPromise, + ctx, + node, + block, + this.sanitizeInputsForLog(input, block.metadata?.id), + filterOutputForLog(block.metadata?.id || '', softOutput, { block }), + duration, + blockLog.startedAt, + blockLog.executionOrder, + blockLog.endedAt + ) + } + + return softOutput + } + const errorOutput: NormalizedBlockOutput = { error: errorMessage, } + // Keep any answer text already drained before timeout/failure so logs match + // what was projected to the client. + const partialContent = streamingPartialOutput?.content + if (typeof partialContent === 'string' && partialContent) { + errorOutput.content = partialContent + } + if (ChildWorkflowError.isChildWorkflowError(error)) { errorOutput.childWorkflowName = error.childWorkflowName if (error.childWorkflowSnapshotId) { @@ -774,119 +837,125 @@ export class BlockExecutor { block: SerializedBlock, streamingExec: StreamingExecution, resolvedInputs: Record, - selectedOutputs: string[], - forwardToClient = true + selectedOutputs: string[] ): Promise { const blockId = node.id + const piiEnabled = Boolean(ctx.piiBlockOutputRedaction?.enabled) + // Live-forward only when a client stream exists and PII redaction is off. + const forwardToClient = Boolean(ctx.onStream) && !piiEnabled const responseFormat = resolvedInputs?.responseFormat ?? (block.config?.params as Record | undefined)?.responseFormat ?? (block.config as Record | undefined)?.responseFormat - const sourceReader = streamingExec.stream.getReader() - const decoder = new TextDecoder() - const accumulated: string[] = [] - let drainError: unknown - let sourceFullyDrained = false - - if (forwardToClient) { - const clientSource = new ReadableStream({ - async pull(controller) { - try { - const { done, value } = await sourceReader.read() - if (done) { - const tail = decoder.decode() - if (tail) accumulated.push(tail) - sourceFullyDrained = true - controller.close() - return - } - accumulated.push(decoder.decode(value, { stream: true })) - controller.enqueue(value) - } catch (error) { - drainError = error - controller.error(error) - } - }, - async cancel(reason) { - try { - await sourceReader.cancel(reason) - } catch {} - }, - }) + const streamFormat = streamingExec.streamFormat ?? 'text' + const pump = createAgentStreamPump({ + source: streamingExec.stream, + streamFormat, + // No live consumer → sink-mode so we never buffer into an unread text stream. + sinkMode: !forwardToClient, + abortSignal: ctx.abortSignal, + }) + + let onStreamPromise: Promise | undefined + let processedClientStream: ReadableStream | undefined - const processedClientStream = streamingResponseFormatProcessor.processStream( - clientSource, + if (forwardToClient && ctx.onStream && pump.textStream) { + processedClientStream = streamingResponseFormatProcessor.processStream( + pump.textStream, blockId, selectedOutputs, responseFormat ) - try { - await ctx.onStream?.({ + // Start onStream without awaiting so a sync `subscribe(sink)` can run before + // the first provider pull, then read the projected text stream concurrently + // with `pump.run()`. + onStreamPromise = ctx + .onStream({ + ...streamingExec, stream: processedClientStream, - execution: streamingExec.execution, + streamFormat: 'text', + subscribe: pump.subscribe, + // processStream returns the input stream identity when no + // response-format extraction applies. + clientStreamTransformed: processedClientStream !== pump.textStream, }) - } catch (error) { - this.execLogger.error('Error in onStream callback', { blockId, error }) - await processedClientStream.cancel().catch(() => {}) - } finally { - try { - sourceReader.releaseLock() - } catch {} + .catch(async (error) => { + this.execLogger.error('Error in onStream callback', { blockId, error }) + await processedClientStream?.cancel().catch(() => {}) + }) + } + + let pumpResult + try { + pumpResult = await pump.run() + } catch (error) { + this.execLogger.error('Error reading stream for block', { blockId, error }) + if (onStreamPromise) { + await onStreamPromise.catch(() => {}) } - } else { - // Buffer-only drain: consume the source so `execution.output` is complete, - // but never forward raw chunks to the client (block-output redaction is on). - try { - while (true) { - const { done, value } = await sourceReader.read() - if (done) { - const tail = decoder.decode() - if (tail) accumulated.push(tail) - sourceFullyDrained = true - break - } - accumulated.push(decoder.decode(value, { stream: true })) - } - } catch (error) { - drainError = error - } finally { - try { - sourceReader.releaseLock() - } catch {} + throw error instanceof Error ? error : new Error(String(error)) + } + + if (onStreamPromise) { + await onStreamPromise + } + + // Timeout still fails the block, but keep any drained answer text so logs + // match what was already projected to the client before the deadline. + // User Stop soft-completes below so logs don't show a scary red agent block + // for a routine cancel (workflow status remains `cancelled` via abort). + if (pumpResult.cancelled && pumpResult.cancelReason === 'timeout') { + const truncated = pumpResult.answerText + if (truncated && streamingExec.execution?.output) { + streamingExec.execution.output.content = truncated } + this.execLogger.warn('Stream timed out; persisting drained answer before failing block', { + blockId, + hasContent: Boolean(truncated), + }) + throw new DOMException('Provider request timed out', 'AbortError') } - if (drainError) { - this.execLogger.error('Error reading stream for block', { blockId, error: drainError }) + // Provider onComplete may have attached thinking to timing segments during drain. + // Under PII redaction, never retain raw thinking in traces. + if (piiEnabled) { + stripThinkingContentFromOutput(streamingExec.execution?.output) + } + + // User/unknown cancel: persist truncated answer when present, then return. + if (pumpResult.cancelled) { + const truncated = pumpResult.answerText + if (truncated && streamingExec.execution?.output) { + streamingExec.execution.output.content = truncated + } + this.execLogger.info('Stream cancelled by client; soft-completing agent block', { + blockId, + cancelReason: pumpResult.cancelReason, + hasContent: Boolean(truncated), + }) return } - // If the onStream consumer exited before the source drained (e.g. it caught - // an internal error and returned normally), `accumulated` holds a truncated - // response. Persisting that to memory or setting it as the block output - // would corrupt downstream state — skip and log instead. - if (!sourceFullyDrained) { + // If the pump did not fully drain (should be rare when not cancelled), skip + // persistence of potentially truncated answer text. + if (!pumpResult.fullyDrained) { this.execLogger.warn( 'Stream consumer exited before source drained; skipping content persistence', - { - blockId, - } + { blockId } ) return } - let fullContent = accumulated.join('') + let fullContent = pumpResult.answerText if (!fullContent) { return } - if (!forwardToClient && ctx.piiBlockOutputRedaction?.enabled) { - // Mask before the content is written to `execution.output` or persisted to - // memory via `onFullContent`, so the streamed agent response can't leak PII - // through either path. The block-output redaction below is then idempotent. + if (piiEnabled && ctx.piiBlockOutputRedaction) { + // Mask before writing to `execution.output` or `onFullContent`. fullContent = await redactObjectStrings(fullContent, { entityTypes: ctx.piiBlockOutputRedaction.entityTypes, language: ctx.piiBlockOutputRedaction.language, @@ -931,3 +1000,16 @@ export class BlockExecutor { } } } + +/** Removes retained thinking from provider timing segments (PII safe default). */ +function stripThinkingContentFromOutput(output: unknown): void { + if (!output || typeof output !== 'object') return + const providerTiming = (output as { providerTiming?: { timeSegments?: unknown } }).providerTiming + const segments = providerTiming?.timeSegments + if (!Array.isArray(segments)) return + for (const segment of segments) { + if (segment && typeof segment === 'object' && 'thinkingContent' in segment) { + ;(segment as { thinkingContent?: string }).thinkingContent = undefined + } + } +} diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index cf3198e9c6f..ead4073efaa 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -189,4 +189,27 @@ describe('serializePauseSnapshot', () => { expect(serialized.metadata.billingAttribution).toEqual(billingAttribution) }) + + it('preserves includeThinking on pause so chat resume can emit thinking SSE', () => { + const context = createContext({ + metadata: { + ...createContext().metadata, + includeThinking: true, + executionMode: 'stream', + }, + }) + + const snapshot = serializePauseSnapshot(context, ['next-block']) + const serialized = JSON.parse(snapshot.snapshot) + + expect(serialized.metadata.includeThinking).toBe(true) + expect(serialized.metadata.executionMode).toBe('stream') + }) + + it('omits includeThinking when the live run did not enable it', () => { + const snapshot = serializePauseSnapshot(createContext(), ['next-block']) + const serialized = JSON.parse(snapshot.snapshot) + + expect(serialized.metadata.includeThinking).toBeUndefined() + }) }) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index e26efc9d1df..67eae7bce09 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -252,6 +252,10 @@ export function serializePauseSnapshot( startTime: metadataFromContext?.startTime ?? new Date().toISOString(), isClientSession: metadataFromContext?.isClientSession, executionMode: metadataFromContext?.executionMode, + // Preserve deployed-chat thinking gate across HITL pause/resume. + includeThinking: metadataFromContext?.includeThinking === true ? true : undefined, + // Preserve the run-level agent-events opt-in across HITL pause/resume. + agentEvents: metadataFromContext?.agentEvents === true ? true : undefined, } const snapshot = new ExecutionSnapshot( diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index d114897721e..db7cda77917 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -47,6 +47,18 @@ export interface ExecutionMetadata { callChain?: string[] correlation?: AsyncExecutionCorrelation executionMode?: 'sync' | 'stream' | 'async' + /** + * Deployed-chat thinking policy half of the SSE dual gate. Persisted so HITL + * resume can re-enable thinking frames without hardcoding false. + */ + includeThinking?: boolean + /** + * Run-level agent-events opt-in. True only on surfaces that consume thinking + * and tool lifecycle events (canvas Run, dual-gated public chat). Enables the + * live streaming tool loops and provider thinking-summary requests; when + * unset, providers behave exactly as they did before agent events existed. + */ + agentEvents?: boolean } export interface SerializableExecutionState { diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 8ef8c310b16..453bf344019 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1875,6 +1875,52 @@ describe('AgentBlockHandler', () => { expect(providerCallArgs.billingAttribution).toEqual(billingAttribution) }) + it('forwards agentEvents to executeProviderRequest on opted-in streaming runs', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Stream this', + apiKey: 'test-api-key', + } + + const streamingContext = { + ...mockContext, + stream: true, + selectedOutputs: ['test-agent-block'], + metadata: { ...mockContext.metadata, agentEvents: true }, + } as ExecutionContext + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(streamingContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1] + expect(providerCallArgs.stream).toBe(true) + expect(providerCallArgs.agentEvents).toBe(true) + }) + + it('does not set agentEvents on runs without the run-level opt-in', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Stream this', + apiKey: 'test-api-key', + } + + const streamingContext = { + ...mockContext, + stream: true, + selectedOutputs: ['test-agent-block'], + } as ExecutionContext + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(streamingContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest).toHaveBeenCalled() + const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1] + expect(providerCallArgs.agentEvents).toBe(false) + }) + it('should handle multiple MCP tools from the same server efficiently', async () => { const fetchCalls: any[] = [] @@ -2290,4 +2336,38 @@ describe('AgentBlockHandler', () => { }) }) }) + + describe('wrapStreamForMemoryPersistence envelope', () => { + it('preserves streamFormat and subscribe via object spread', () => { + const handler = new AgentBlockHandler() + const subscribe = vi.fn() + const streamingExec: StreamingExecution = { + stream: new ReadableStream(), + streamFormat: 'agent-events-v1', + subscribe, + execution: { + success: true, + output: { content: '' }, + logs: [], + metadata: { startTime: '', endTime: '', duration: 0 }, + }, + } + + const wrapped = ( + handler as unknown as { + wrapStreamForMemoryPersistence: ( + ctx: ExecutionContext, + inputs: Record, + exec: StreamingExecution + ) => StreamingExecution + } + ).wrapStreamForMemoryPersistence({} as ExecutionContext, {}, streamingExec) + + expect(wrapped.streamFormat).toBe('agent-events-v1') + expect(wrapped.subscribe).toBe(subscribe) + expect(wrapped.stream).toBe(streamingExec.stream) + expect(wrapped.execution).toBe(streamingExec.execution) + expect(typeof wrapped.onFullContent).toBe('function') + }) + }) }) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index a09d70e3f31..48d7f24c851 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -46,6 +46,7 @@ import { shouldUseLargeFilePath, supportsFileAttachments, } from '@/providers/attachments' +import { supportsStreamingToolCalls } from '@/providers/streaming-tool-loop-shared' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' import { filterSchemaForLLM, type ToolSchema } from '@/tools/params' @@ -955,6 +956,18 @@ export class AgentBlockHandler implements BlockHandler { verbosity: inputs.verbosity, thinkingLevel: inputs.thinkingLevel, previousInteractionId: inputs.previousInteractionId, + /** + * Agent-events opt-in and live tool lifecycle. Both are gated on the + * run-level {@link ExecutionMetadata.agentEvents} flag so runs without an + * agent-events consumer keep the exact pre-agent-events provider + * behavior (legacy loops, unchanged request payloads). + */ + agentEvents: streaming && ctx.metadata?.agentEvents === true, + streamToolCalls: + streaming && + ctx.metadata?.agentEvents === true && + formattedTools.length > 0 && + supportsStreamingToolCalls(providerId), } } @@ -1029,6 +1042,8 @@ export class AgentBlockHandler implements BlockHandler { verbosity: providerRequest.verbosity, thinkingLevel: providerRequest.thinkingLevel, previousInteractionId: providerRequest.previousInteractionId, + agentEvents: providerRequest.agentEvents, + streamToolCalls: providerRequest.streamToolCalls, abortSignal: ctx.abortSignal, }) @@ -1088,8 +1103,7 @@ export class AgentBlockHandler implements BlockHandler { streamingExec: StreamingExecution ): StreamingExecution { return { - stream: streamingExec.stream, - execution: streamingExec.execution, + ...streamingExec, onFullContent: async (content: string) => { if (!content.trim()) return try { diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index bddbd2fc763..09f41dad8f9 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -10,6 +10,7 @@ import type { SerializableExecutionState, } from '@/executor/execution/types' import type { RunFromBlockContext } from '@/executor/utils/run-from-block' +import type { AgentStreamSink, UnsubscribeAgentStreamSink } from '@/providers/stream-events' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { SubflowType } from '@/stores/workflows/workflow/types' @@ -314,6 +315,11 @@ interface ExecutionMetadata { resumeFromSnapshot?: boolean resumeTerminalNoop?: boolean executionMode?: 'sync' | 'stream' | 'async' + /** + * Run-level agent-events opt-in (see the snapshot ExecutionMetadata). + * Gates streaming tool loops and provider thinking-summary requests. + */ + agentEvents?: boolean } export interface BlockState { @@ -522,7 +528,32 @@ export interface ExecutionResult { } export interface StreamingExecution { + /** + * Provider stream payload. Format is declared by {@link streamFormat}: + * - `'text'` (default): UTF-8 answer bytes (`ReadableStream`) + * - `'agent-events-v1'`: in-process `ReadableStream` of `AgentStreamEvent` objects + * + * Never sniff the payload; always read {@link streamFormat}. + * After the executor pump, {@link stream} is always projected UTF-8 answer text. + */ stream: ReadableStream + /** + * Discriminator for {@link stream}. Defaults to `'text'` when omitted so + * existing providers remain byte-stream consumers without changes. + */ + streamFormat?: 'text' | 'agent-events-v1' + /** + * Optional sink subscription installed synchronously during `onStream` before + * the executor pump starts draining. Late subscribers receive future events only. + */ + subscribe?: (sink: AgentStreamSink) => UnsubscribeAgentStreamSink + /** + * True when {@link stream} is a response-format projection (selected JSON + * fields extracted from structured output) rather than raw answer text. Sink + * `text_delta` events then do NOT match the byte stream, so consumers must + * keep sourcing answer text from {@link stream} instead of the sink. + */ + clientStreamTransformed?: boolean execution: ExecutionResult & { isStreaming?: boolean } /** * Invoked with the assembled response text after the stream drains. Lets agent diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 1c1da29faa1..046bfcc9dbb 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -175,6 +175,8 @@ export interface ChatFormData { emails: string[] welcomeMessage: string selectedOutputBlocks: string[] + /** When true, thinking may be streamed to clients that opt into agent-events-v1. Default false. */ + includeThinking: boolean } /** @@ -263,6 +265,7 @@ function buildChatPayload( allowedEmails: formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], outputConfigs, + includeThinking: formData.includeThinking, } } diff --git a/apps/sim/hooks/use-execution-stream.test.ts b/apps/sim/hooks/use-execution-stream.test.ts index f38f028c805..4ad6425ce13 100644 --- a/apps/sim/hooks/use-execution-stream.test.ts +++ b/apps/sim/hooks/use-execution-stream.test.ts @@ -53,6 +53,61 @@ describe('processSSEStream', () => { expect(order).toEqual(['handler:start', 'handler:end', 'event-id']) }) + it('routes stream:thinking and stream:tool without requiring event ids', async () => { + const onStreamThinking = vi.fn() + const onStreamTool = vi.fn() + const onStreamChunk = vi.fn() + const onEventId = vi.fn() + + const events: ExecutionEvent[] = [ + { + type: 'stream:thinking', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { blockId: 'agent-1', text: 'reasoning ' }, + }, + { + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { + blockId: 'agent-1', + phase: 'start', + id: 'tool_1', + name: 'http_request', + }, + }, + { + type: 'stream:chunk', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { blockId: 'agent-1', chunk: 'answer' }, + }, + ] + + await processSSEStream( + streamEvents(events).getReader(), + { onStreamThinking, onStreamTool, onStreamChunk, onEventId }, + 'test' + ) + + expect(onStreamThinking).toHaveBeenCalledWith({ + blockId: 'agent-1', + text: 'reasoning ', + }) + expect(onStreamTool).toHaveBeenCalledWith({ + blockId: 'agent-1', + phase: 'start', + id: 'tool_1', + name: 'http_request', + }) + expect(onStreamChunk).toHaveBeenCalledWith({ blockId: 'agent-1', chunk: 'answer' }) + expect(onEventId).not.toHaveBeenCalled() + }) + it('propagates callback failures without acknowledging the event id', async () => { const event: ExecutionEvent = { type: 'block:started', diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index ffe862c4710..a27c1ab4ce4 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -14,7 +14,10 @@ import type { ExecutionPausedData, ExecutionStartedData, StreamChunkData, + StreamChunkResetData, StreamDoneData, + StreamThinkingData, + StreamToolData, } from '@/lib/workflows/executor/execution-events' import type { SerializableExecutionState } from '@/executor/execution/types' @@ -121,9 +124,18 @@ export async function processSSEStream( case 'stream:chunk': await callbacks.onStreamChunk?.(event.data) break + case 'stream:chunk_reset': + await callbacks.onStreamChunkReset?.(event.data) + break case 'stream:done': await callbacks.onStreamDone?.(event.data) break + case 'stream:thinking': + await callbacks.onStreamThinking?.(event.data) + break + case 'stream:tool': + await callbacks.onStreamTool?.(event.data) + break default: logger.warn('Unknown event type:', (event as any).type) } @@ -164,7 +176,10 @@ export interface ExecutionStreamCallbacks { onBlockError?: (data: BlockErrorData) => void | Promise onBlockChildWorkflowStarted?: (data: BlockChildWorkflowStartedData) => void | Promise onStreamChunk?: (data: StreamChunkData) => void | Promise + onStreamChunkReset?: (data: StreamChunkResetData) => void | Promise onStreamDone?: (data: StreamDoneData) => void | Promise + onStreamThinking?: (data: StreamThinkingData) => void | Promise + onStreamTool?: (data: StreamToolData) => void | Promise onEventId?: (eventId: number) => void | Promise } diff --git a/apps/sim/lib/api/contracts/chats.include-thinking.test.ts b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts new file mode 100644 index 00000000000..903e99e0baa --- /dev/null +++ b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createChatBodySchema, + deployedChatConfigSchema, + updateChatBodySchema, +} from '@/lib/api/contracts/chats' +import { chatDetailSchema } from '@/lib/api/contracts/deployments' + +describe('chat includeThinking contracts (Step 4)', () => { + it('create defaults includeThinking to false', () => { + const parsed = createChatBodySchema.parse({ + workflowId: 'wf-1', + identifier: 'my-chat', + title: 'Support', + customizations: { + primaryColor: 'var(--brand-hover)', + welcomeMessage: 'Hi', + }, + }) + expect(parsed.includeThinking).toBe(false) + }) + + it('create accepts includeThinking true', () => { + const parsed = createChatBodySchema.parse({ + workflowId: 'wf-1', + identifier: 'my-chat', + title: 'Support', + customizations: { + primaryColor: 'var(--brand-hover)', + welcomeMessage: 'Hi', + }, + includeThinking: true, + }) + expect(parsed.includeThinking).toBe(true) + }) + + it('update accepts includeThinking toggle', () => { + expect(updateChatBodySchema.parse({ includeThinking: true }).includeThinking).toBe(true) + expect(updateChatBodySchema.parse({ includeThinking: false }).includeThinking).toBe(false) + expect(updateChatBodySchema.parse({ title: 'x' }).includeThinking).toBeUndefined() + }) + + it('chat detail and deployed config expose includeThinking (default false)', () => { + const detail = chatDetailSchema.parse({ + id: 'chat-1', + identifier: 'my-chat', + title: 'Support', + description: '', + authType: 'public', + allowedEmails: [], + outputConfigs: [], + isActive: true, + chatUrl: 'http://localhost/chat/my-chat', + hasPassword: false, + }) + expect(detail.includeThinking).toBe(false) + + const detailOn = chatDetailSchema.parse({ + ...detail, + includeThinking: true, + }) + expect(detailOn.includeThinking).toBe(true) + + const config = deployedChatConfigSchema.parse({ + id: 'chat-1', + title: 'Support', + description: '', + customizations: {}, + authType: 'public', + }) + expect(config.includeThinking).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index d0840bca8a5..9aa37e99723 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -41,6 +41,8 @@ export const createChatBodySchema = z.object({ password: z.string().optional(), allowedEmails: z.array(z.string()).optional().default([]), outputConfigs: z.array(chatOutputConfigSchema).optional().default([]), + /** When true, clients may receive thinking SSE if they also send the protocol header. Default off. */ + includeThinking: z.boolean().optional().default(false), }) export type CreateChatBody = z.input @@ -58,6 +60,7 @@ export const updateChatBodySchema = z.object({ password: z.string().optional(), allowedEmails: z.array(z.string()).optional(), outputConfigs: z.array(chatOutputConfigSchema).optional(), + includeThinking: z.boolean().optional(), }) export type UpdateChatBody = z.input @@ -101,6 +104,8 @@ export const deployedChatConfigSchema = z.object({ (value) => value ?? undefined, z.array(deployedChatOutputConfigSchema).optional() ), + /** Policy for thinking SSE; clients still need the X-Sim-Stream-Protocol opt-in. */ + includeThinking: z.preprocess((value) => value ?? false, z.boolean()), }) export type DeployedChatConfig = z.output @@ -209,8 +214,10 @@ export const deployedChatPostContract = defineRouteContract({ params: chatIdentifierParamsSchema, body: deployedChatPostBodySchema, response: { - mode: 'json', - schema: deployedChatConfigSchema, + // Message posts return SSE (`text/event-stream`). Auth-only POSTs use + // authenticateDeployedChatContract (JSON). Terminal frames: `final` or one + // `error`, then `[DONE]`. Thinking frames require includeThinking + protocol header. + mode: 'stream', }, }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index a7daf83b739..a4689c1c133 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -176,6 +176,7 @@ export const chatDetailSchema = z.object({ }) ) ), + includeThinking: z.preprocess((value) => value ?? false, z.boolean()), customizations: z.preprocess( (value) => value ?? undefined, z diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 20282e78f4d..0622e78d977 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -308,6 +308,7 @@ export async function executeDeployChat( allowedEmails: (existing[0].allowedEmails as string[]) || [], outputConfigs: (existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [], + includeThinking: existing[0].includeThinking ?? false, welcomeMessage: (existing[0].customizations as { welcomeMessage?: string } | null) ?.welcomeMessage || 'Hi there! How can I help you today?', @@ -395,6 +396,10 @@ export async function executeDeployChat( blockId: string path: string }> + const resolvedIncludeThinking = + typeof params.includeThinking === 'boolean' + ? params.includeThinking + : (existingDeployment?.includeThinking ?? false) const welcomeMessage = typeof params.welcomeMessage === 'string' ? params.welcomeMessage @@ -438,6 +443,7 @@ export async function executeDeployChat( password: params.password, allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, + includeThinking: resolvedIncludeThinking, workspaceId: workflowRecord.workspaceId, }) @@ -490,6 +496,7 @@ export async function executeDeployChat( authType: resolvedAuthType, allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, + includeThinking: resolvedIncludeThinking, welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', primaryColor: params.customizations?.primaryColor || diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index e1b35503c59..1218b25440b 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -61,6 +61,7 @@ export async function executeCheckDeploymentStatus( authType: chat.authType, allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, password: chat.password, customizations: chat.customizations, }) @@ -103,6 +104,7 @@ export async function executeCheckDeploymentStatus( authType: chatDeploy[0]?.authType || null, allowedEmails: chatDeploy[0]?.allowedEmails || null, outputConfigs: chatDeploy[0]?.outputConfigs || null, + includeThinking: chatDeploy[0]?.includeThinking ?? false, welcomeMessage: chatCustomizations.welcomeMessage || null, primaryColor: chatCustomizations.primaryColor || null, hasPassword: Boolean(chatDeploy[0]?.password), diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index 16fdbd8bf02..a1c585bde4a 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -158,6 +158,7 @@ export interface DeployChatParams { subdomain?: string allowedEmails?: string[] outputConfigs?: unknown[] + includeThinking?: boolean } export interface DeployMcpParams { diff --git a/apps/sim/lib/core/execution-limits/types.test.ts b/apps/sim/lib/core/execution-limits/types.test.ts index a6a78777419..4556d8990a2 100644 --- a/apps/sim/lib/core/execution-limits/types.test.ts +++ b/apps/sim/lib/core/execution-limits/types.test.ts @@ -35,6 +35,7 @@ declare module '@/lib/core/execution-limits/types?execution-limits-test' { import { createTimeoutAbortController, getExecutionTimeout, + isTimeoutAbortReason, } from '@/lib/core/execution-limits/types?execution-limits-test' afterAll(resetEnvFlagsMock) @@ -80,4 +81,35 @@ describe('getExecutionTimeout', () => { vi.useRealTimers() } }) + + it('aborts with an AbortError carrying the timeout reason when the timer fires', () => { + vi.useFakeTimers() + try { + const controller = createTimeoutAbortController(1000) + vi.advanceTimersByTime(1000) + expect(controller.signal.aborted).toBe(true) + expect(controller.isTimedOut()).toBe(true) + const reason = controller.signal.reason as DOMException + expect(reason).toBeInstanceOf(DOMException) + expect(reason.name).toBe('AbortError') + expect(reason.message).toBe('timeout') + expect(isTimeoutAbortReason(reason)).toBe(true) + controller.cleanup() + } finally { + vi.useRealTimers() + } + }) + + it('manual abort uses an AbortError carrying the user reason', () => { + const controller = createTimeoutAbortController(60_000) + controller.abort() + expect(controller.signal.aborted).toBe(true) + expect(controller.isTimedOut()).toBe(false) + const reason = controller.signal.reason as DOMException + expect(reason).toBeInstanceOf(DOMException) + expect(reason.name).toBe('AbortError') + expect(reason.message).toBe('user') + expect(isTimeoutAbortReason(reason)).toBe(false) + controller.cleanup() + }) }) diff --git a/apps/sim/lib/core/execution-limits/types.ts b/apps/sim/lib/core/execution-limits/types.ts index d11ddc22892..c747ff7ff83 100644 --- a/apps/sim/lib/core/execution-limits/types.ts +++ b/apps/sim/lib/core/execution-limits/types.ts @@ -144,6 +144,19 @@ export interface TimeoutAbortController { timeoutMs: number | undefined } +/** + * True when an abort signal's reason marks an execution timeout. Abort reasons + * are `DOMException('timeout' | 'user', 'AbortError')` so code that passes the + * signal straight into `fetch` still sees a standard AbortError, while pumps + * and executors can discriminate timeout from user Stop via the message. + */ +export function isTimeoutAbortReason(reason: unknown): boolean { + if (reason === 'timeout') return true + return ( + reason instanceof DOMException && reason.name === 'AbortError' && reason.message === 'timeout' + ) +} + export function createTimeoutAbortController(timeoutMs?: number): TimeoutAbortController { const abortController = new AbortController() let isTimedOut = false @@ -152,7 +165,8 @@ export function createTimeoutAbortController(timeoutMs?: number): TimeoutAbortCo if (timeoutMs) { timeoutId = setTimeout(() => { isTimedOut = true - abortController.abort() + // AbortError with a typed message — see isTimeoutAbortReason. + abortController.abort(new DOMException('timeout', 'AbortError')) }, timeoutMs) } @@ -162,7 +176,8 @@ export function createTimeoutAbortController(timeoutMs?: number): TimeoutAbortCo cleanup: () => { if (timeoutId) clearTimeout(timeoutId) }, - abort: () => abortController.abort(), + // Manual abort is user/client cancellation (disconnect, Stop, registerManualExecutionAborter). + abort: () => abortController.abort(new DOMException('user', 'AbortError')), timeoutMs, } } diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 43408586eed..5c77fef1472 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -54,6 +54,13 @@ export interface ExecuteWorkflowOptions { executionMode?: 'sync' | 'stream' | 'async' /** Immutable actor/payer decision captured by preprocessing. */ billingAttribution?: BillingAttributionSnapshot + /** Deployed-chat thinking policy; persisted on the snapshot for resume. */ + includeThinking?: boolean + /** + * Run-level agent-events opt-in (see {@link ExecutionMetadata.agentEvents}). + * Callers set this only when the surface consumes thinking/tool events. + */ + agentEvents?: boolean } export interface WorkflowInfo { @@ -111,6 +118,8 @@ export async function executeWorkflow( largeValueKeys: streamConfig?.largeValueKeys, fileKeys: streamConfig?.fileKeys, executionMode: streamConfig?.executionMode, + includeThinking: streamConfig?.includeThinking === true ? true : undefined, + agentEvents: streamConfig?.agentEvents === true ? true : undefined, } const snapshot = new ExecutionSnapshot( diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index d7212e8c1ed..5b94f977613 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -17,7 +17,26 @@ export type ExecutionEventType = | 'block:error' | 'block:childWorkflowStarted' | 'stream:chunk' + /** Live-only: clears a block's streamed answer text (intermediate turn). */ + | 'stream:chunk_reset' | 'stream:done' + /** Live-only agent thinking delta (not buffered for reconnect replay). */ + | 'stream:thinking' + /** Live-only tool lifecycle (not buffered for reconnect replay). */ + | 'stream:tool' + +/** + * Event types that are live-only: forwarded to connected clients but excluded + * from reconnect replay buffers (same rule as answer chunks — guaranteed `seq` + * replay for stream events is out of scope). + */ +export const LIVE_ONLY_EXECUTION_EVENT_TYPES: ReadonlySet = new Set([ + 'stream:chunk', + 'stream:chunk_reset', + 'stream:done', + 'stream:thinking', + 'stream:tool', +]) /** * Base event structure for SSE @@ -208,6 +227,20 @@ interface StreamChunkEvent extends BaseExecutionEvent { } } +/** + * Live-only reconciliation for agent-events runs: the answer text streamed so + * far for `blockId` belonged to an intermediate turn (tool calls follow). + * Clients discard the block's accumulated streamed text; the final turn's + * text re-streams as regular `stream:chunk` events after tools settle. + */ +interface StreamChunkResetEvent extends BaseExecutionEvent { + type: 'stream:chunk_reset' + workflowId: string + data: { + blockId: string + } +} + /** * Stream done event */ @@ -219,6 +252,36 @@ interface StreamDoneEvent extends BaseExecutionEvent { } } +/** + * Live thinking delta from an agent-events provider sink (canvas / draft runs). + * Builder runs show provider-exposed signals when the sink is attached + * (executor already disables the sink under PII redaction). + */ +interface StreamThinkingEvent extends BaseExecutionEvent { + type: 'stream:thinking' + workflowId: string + data: { + blockId: string + text: string + } +} + +/** + * Live tool lifecycle from an agent-events provider sink. + * Name + status only — never args or results. + */ +interface StreamToolEvent extends BaseExecutionEvent { + type: 'stream:tool' + workflowId: string + data: { + blockId: string + phase: 'start' | 'end' + id: string + name: string + status?: 'success' | 'error' | 'cancelled' + } +} + /** * Union type of all execution events */ @@ -233,7 +296,10 @@ export type ExecutionEvent = | BlockErrorEvent | BlockChildWorkflowStartedEvent | StreamChunkEvent + | StreamChunkResetEvent | StreamDoneEvent + | StreamThinkingEvent + | StreamToolEvent export type ExecutionStartedData = ExecutionStartedEvent['data'] export type ExecutionCompletedData = ExecutionCompletedEvent['data'] @@ -245,7 +311,10 @@ export type BlockCompletedData = BlockCompletedEvent['data'] export type BlockErrorData = BlockErrorEvent['data'] export type BlockChildWorkflowStartedData = BlockChildWorkflowStartedEvent['data'] export type StreamChunkData = StreamChunkEvent['data'] +export type StreamChunkResetData = StreamChunkResetEvent['data'] export type StreamDoneData = StreamDoneEvent['data'] +export type StreamThinkingData = StreamThinkingEvent['data'] +export type StreamToolData = StreamToolEvent['data'] /** * Helper to create SSE formatted message diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 3be39c26d7f..69adefd2174 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -25,7 +25,10 @@ import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' -import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' +import { + type ExecutionEvent, + LIVE_ONLY_EXECUTION_EVENT_TYPES, +} from '@/lib/workflows/executor/execution-events' import { createPausedExecutionResumeMetadata, parsePausedExecutionResumeMetadata, @@ -35,6 +38,10 @@ import { normalizeAutomaticResumeWaitingReason, resolveAutomaticResumeAdmissionFailure, } from '@/lib/workflows/executor/resume-policy' +import { + forwardAgentStreamToExecutionEvents, + shouldForwardAnswerTextFromSink, +} from '@/lib/workflows/streaming/forward-agent-stream-events' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ChildWorkflowContext, @@ -1276,7 +1283,7 @@ export class PauseResumeManager { event: ExecutionEvent, terminalStatus?: TerminalExecutionStreamStatus ) => { - const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done' + const isBuffered = !LIVE_ONLY_EXECUTION_EVENT_TYPES.has(event.type) if (isBuffered) { const entry = terminalStatus ? await eventWriter.writeTerminal(event, terminalStatus).catch((error) => { @@ -1418,12 +1425,26 @@ export class PauseResumeManager { ? streamingExec.execution.blockId : undefined const blockId = typeof blockIdValue === 'string' ? blockIdValue : '' + + // Live answer text rides the sink when available; the byte stream is + // then drained without re-emitting chunks (same final-turn content). + const answerTextFromSink = shouldForwardAnswerTextFromSink(streamingExec) + + const unsubscribe = forwardAgentStreamToExecutionEvents(streamingExec, { + blockId, + executionId: resumeExecutionId, + workflowId, + sendEvent: writeBufferedEvent, + forwardAnswerText: answerTextFromSink, + }) + const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() try { while (true) { const { done, value } = await reader.read() if (done) break + if (answerTextFromSink) continue const chunk = decoder.decode(value, { stream: true }) await writeBufferedEvent({ type: 'stream:chunk', @@ -1447,6 +1468,7 @@ export class PauseResumeManager { error: toError(streamError).message, }) } finally { + unsubscribe() try { await reader.cancel().catch(() => {}) } catch {} diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 7b925510065..13b5a51a371 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -29,6 +29,8 @@ export interface ChatDeployPayload { password?: string | null allowedEmails?: string[] outputConfigs?: Array<{ blockId: string; path: string }> + /** When true, public SSE may expose thinking if the client also opts into agent-events-v1. */ + includeThinking?: boolean workspaceId?: string | null } @@ -60,6 +62,7 @@ export async function performChatDeploy( password, allowedEmails = [], outputConfigs = [], + includeThinking = false, } = params const customizations = { @@ -141,6 +144,7 @@ export async function performChatDeploy( password: passwordToStore, allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, + includeThinking, updatedAt: new Date(), }) .where(eq(chat.id, chatId)) @@ -159,6 +163,7 @@ export async function performChatDeploy( password: encryptedPassword, allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, + includeThinking, createdAt: new Date(), updatedAt: new Date(), }) diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts new file mode 100644 index 00000000000..a001ad1742a --- /dev/null +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + AGENT_STREAM_PROTOCOL_HEADER, + AGENT_STREAM_PROTOCOL_V1, + isChatChunkFrame, + isChatChunkResetFrame, + shouldEmitAgentStreamEvents, +} from '@/lib/workflows/streaming/agent-stream-protocol' + +function headers(init?: Record): Headers { + return new Headers(init) +} + +describe('chunk_reset frame guard', () => { + it('identifies reset frames and keeps them out of the chunk guard', () => { + const reset = { blockId: 'agent-1', event: 'chunk_reset' } + expect(isChatChunkResetFrame(reset)).toBe(true) + // A reset must never be appended as answer text. + expect(isChatChunkFrame(reset)).toBe(false) + + expect(isChatChunkResetFrame({ event: 'chunk_reset' })).toBe(false) + expect(isChatChunkResetFrame({ blockId: 'agent-1', chunk: 'text' })).toBe(false) + }) +}) + +describe('shouldEmitAgentStreamEvents', () => { + it('defaults to false when policy is off and header is missing', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + requestHeaders: headers(), + }) + ).toBe(false) + expect( + shouldEmitAgentStreamEvents({ + includeThinking: undefined, + requestHeaders: headers(), + }) + ).toBe(false) + }) + + it('requires both includeThinking and protocol header', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders: headers(), + }) + ).toBe(false) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(false) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(true) + }) + + it('accepts case-insensitive header values and comma lists', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: ' Agent-Events-V1 ' }), + }) + ).toBe(true) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders: headers({ + [AGENT_STREAM_PROTOCOL_HEADER]: 'text, agent-events-v1', + }), + }) + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts new file mode 100644 index 00000000000..f55ebc18da3 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts @@ -0,0 +1,188 @@ +/** + * Public agent stream protocol: header negotiation and the wire frame + * vocabulary for the public chat / simple SSE surface. + * + * Exposure rule (locked) for public chat / simple SSE: + * emit thinking/tool SSE frames iff + * deployment.includeThinking === true + * AND request opts into agent-events-v1 via {@link AGENT_STREAM_PROTOCOL_HEADER} + * + * Canvas draft runs (execution-events) forward the same sink as live-only + * `stream:thinking` / `stream:tool` events without the includeThinking gate; + * the executor still disables the sink when block-output PII redaction is on. + * + * Legacy clients omitting the header stay text-only even when the deployment + * has thinking enabled. Deployed chat UI always sends the header when loading + * its own deployment. + * + * See docs: workflows/deployment/agent-events. + */ + +import type { ToolCallEndStatus } from '@/providers/stream-events' + +export const AGENT_STREAM_PROTOCOL_HEADER = 'x-sim-stream-protocol' as const + +export const AGENT_STREAM_PROTOCOL_V1 = 'agent-events-v1' as const + +export type AgentStreamProtocol = typeof AGENT_STREAM_PROTOCOL_V1 + +/** + * Answer text. The only frame legacy clients append to the answer. + * + * Legacy clients (no protocol header) receive only settled final-turn text. + * Dual-gated clients receive answer text live as it streams — including text + * from a turn that may later resolve to tool calls — reconciled by + * {@link ChatStreamChunkResetFrame} when a turn turns out to be intermediate. + */ +export interface ChatStreamChunkFrame { + blockId: string + chunk: string +} + +/** + * Dual-gated only: the live-streamed answer text for `blockId` belonged to an + * intermediate turn (tool calls follow). Clients discard the block's + * accumulated answer text; the final turn re-streams after tools settle. + */ +export interface ChatStreamChunkResetFrame { + blockId: string + event: 'chunk_reset' +} + +/** Thinking / reasoning-summary delta. Dual-gated; never reuses `chunk`. */ +export interface ChatStreamThinkingFrame { + blockId: string + event: 'thinking' + data: string +} + +/** Tool lifecycle (name + status only — never args or results). Dual-gated. */ +export interface ChatStreamToolFrame { + blockId: string + event: 'tool' + phase: 'start' | 'end' + id: string + name: string + status?: ToolCallEndStatus +} + +/** Terminal success envelope, followed by `[DONE]`. */ +export interface ChatStreamFinalFrame { + event: 'final' + data: Record +} + +/** Terminal failure, followed by `[DONE]`. Never followed by `final`. */ +export interface ChatStreamErrorFrame { + blockId?: string + event: 'error' + error: string +} + +/** Non-terminal mid-block read issue; the stream keeps going. */ +export interface ChatStreamStreamErrorFrame { + blockId?: string + event: 'stream_error' + error: string +} + +/** + * Every JSON frame the public chat / simple SSE stream can carry (the stream + * additionally ends with a literal `[DONE]` marker). The server emitters and + * the chat client both consume this union so the two cannot drift. + */ +export type ChatStreamFrame = + | ChatStreamChunkFrame + | ChatStreamChunkResetFrame + | ChatStreamThinkingFrame + | ChatStreamToolFrame + | ChatStreamFinalFrame + | ChatStreamErrorFrame + | ChatStreamStreamErrorFrame + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +/** + * Answer text frame: `{ blockId, chunk }` with no `event` discriminator. + * Positively defined so thinking/tool/terminal frames can never be appended + * into the answer by a client that checks this first. + */ +export function isChatChunkFrame(value: unknown): value is ChatStreamChunkFrame { + if (!isRecord(value)) return false + return ( + typeof value.blockId === 'string' && + typeof value.chunk === 'string' && + value.chunk.length > 0 && + value.event === undefined + ) +} + +export function isChatChunkResetFrame(value: unknown): value is ChatStreamChunkResetFrame { + if (!isRecord(value)) return false + return value.event === 'chunk_reset' && typeof value.blockId === 'string' +} + +export function isChatThinkingFrame(value: unknown): value is ChatStreamThinkingFrame { + if (!isRecord(value)) return false + return ( + value.event === 'thinking' && + typeof value.blockId === 'string' && + typeof value.data === 'string' + ) +} + +export function isChatToolFrame(value: unknown): value is ChatStreamToolFrame { + if (!isRecord(value)) return false + return ( + value.event === 'tool' && + typeof value.blockId === 'string' && + (value.phase === 'start' || value.phase === 'end') && + typeof value.id === 'string' && + value.id.length > 0 && + typeof value.name === 'string' && + value.name.length > 0 + ) +} + +export function isChatFinalFrame(value: unknown): value is ChatStreamFinalFrame { + if (!isRecord(value)) return false + return value.event === 'final' && isRecord(value.data) +} + +export function isChatErrorFrame(value: unknown): value is ChatStreamErrorFrame { + if (!isRecord(value)) return false + return value.event === 'error' +} + +export function isChatStreamErrorFrame(value: unknown): value is ChatStreamStreamErrorFrame { + if (!isRecord(value)) return false + return value.event === 'stream_error' +} + +/** + * Returns true when both the deployment policy and the request protocol opt-in + * are present. Simple SSE checks this before emitting thinking/tool frames. + */ +export function shouldEmitAgentStreamEvents(options: { + includeThinking: boolean | null | undefined + requestHeaders: Headers | { get(name: string): string | null } +}): boolean { + if (options.includeThinking !== true) { + return false + } + + const raw = options.requestHeaders.get(AGENT_STREAM_PROTOCOL_HEADER) + if (!raw) { + return false + } + + // Allow comma-separated values / surrounding whitespace from proxies. + const tokens = raw + .split(',') + .map((token) => token.trim().toLowerCase()) + .filter(Boolean) + + return tokens.includes(AGENT_STREAM_PROTOCOL_V1) +} diff --git a/apps/sim/lib/workflows/streaming/forward-agent-stream-events.test.ts b/apps/sim/lib/workflows/streaming/forward-agent-stream-events.test.ts new file mode 100644 index 00000000000..ca8637730ee --- /dev/null +++ b/apps/sim/lib/workflows/streaming/forward-agent-stream-events.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + forwardAgentStreamToExecutionEvents, + shouldForwardAnswerTextFromSink, +} from '@/lib/workflows/streaming/forward-agent-stream-events' +import type { StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent } from '@/providers/stream-events' + +function makeStreamingExec( + onSubscribe: (handler: (event: AgentStreamEvent) => void | Promise) => void, + unsubscribe = vi.fn() +): StreamingExecution { + return { + stream: new ReadableStream(), + execution: { success: true, output: {} }, + subscribe: (sink: { onEvent: (event: AgentStreamEvent) => void | Promise }) => { + onSubscribe(sink.onEvent) + return unsubscribe + }, + } as StreamingExecution +} + +describe('forwardAgentStreamToExecutionEvents', () => { + it('subscribes in the sync window and maps sink events to execution events', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const unsubscribe = vi.fn() + const sendEvent = vi.fn() + + const unsub = forwardAgentStreamToExecutionEvents( + makeStreamingExec((handler) => { + sinkHandler = handler + }, unsubscribe), + { blockId: 'agent-1', executionId: 'exec-1', workflowId: 'wf-1', sendEvent } + ) + + expect(sinkHandler).toBeTypeOf('function') + + await sinkHandler!({ type: 'thinking_delta', text: 'plan ' }) + await sinkHandler!({ type: 'tool_call_start', id: 't1', name: 'http_request' }) + await sinkHandler!({ + type: 'tool_call_end', + id: 't1', + name: 'http_request', + status: 'success', + }) + await sinkHandler!({ type: 'text_delta', text: 'hi', turn: 'final' }) + + expect(sendEvent).toHaveBeenCalledTimes(3) + expect(sendEvent.mock.calls[0][0]).toMatchObject({ + type: 'stream:thinking', + executionId: 'exec-1', + workflowId: 'wf-1', + data: { blockId: 'agent-1', text: 'plan ' }, + }) + expect(sendEvent.mock.calls[1][0]).toMatchObject({ + type: 'stream:tool', + data: { blockId: 'agent-1', phase: 'start', id: 't1', name: 'http_request' }, + }) + expect(sendEvent.mock.calls[2][0]).toMatchObject({ + type: 'stream:tool', + data: { blockId: 'agent-1', phase: 'end', id: 't1', name: 'http_request', status: 'success' }, + }) + + unsub() + expect(unsubscribe).toHaveBeenCalled() + }) + + it('does not forward text deltas by default (answer text rides the byte stream)', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const sendEvent = vi.fn() + + forwardAgentStreamToExecutionEvents( + makeStreamingExec((handler) => { + sinkHandler = handler + }), + { blockId: 'agent-1', executionId: 'exec-1', workflowId: 'wf-1', sendEvent } + ) + + await sinkHandler!({ type: 'text_delta', text: 'answer', turn: 'final' }) + await sinkHandler!({ type: 'text_delta', text: 'preamble', turn: 'intermediate' }) + await sinkHandler!({ type: 'turn_end', turn: 'intermediate' }) + expect(sendEvent).not.toHaveBeenCalled() + }) + + it('forwardAnswerText streams live text and resets intermediate turns', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const sendEvent = vi.fn() + + forwardAgentStreamToExecutionEvents( + makeStreamingExec((handler) => { + sinkHandler = handler + }), + { + blockId: 'agent-1', + executionId: 'exec-1', + workflowId: 'wf-1', + sendEvent, + forwardAnswerText: true, + } + ) + + // Turn 1: preamble text, then tools follow → reset. + await sinkHandler!({ type: 'text_delta', text: 'Let me check…', turn: 'pending' }) + await sinkHandler!({ type: 'turn_end', turn: 'intermediate' }) + // Turn 2: final answer. + await sinkHandler!({ type: 'text_delta', text: 'Answer', turn: 'pending' }) + await sinkHandler!({ type: 'turn_end', turn: 'final' }) + // Intermediate-tagged deltas never forward. + await sinkHandler!({ type: 'text_delta', text: 'hidden', turn: 'intermediate' }) + + const calls = sendEvent.mock.calls.map(([event]) => ({ type: event.type, data: event.data })) + expect(calls).toEqual([ + { type: 'stream:chunk', data: { blockId: 'agent-1', chunk: 'Let me check…' } }, + { type: 'stream:chunk_reset', data: { blockId: 'agent-1' } }, + { type: 'stream:chunk', data: { blockId: 'agent-1', chunk: 'Answer' } }, + ]) + }) + + it('skips chunk_reset when no text was forwarded for the turn', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const sendEvent = vi.fn() + + forwardAgentStreamToExecutionEvents( + makeStreamingExec((handler) => { + sinkHandler = handler + }), + { + blockId: 'agent-1', + executionId: 'exec-1', + workflowId: 'wf-1', + sendEvent, + forwardAnswerText: true, + } + ) + + // Tool-only turn (no text) resolves intermediate — nothing to clear. + await sinkHandler!({ type: 'turn_end', turn: 'intermediate' }) + expect(sendEvent).not.toHaveBeenCalled() + }) + + it('no-ops when subscribe is absent', () => { + const streamingExec = { + stream: new ReadableStream(), + execution: { success: true, output: {} }, + } as StreamingExecution + + const unsub = forwardAgentStreamToExecutionEvents(streamingExec, { + blockId: 'agent-1', + executionId: 'exec-1', + workflowId: 'wf-1', + sendEvent: vi.fn(), + }) + expect(() => unsub()).not.toThrow() + }) +}) + +describe('shouldForwardAnswerTextFromSink', () => { + it('requires a sink and an untransformed client stream', () => { + const base = { + stream: new ReadableStream(), + execution: { success: true, output: {} }, + } as StreamingExecution + const subscribe = () => () => {} + + expect(shouldForwardAnswerTextFromSink(base)).toBe(false) + expect(shouldForwardAnswerTextFromSink({ ...base, subscribe })).toBe(true) + expect( + shouldForwardAnswerTextFromSink({ ...base, subscribe, clientStreamTransformed: true }) + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/workflows/streaming/forward-agent-stream-events.ts b/apps/sim/lib/workflows/streaming/forward-agent-stream-events.ts new file mode 100644 index 00000000000..0d270b64fe4 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/forward-agent-stream-events.ts @@ -0,0 +1,117 @@ +/** + * Bridges an agent-events provider sink onto the execution-events SSE + * vocabulary (`stream:thinking` / `stream:tool`, and optionally live answer + * text as `stream:chunk` + `stream:chunk_reset`). Shared by the workflow + * execute route and the HITL resume manager so the mapping cannot drift. + * + * Must be called in the caller's sync window (before awaiting the text + * reader) so the executor pump registers the sink before pulling provider + * chunks. + */ + +import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' +import type { StreamingExecution } from '@/executor/types' + +export interface ForwardAgentStreamEventsOptions { + blockId: string + executionId: string + workflowId: string + sendEvent: (event: ExecutionEvent) => void | Promise + /** + * When true, answer text deltas forward live as `stream:chunk` events and an + * intermediate `turn_end` forwards as `stream:chunk_reset`. The caller MUST + * then stop emitting `stream:chunk` from the block's byte stream, or clients + * receive the final turn's text twice. Never enable for response-format + * projected streams ({@link StreamingExecution.clientStreamTransformed}). + */ + forwardAnswerText?: boolean +} + +/** + * Returns true when the caller should source `stream:chunk` events from the + * sink (via {@link forwardAgentStreamToExecutionEvents} with + * `forwardAnswerText`) instead of the block's byte stream. + */ +export function shouldForwardAnswerTextFromSink(streamingExec: StreamingExecution): boolean { + return Boolean(streamingExec.subscribe) && streamingExec.clientStreamTransformed !== true +} + +/** + * Subscribes to the streaming execution's agent-events sink and forwards + * thinking deltas and tool lifecycle as execution events. With + * {@link ForwardAgentStreamEventsOptions.forwardAnswerText}, answer text also + * forwards live (`pending` deltas stream as the model generates; a + * `chunk_reset` clears turns that resolve to tool calls). Returns an + * unsubscribe function (no-op when the execution has no sink). + */ +export function forwardAgentStreamToExecutionEvents( + streamingExec: StreamingExecution, + options: ForwardAgentStreamEventsOptions +): () => void { + if (!streamingExec.subscribe) { + return () => {} + } + + const { blockId, executionId, workflowId, sendEvent, forwardAnswerText = false } = options + let emittedSinceReset = false + + return streamingExec.subscribe({ + onEvent: async (event) => { + if (event.type === 'thinking_delta') { + await sendEvent({ + type: 'stream:thinking', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, text: event.text }, + }) + return + } + if (event.type === 'tool_call_start') { + await sendEvent({ + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, phase: 'start', id: event.id, name: event.name }, + }) + return + } + if (event.type === 'tool_call_end') { + await sendEvent({ + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, phase: 'end', id: event.id, name: event.name, status: event.status }, + }) + return + } + if (!forwardAnswerText) { + return + } + if (event.type === 'text_delta') { + if (event.turn === 'intermediate' || !event.text) return + emittedSinceReset = true + await sendEvent({ + type: 'stream:chunk', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId, chunk: event.text }, + }) + return + } + if (event.type === 'turn_end' && event.turn === 'intermediate' && emittedSinceReset) { + emittedSinceReset = false + await sendEvent({ + type: 'stream:chunk_reset', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId }, + }) + } + }, + }) +} diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index f802dc5bfa4..da07914f4e9 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -604,3 +604,529 @@ describe('createStreamingResponse', () => { await expect(readSSEStream(stream)).resolves.toBe('ok') }) }) + +describe('createStreamingResponse agent-events-v1', () => { + beforeEach(() => { + vi.clearAllMocks() + clearLargeValueCacheForTests() + }) + + function createAgentStreamExecuteFn(options: { + thinking?: string[] + answer: string + fail?: boolean + tools?: Array< + | { type: 'tool_call_start'; id: string; name: string } + | { type: 'tool_call_end'; id: string; name: string; status: string } + > + }) { + return async ({ + onStream, + abortSignal, + }: { + onStream: (streamingExec: any) => Promise + onBlockComplete: (blockId: string, output: unknown) => Promise + abortSignal: AbortSignal + }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => { + sink = undefined + } + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: options.answer }, + logs: [], + metadata: {}, + }, + }) + + if (options.fail) { + textController.error(new Error('provider reset')) + await onStreamPromise.catch(() => {}) + throw new Error('provider reset') + } + + for (const text of options.thinking ?? []) { + await sink?.onEvent({ type: 'thinking_delta', text }) + } + for (const toolEvent of options.tools ?? []) { + await sink?.onEvent(toolEvent) + } + // Mirror the pump: text dispatches to the sink first, then projects to bytes. + await sink?.onEvent({ type: 'text_delta', text: options.answer, turn: 'final' }) + textController.enqueue(new TextEncoder().encode(options.answer)) + textController.close() + await onStreamPromise + + expect(abortSignal).toBeDefined() + + return { + success: true, + output: { content: options.answer }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + } + } + + async function collectSSEPayloads(stream: ReadableStream): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done) { + buffer += decoder.decode() + break + } + buffer += decoder.decode(value, { stream: true }) + } + return buffer + .split('\n\n') + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.startsWith('data: ')) + .map((chunk) => chunk.slice(6)) + } + + it('legacy path without protocol header stays text-only (no thinking frames)', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + // No requestHeaders → gate closed + executeFn: createAgentStreamExecuteFn({ + thinking: ['secret thought'], + answer: 'Hello', + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Hello' }) + expect(events.some((event) => event.event === 'final')).toBe(true) + }) + + it('header + includeThinking emits thinking on data and answer on chunk', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + thinking: ['hmm ', 'yes'], + answer: 'Answer', + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.filter((event) => event.event === 'thinking')).toEqual([ + { blockId: 'agent-1', event: 'thinking', data: 'hmm ' }, + { blockId: 'agent-1', event: 'thinking', data: 'yes' }, + ]) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + expect(events.some((event) => event.event === 'final')).toBe(true) + }) + + it('dual gate emits tool start/end frames without putting tools on chunk', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + answer: 'Done', + tools: [ + { type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }, + { + type: 'tool_call_end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }, + ], + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.filter((event) => event.event === 'tool')).toEqual([ + { + blockId: 'agent-1', + event: 'tool', + phase: 'start', + id: 'toolu_1', + name: 'get_weather', + }, + { + blockId: 'agent-1', + event: 'tool', + phase: 'end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }, + ]) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Done' }) + expect( + events.some( + (event) => + typeof event.chunk === 'string' && + (String(event.chunk).includes('toolu_1') || String(event.chunk).includes('get_weather')) + ) + ).toBe(false) + }) + + it('dual gate streams pending text live and resets intermediate turns', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => {} + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: 'Final answer' }, + logs: [], + metadata: {}, + }, + } as any) + + // Turn 1: live preamble, then tools follow → intermediate turn_end. + await sink?.onEvent({ type: 'text_delta', text: 'Checking…', turn: 'pending' }) + await sink?.onEvent({ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }) + await sink?.onEvent({ type: 'turn_end', turn: 'intermediate' }) + await sink?.onEvent({ + type: 'tool_call_end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }) + // Turn 2: live final answer; pump projects it to bytes at turn_end. + await sink?.onEvent({ type: 'text_delta', text: 'Final ', turn: 'pending' }) + await sink?.onEvent({ type: 'text_delta', text: 'answer', turn: 'pending' }) + await sink?.onEvent({ type: 'turn_end', turn: 'final' }) + textController.enqueue(new TextEncoder().encode('Final answer')) + textController.close() + await onStreamPromise + + return { + success: true, + output: { content: 'Final answer' }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + + // Live text arrives as chunk frames in stream order, with a reset between turns. + const answerFlow = events + .filter((event) => event.chunk !== undefined || event.event === 'chunk_reset') + .map((event) => (event.event === 'chunk_reset' ? 'RESET' : event.chunk)) + expect(answerFlow).toEqual(['Checking…', 'RESET', 'Final ', 'answer']) + + // The byte-path flush of the same final text must not duplicate chunk frames. + expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual( + ['Checking…', 'Final ', 'answer'] + ) + }) + + it('dual gate keeps byte-path chunks for response-format transformed streams', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => {} + }, + clientStreamTransformed: true, + execution: { + blockId: 'agent-1', + success: true, + output: { content: '{"answer":"extracted"}' }, + logs: [], + metadata: {}, + }, + } as any) + + // Sink text must NOT become chunk frames — bytes are a different projection. + await sink?.onEvent({ type: 'text_delta', text: '{"answer":"', turn: 'pending' }) + await sink?.onEvent({ type: 'text_delta', text: 'extracted"}', turn: 'pending' }) + await sink?.onEvent({ type: 'turn_end', turn: 'final' }) + textController.enqueue(new TextEncoder().encode('extracted')) + textController.close() + await onStreamPromise + + return { + success: true, + output: { content: '{"answer":"extracted"}' }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual( + ['extracted'] + ) + expect(events.some((event) => event.event === 'chunk_reset')).toBe(false) + }) + + it('protocol header without includeThinking does not emit tool frames', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + answer: 'Answer', + tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }], + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'tool')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + }) + + it('protocol header without includeThinking does not emit thinking', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + thinking: ['should not appear'], + answer: 'Answer', + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + }) + + it('provider failure emits one terminal error, no final, then [DONE]', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: {}, + executeFn: createAgentStreamExecuteFn({ + answer: 'partial', + fail: true, + }), + }) + + const payloads = await collectSSEPayloads(stream) + const events = payloads + .filter((payload) => payload !== '[DONE]' && payload !== '"[DONE]"') + .map((payload) => JSON.parse(payload) as Record) + + expect(events.filter((event) => event.event === 'error')).toHaveLength(1) + expect(events.some((event) => event.event === 'final')).toBe(false) + expect(payloads.some((payload) => payload === '[DONE]' || payload === '"[DONE]"')).toBe(true) + }) + + it('requestSignal abort propagates to executeFn abortSignal', async () => { + const requestAbort = new AbortController() + let sawAbort = false + + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestSignal: requestAbort.signal, + streamConfig: {}, + executeFn: async ({ abortSignal }) => { + requestAbort.abort() + sawAbort = abortSignal.aborted + return { + success: false, + status: 'cancelled', + output: {}, + logs: [], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + expect(sawAbort).toBe(true) + expect(events.some((event) => event.event === 'final')).toBe(false) + expect(events).toContainEqual({ event: 'error', error: 'Client cancelled request' }) + }) + + it('thinking never enters streamedChunks / log content rewrite', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + let rewrittenContent: string | undefined + + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: any) => { + sink = nextSink + return () => { + sink = undefined + } + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: 'visible' }, + logs: [], + metadata: {}, + }, + } as any) + + await sink?.onEvent({ type: 'thinking_delta', text: 'PRIVATE_THINKING' }) + textController.enqueue(new TextEncoder().encode('visible')) + textController.close() + await onStreamPromise + + return { + success: true, + output: {}, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + const answerChunks = events.filter((event) => typeof event.chunk === 'string') + expect(answerChunks.every((event) => !String(event.chunk).includes('PRIVATE_THINKING'))).toBe( + true + ) + expect(events).toContainEqual({ + blockId: 'agent-1', + event: 'thinking', + data: 'PRIVATE_THINKING', + }) + // Force consumption of stream so log rewrite runs + expect(events.some((event) => event.event === 'final')).toBe(true) + void rewrittenContent + }) +}) diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index c848854346f..b96918bcba0 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' import { extractBlockIdFromOutputId, @@ -24,8 +25,22 @@ import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' +import { + AGENT_STREAM_PROTOCOL_HEADER, + AGENT_STREAM_PROTOCOL_V1, + type ChatStreamChunkFrame, + type ChatStreamChunkResetFrame, + type ChatStreamErrorFrame, + type ChatStreamFinalFrame, + type ChatStreamStreamErrorFrame, + type ChatStreamThinkingFrame, + type ChatStreamToolFrame, + shouldEmitAgentStreamEvents, +} from '@/lib/workflows/streaming/agent-stream-protocol' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' +import type { ToolCallEndStatus } from '@/providers/stream-events' +import { DEFAULT_MAX_THINKING_CHARS } from '@/providers/stream-pump' /** * Extended streaming execution type that includes blockId on the execution. @@ -41,6 +56,21 @@ const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] const SELECTED_OUTPUT_TOO_LARGE_MESSAGE = 'Selected output is too large to inline; select a nested field or use pagination/preview.' +/** + * Simple SSE stream contract — frame shapes are the `ChatStreamFrame` union in + * `agent-stream-protocol.ts`, consumed by both these emitters and the chat client: + * - Answer text: `{ blockId, chunk }` only (`chunk` is forever answer text). + * Legacy clients get settled final-turn text; dual-gated clients get answer + * text live from the agent-events sink, reconciled by + * `{ blockId, event: 'chunk_reset' }` when a turn resolves to tool calls. + * - Thinking (opt-in): `{ blockId, event: 'thinking', data }` — never uses `chunk`. + * - Success terminal: `{ event: 'final', data }` then `[DONE]`. + * - Failure terminal: exactly one `{ event: 'error', ... }` then `[DONE]`. No `final` after failure. + * - Mid-block read issues may emit non-terminal `{ event: 'stream_error', blockId, error }`. + * - Thinking never enters `streamedChunks` / log rewrite / tokenization — the + * log/tokenization source is always the byte stream (final-turn text only). + */ + interface StreamingConfig { selectedOutputs?: string[] isSecureMode?: boolean @@ -48,6 +78,11 @@ interface StreamingConfig { includeFileBase64?: boolean base64MaxBytes?: number timeoutMs?: number + /** + * Deployment policy for thinking/tool SSE. Still requires the client to send + * {@link AGENT_STREAM_PROTOCOL_HEADER}: {@link AGENT_STREAM_PROTOCOL_V1}. + */ + includeThinking?: boolean } export type StreamingExecutorFn = (callbacks: { @@ -67,9 +102,33 @@ export interface StreamingResponseOptions { workspaceId?: string workflowId?: string userId?: string + /** Incoming fetch/request abort — combined with the stream timeout. */ + requestSignal?: AbortSignal + /** Used with {@link StreamingConfig.includeThinking} for dual-gate thinking SSE. */ + requestHeaders?: Headers | { get(name: string): string | null } executeFn: StreamingExecutorFn } +/** + * Extra response headers when the dual-gate agent stream protocol is active. + * Callers should merge these into the SSE response alongside {@link SSE_HEADERS}. + */ +export function agentStreamProtocolResponseHeaders(options: { + includeThinking?: boolean | null + requestHeaders?: Headers | { get(name: string): string | null } +}): Record { + if (!options.requestHeaders) return {} + if ( + !shouldEmitAgentStreamEvents({ + includeThinking: options.includeThinking, + requestHeaders: options.requestHeaders, + }) + ) { + return {} + } + return { [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 } +} + interface StreamingState { streamedChunks: Map processedOutputs: Set @@ -193,6 +252,47 @@ function assertSelectedOutputBytes(value: unknown): number { return bytes } +/** + * Strips model internals from `providerTiming.timeSegments` before an output + * rides a simple-SSE `final` envelope: `thinkingContent`, intermediate + * `assistantContent`, and tool-call arguments would otherwise reach public + * chat clients wholesale, bypassing the dual-gated thinking/tool frames. + * Timing numbers and tool names stay — they carry no model internals. + */ +function sanitizeProviderTimingForEnvelope( + output: Record +): Record { + const providerTiming = output.providerTiming as { timeSegments?: unknown } | undefined + if (!providerTiming || !Array.isArray(providerTiming.timeSegments)) { + return output + } + return { + ...output, + providerTiming: { + ...providerTiming, + timeSegments: providerTiming.timeSegments.map((segment) => { + if (!segment || typeof segment !== 'object') return segment + const { toolCalls, ...rest } = omit(segment as Record, [ + 'thinkingContent', + 'assistantContent', + ]) as Record & { toolCalls?: unknown } + return { + ...rest, + ...(Array.isArray(toolCalls) + ? { + toolCalls: toolCalls.map((toolCall) => + toolCall && typeof toolCall === 'object' + ? omit(toolCall as Record, ['arguments']) + : toolCall + ), + } + : {}), + } + }), + }, + } +} + async function buildMinimalResult( result: ExecutionResult, selectedOutputs: string[] | undefined, @@ -220,7 +320,7 @@ async function buildMinimalResult( } if (result.status === 'paused') { - minimalResult.output = result.output || {} + minimalResult.output = sanitizeProviderTimingForEnvelope(result.output || {}) return compactExecutionPayload(minimalResult, { ...durableContext, preserveUserFileBase64: includeFileBase64, @@ -229,7 +329,7 @@ async function buildMinimalResult( } if (!selectedOutputs?.length) { - minimalResult.output = result.output || {} + minimalResult.output = sanitizeProviderTimingForEnvelope(result.output || {}) return compactExecutionPayload(minimalResult, { ...durableContext, preserveUserFileBase64: includeFileBase64, @@ -351,14 +451,31 @@ export async function createStreamingResponse( options: StreamingResponseOptions ): Promise { const { requestId, streamConfig, executionId, executeFn } = options - const durableContext = { - workspaceId: options.workspaceId, - workflowId: options.workflowId, - executionId, - userId: options.userId, - requireDurable: Boolean(options.workspaceId && options.workflowId && executionId), - } const timeoutController = createTimeoutAbortController(streamConfig.timeoutMs) + const emitAgentEvents = + Boolean(options.requestHeaders) && + shouldEmitAgentStreamEvents({ + includeThinking: streamConfig.includeThinking, + requestHeaders: options.requestHeaders!, + }) + const maxThinkingChars = DEFAULT_MAX_THINKING_CHARS + + let requestAborted = false + const onRequestAbort = () => { + requestAborted = true + timeoutController.abort() + } + if (options.requestSignal) { + if (options.requestSignal.aborted) { + onRequestAbort() + } else { + options.requestSignal.addEventListener('abort', onRequestAbort, { once: true }) + } + } + + const cleanupRequestAbort = () => { + options.requestSignal?.removeEventListener('abort', onRequestAbort) + } return new ReadableStream({ async start(controller) { @@ -370,6 +487,7 @@ export async function createStreamingResponse( selectedOutputBytes: 0, streamedSelectedOutputKeys: new Set(), } + let thinkingCharsEmitted = 0 const sendChunk = ( blockId: string, @@ -386,12 +504,47 @@ export async function createStreamingResponse( state.selectedOutputBytes = nextSelectedOutputBytes state.streamedSelectedOutputKeys.add(options.selectedOutputKey) } - controller.enqueue(encodeSSE({ blockId, chunk })) + const frame: ChatStreamChunkFrame = { blockId, chunk } + controller.enqueue(encodeSSE(frame)) state.processedOutputs.add(blockId) } + const sendThinking = (blockId: string, text: string) => { + if (!text || thinkingCharsEmitted >= maxThinkingChars) return + const remaining = maxThinkingChars - thinkingCharsEmitted + const forwarded = text.length > remaining ? text.slice(0, remaining) : text + thinkingCharsEmitted += forwarded.length + // Never push thinking into streamedChunks — logs stay answer-text only. + const frame: ChatStreamThinkingFrame = { + blockId, + event: 'thinking', + data: forwarded, + } + controller.enqueue(encodeSSE(frame)) + } + + const sendTool = ( + blockId: string, + phase: 'start' | 'end', + id: string, + name: string, + status?: ToolCallEndStatus + ) => { + const frame: ChatStreamToolFrame = { + blockId, + event: 'tool', + phase, + id, + name, + ...(phase === 'end' && status ? { status } : {}), + } + controller.enqueue(encodeSSE(frame)) + } + /** * Callback for handling streaming execution events. + * Subscribe synchronously before the first await so the executor pump + * can attach sinks before pulling provider chunks. */ const onStreamCallback = async (streamingExec: StreamingExecutionWithBlockId) => { const blockId = streamingExec.execution?.blockId @@ -400,9 +553,62 @@ export async function createStreamingResponse( return } + /** + * Dual-gated clients get answer text live from the sink (pending deltas + * stream as the model generates; `chunk_reset` clears an intermediate + * turn). The byte stream then only feeds `streamedChunks` for logs. + * Response-format projections rewrite the bytes, so those blocks keep + * the byte stream as the frame source. + */ + const sinkAnswerText = + emitAgentEvents && + Boolean(streamingExec.subscribe) && + streamingExec.clientStreamTransformed !== true + + /** False until the first chunk since block start or since a reset. */ + let emittedSinceReset = false + + const emitAnswerChunk = (text: string) => { + if (!text) return + if (!emittedSinceReset) { + // sendChunk adds the cross-block separator + output bookkeeping. + sendChunk(blockId, text) + emittedSinceReset = true + } else { + const frame: ChatStreamChunkFrame = { blockId, chunk: text } + controller.enqueue(encodeSSE(frame)) + } + } + + let unsubscribe: (() => void) | undefined + if (emitAgentEvents && streamingExec.subscribe) { + unsubscribe = streamingExec.subscribe({ + onEvent: async (event) => { + if (event.type === 'thinking_delta') { + sendThinking(blockId, event.text) + } else if (event.type === 'tool_call_start') { + sendTool(blockId, 'start', event.id, event.name) + } else if (event.type === 'tool_call_end') { + sendTool(blockId, 'end', event.id, event.name, event.status) + } else if (sinkAnswerText && event.type === 'text_delta') { + if (event.turn !== 'intermediate') { + emitAnswerChunk(event.text) + } + } else if (sinkAnswerText && event.type === 'turn_end') { + if (event.turn === 'intermediate' && emittedSinceReset) { + const frame: ChatStreamChunkResetFrame = { blockId, event: 'chunk_reset' } + controller.enqueue(encodeSSE(frame)) + // Re-arm separator bookkeeping so re-streamed text starts clean. + emittedSinceReset = false + state.processedOutputs.delete(blockId) + } + } + }, + }) + } + const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() - let isFirstChunk = true try { while (true) { @@ -418,22 +624,20 @@ export async function createStreamingResponse( } state.streamedChunks.get(blockId)!.push(textChunk) - if (isFirstChunk) { - sendChunk(blockId, textChunk) - isFirstChunk = false - } else { - controller.enqueue(encodeSSE({ blockId, chunk: textChunk })) + if (!sinkAnswerText) { + emitAnswerChunk(textChunk) } } } catch (error) { logger.error(`[${requestId}] Error reading stream for block ${blockId}:`, error) - controller.enqueue( - encodeSSE({ - event: 'stream_error', - blockId, - error: getErrorMessage(error, 'Stream reading error'), - }) - ) + const frame: ChatStreamStreamErrorFrame = { + event: 'stream_error', + blockId, + error: getErrorMessage(error, 'Stream reading error'), + } + controller.enqueue(encodeSSE(frame)) + } finally { + unsubscribe?.() } } @@ -521,13 +725,12 @@ export async function createStreamingResponse( }) const errorMessage = getSelectedOutputErrorMessage(error) state.selectedOutputError ??= errorMessage - controller.enqueue( - encodeSSE({ - event: 'error', - blockId, - error: errorMessage, - }) - ) + const frame: ChatStreamErrorFrame = { + event: 'error', + blockId, + error: errorMessage, + } + controller.enqueue(encodeSSE(frame)) break } } @@ -555,7 +758,8 @@ export async function createStreamingResponse( if ( result.status === 'cancelled' && timeoutController.isTimedOut() && - timeoutController.timeoutMs + timeoutController.timeoutMs && + !requestAborted ) { const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) logger.info(`[${requestId}] Streaming execution timed out`, { @@ -564,7 +768,17 @@ export async function createStreamingResponse( if (result._streamingMetadata?.loggingSession) { await result._streamingMetadata.loggingSession.markAsFailed(timeoutErrorMessage) } - controller.enqueue(encodeSSE({ event: 'error', error: timeoutErrorMessage })) + const frame: ChatStreamErrorFrame = { event: 'error', error: timeoutErrorMessage } + controller.enqueue(encodeSSE(frame)) + } else if (result.status === 'cancelled' && requestAborted) { + logger.info(`[${requestId}] Streaming execution aborted by client disconnect`) + if (result._streamingMetadata?.loggingSession) { + // LoggingSession has no cancelled status; match workflow execute route wording. + await result._streamingMetadata.loggingSession.markAsFailed('Client cancelled request') + } + // No `final` after abort; clients that already disconnected ignore these. + const frame: ChatStreamErrorFrame = { event: 'error', error: 'Client cancelled request' } + controller.enqueue(encodeSSE(frame)) } else { await completeLoggingSession(result) @@ -591,18 +805,18 @@ export async function createStreamingResponse( } ) - controller.enqueue( - encodeSSE({ - event: 'final', - data: { - ...minimalResult, - ...(result.status === 'paused' && { status: 'paused' }), - }, - }) - ) + const frame: ChatStreamFinalFrame = { + event: 'final', + data: { + ...minimalResult, + ...(result.status === 'paused' && { status: 'paused' }), + }, + } + controller.enqueue(encodeSSE(frame)) } } + // Terminal marker: always follows success `final` or a single terminal `error`. controller.enqueue(encodeSSE('[DONE]')) if (executionId) { @@ -616,7 +830,10 @@ export async function createStreamingResponse( streamConfig.selectedOutputs?.length && isExecutionResourceLimitError(error) ? SELECTED_OUTPUT_TOO_LARGE_MESSAGE : getErrorMessage(error, 'Stream processing error') - controller.enqueue(encodeSSE({ event: 'error', error: errorMessage })) + const frame: ChatStreamErrorFrame = { event: 'error', error: errorMessage } + controller.enqueue(encodeSSE(frame)) + // Same terminal rule as timeout/abort: one error, then [DONE], never `final`. + controller.enqueue(encodeSSE('[DONE]')) if (executionId) { await cleanupExecutionBase64Cache(executionId) @@ -624,12 +841,15 @@ export async function createStreamingResponse( controller.close() } finally { + cleanupRequestAbort() timeoutController.cleanup() } }, async cancel(reason) { logger.info(`[${requestId}] Streaming response cancelled`, { reason }) + requestAborted = true timeoutController.abort() + cleanupRequestAbort() timeoutController.cleanup() if (executionId) { try { diff --git a/apps/sim/package.json b/apps/sim/package.json index 056cd32c491..f0aaa82fa3a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -36,7 +36,7 @@ "dependencies": { "@1password/sdk": "0.3.1", "@a2a-js/sdk": "1.0.0-alpha.0", - "@anthropic-ai/sdk": "0.71.2", + "@anthropic-ai/sdk": "0.114.0", "@aws-sdk/client-appconfigdata": "3.1032.0", "@aws-sdk/client-athena": "3.1032.0", "@aws-sdk/client-bedrock-runtime": "3.1032.0", diff --git a/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts b/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts new file mode 100644 index 00000000000..b16d8107bc9 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts @@ -0,0 +1,194 @@ +/** + * @vitest-environment node + * + * Fixture gate: Anthropic stream fixtures parse and match expected assembled + * thinking/text/tool/signature content. No provider adapters are exercised yet. + */ +import { describe, expect, it } from 'vitest' +import { + anthropicRedactedThinkingAssembledContent, + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, + anthropicThinkingTextToolAssembledContent, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' + +type StreamEvent = { + type: string + index?: number + delta?: { + type: string + thinking?: string + text?: string + signature?: string + partial_json?: string + } + content_block?: { + type: string + thinking?: string + text?: string + data?: string + id?: string + name?: string + input?: unknown + signature?: string + } +} + +function assembleAnthropicContentFromStream(events: readonly StreamEvent[]) { + const blocks: Array> = [] + + for (const event of events) { + if (event.type === 'content_block_start' && event.content_block) { + const block = event.content_block + if (block.type === 'thinking') { + blocks.push({ type: 'thinking', thinking: block.thinking ?? '', signature: '' }) + } else if (block.type === 'redacted_thinking') { + blocks.push({ type: 'redacted_thinking', data: block.data ?? '' }) + } else if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text ?? '' }) + } else if (block.type === 'tool_use') { + blocks.push({ + type: 'tool_use', + id: block.id, + name: block.name, + inputJson: '', + }) + } + continue + } + + if (event.type !== 'content_block_delta' || event.index === undefined || !event.delta) { + continue + } + + const target = blocks[event.index] + if (!target) continue + + const delta = event.delta + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + target.thinking = `${target.thinking ?? ''}${delta.thinking}` + } else if (delta.type === 'signature_delta' && typeof delta.signature === 'string') { + target.signature = delta.signature + } else if (delta.type === 'text_delta' && typeof delta.text === 'string') { + target.text = `${target.text ?? ''}${delta.text}` + } else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') { + target.inputJson = `${target.inputJson ?? ''}${delta.partial_json}` + } + } + + return blocks.map((block) => { + if (block.type === 'tool_use') { + const inputJson = typeof block.inputJson === 'string' ? block.inputJson : '{}' + return { + type: 'tool_use', + id: block.id, + name: block.name, + input: JSON.parse(inputJson || '{}'), + } + } + if (block.type === 'thinking') { + return { + type: 'thinking', + thinking: block.thinking, + signature: block.signature, + } + } + return block + }) +} + +function extractTextDeltas(events: readonly StreamEvent[]): string { + return events + .filter( + (e) => + e.type === 'content_block_delta' && + e.delta?.type === 'text_delta' && + typeof e.delta.text === 'string' + ) + .map((e) => e.delta!.text!) + .join('') +} + +function extractThinkingDeltas(events: readonly StreamEvent[]): string { + return events + .filter( + (e) => + e.type === 'content_block_delta' && + e.delta?.type === 'thinking_delta' && + typeof e.delta.thinking === 'string' + ) + .map((e) => e.delta!.thinking!) + .join('') +} + +function assertValidStreamSequence(events: readonly StreamEvent[]) { + expect(events[0]?.type).toBe('message_start') + expect(events[events.length - 1]?.type).toBe('message_stop') + + const open = new Set() + for (const event of events) { + if (event.type === 'content_block_start' && event.index !== undefined) { + expect(open.has(event.index)).toBe(false) + open.add(event.index) + } + if (event.type === 'content_block_stop' && event.index !== undefined) { + expect(open.has(event.index)).toBe(true) + open.delete(event.index) + } + } + expect(open.size).toBe(0) +} + +describe('Anthropic stream fixtures', () => { + it('parses thinking → text → tool_use stream and preserves signature', () => { + assertValidStreamSequence(anthropicThinkingTextToolStreamEvents) + + expect(extractThinkingDeltas(anthropicThinkingTextToolStreamEvents)).toBe( + anthropicThinkingTextToolExpectedThinking + ) + expect(extractTextDeltas(anthropicThinkingTextToolStreamEvents)).toBe( + anthropicThinkingTextToolExpectedText + ) + + const assembled = assembleAnthropicContentFromStream(anthropicThinkingTextToolStreamEvents) + expect(assembled).toEqual([...anthropicThinkingTextToolAssembledContent]) + + const thinkingBlock = assembled.find((b) => b.type === 'thinking') as { + signature?: string + } + expect(thinkingBlock?.signature).toMatch(/^EpAB/) + }) + + it('parses redacted_thinking + signed thinking + text and matches trace mapping', () => { + assertValidStreamSequence(anthropicRedactedThinkingStreamEvents) + + expect(extractTextDeltas(anthropicRedactedThinkingStreamEvents)).toBe( + anthropicRedactedThinkingExpectedText + ) + + const assembled = assembleAnthropicContentFromStream(anthropicRedactedThinkingStreamEvents) + expect(assembled).toEqual([...anthropicRedactedThinkingAssembledContent]) + + // Mirrors enrichLastModelSegmentFromAnthropicResponse: redacted → "[redacted]" + const traceThinking = assembled + .filter((b) => b.type === 'thinking' || b.type === 'redacted_thinking') + .map((b) => (b.type === 'thinking' ? b.thinking : '[redacted]')) + .join('\n\n') + expect(traceThinking).toBe(anthropicRedactedThinkingExpectedTraceThinking) + }) + + it('documents that live stream today would only surface text_delta bytes', () => { + // Baseline behavior of createReadableStreamFromAnthropicStream: only text_delta + // is enqueued. This test locks the fixture expectation for later adapter work. + const textOnlyFromStream = extractTextDeltas(anthropicThinkingTextToolStreamEvents) + const thinkingFromStream = extractThinkingDeltas(anthropicThinkingTextToolStreamEvents) + + expect(textOnlyFromStream).toBe(anthropicThinkingTextToolExpectedText) + expect(thinkingFromStream.length).toBeGreaterThan(0) + expect(textOnlyFromStream).not.toContain('I should check the weather') + }) +}) diff --git a/apps/sim/providers/__fixtures__/anthropic/index.ts b/apps/sim/providers/__fixtures__/anthropic/index.ts new file mode 100644 index 00000000000..222e7af8c71 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/index.ts @@ -0,0 +1,17 @@ +/** + * Re-exports Anthropic stream fixtures used by agent-stream-events work. + * Keep fixture data in dedicated modules so adapters can import without pulling tests. + */ + +export { + anthropicRedactedThinkingAssembledContent, + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, +} from '@/providers/__fixtures__/anthropic/redacted-thinking-signature' +export { + anthropicThinkingTextToolAssembledContent, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic/thinking-text-tool' diff --git a/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts b/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts new file mode 100644 index 00000000000..7e70d0a44e5 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts @@ -0,0 +1,98 @@ +/** + * Anthropic stream + assembled message covering redacted_thinking blocks. + * + * When Anthropic redacts thinking, the stream emits a redacted_thinking content + * block (opaque `data`) instead of thinking_delta text. Multi-turn tool loops + * must round-trip that block (and any adjacent signed thinking blocks) back + * into subsequent Messages API requests unchanged. + */ + +export const anthropicRedactedThinkingStreamEvents = [ + { + type: 'message_start', + message: { + id: 'msg_fixture_redacted_thinking', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 20, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'redacted_thinking', + data: 'fixture-redacted-thinking-opaque-blob-001', + }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { + type: 'thinking', + thinking: '', + }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'thinking_delta', thinking: 'Visible follow-up reasoning after redaction.' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { + type: 'signature_delta', + signature: 'EpABCkYICBgCKkDfixture-visible-thinking-signature-def456', + }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'text_delta', text: 'Here is the answer after redacted thinking.' }, + }, + { type: 'content_block_stop', index: 2 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 64 }, + }, + { type: 'message_stop' }, +] as const + +/** + * Assembled content that must be preserved when appending the assistant turn + * to Anthropic history (see anthropic/core.ts thinking/redacted_thinking filters). + */ +export const anthropicRedactedThinkingAssembledContent = [ + { + type: 'redacted_thinking', + data: 'fixture-redacted-thinking-opaque-blob-001', + }, + { + type: 'thinking', + thinking: 'Visible follow-up reasoning after redaction.', + signature: 'EpABCkYICBgCKkDfixture-visible-thinking-signature-def456', + }, + { + type: 'text', + text: 'Here is the answer after redacted thinking.', + }, +] as const + +/** What enrichLastModelSegmentFromAnthropicResponse maps redacted blocks to today. */ +export const anthropicRedactedThinkingExpectedTraceThinking = + '[redacted]\n\nVisible follow-up reasoning after redaction.' + +export const anthropicRedactedThinkingExpectedText = 'Here is the answer after redacted thinking.' diff --git a/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts b/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts new file mode 100644 index 00000000000..8ac06d00a63 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts @@ -0,0 +1,118 @@ +/** + * Anthropic Messages API SSE-style stream events for a single assistant turn that: + * 1. Streams extended thinking (thinking_delta + signature_delta) + * 2. Streams answer text (text_delta) + * 3. Streams a tool_use block (input_json_delta) + * + * Shapes mirror Anthropic RawMessageStreamEvent fields used by + * `createReadableStreamFromAnthropicStream`. The adapter emits thinking_delta + + * text_delta AgentStreamEvents; tool_use deltas are ignored until the streaming + * tool loop. + */ + +export const anthropicThinkingTextToolStreamEvents = [ + { + type: 'message_start', + message: { + id: 'msg_fixture_thinking_text_tool', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 42, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '', signature: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'I should check the weather before answering. ' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Calling get_weather for SF.' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { + type: 'signature_delta', + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'Let me check the weather in San Francisco.' }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '{"city":' }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '"San Francisco"}' }, + }, + { type: 'content_block_stop', index: 2 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use', stop_sequence: null }, + usage: { output_tokens: 128 }, + }, + { type: 'message_stop' }, +] as const + +/** + * Expected assembled assistant Message.content after draining the stream above. + * Used for history round-trip tests (thinking block must keep its signature). + */ +export const anthropicThinkingTextToolAssembledContent = [ + { + type: 'thinking', + thinking: 'I should check the weather before answering. Calling get_weather for SF.', + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + { + type: 'text', + text: 'Let me check the weather in San Francisco.', + }, + { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: { city: 'San Francisco' }, + }, +] as const + +/** Concatenated thinking text (what traces should store as thinkingContent). */ +export const anthropicThinkingTextToolExpectedThinking = + 'I should check the weather before answering. Calling get_weather for SF.' + +/** Concatenated answer text (what output.content / live stream should contain today). */ +export const anthropicThinkingTextToolExpectedText = 'Let me check the weather in San Francisco.' diff --git a/apps/sim/providers/__fixtures__/openai-compat/index.ts b/apps/sim/providers/__fixtures__/openai-compat/index.ts new file mode 100644 index 00000000000..d13cf94acbf --- /dev/null +++ b/apps/sim/providers/__fixtures__/openai-compat/index.ts @@ -0,0 +1,69 @@ +/** + * OpenAI-compat stream fixtures — capability-honest reasoning deltas. + */ +export const openaiCompatReasoningAndTextChunks = [ + { + choices: [ + { + delta: { + reasoning_content: 'I should compute carefully. ', + }, + }, + ], + }, + { + choices: [ + { + delta: { + reasoning_content: 'Answer is 4.', + content: '2+2=', + }, + }, + ], + }, + { + choices: [{ delta: { content: '4' } }], + }, + // Usage arrives on a trailing chunk with empty choices (stream_options.include_usage). + { + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }, +] as const + +export const openaiCompatTextOnlyChunks = [ + { + choices: [{ delta: { content: 'Hello' } }], + }, + { + choices: [{ delta: { content: ' world' } }], + }, + // Usage arrives on a trailing chunk with empty choices (stream_options.include_usage). + { + choices: [], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }, +] as const + +export const openaiCompatToolCallStartChunks = [ + { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: 'call_abc', function: { name: 'http_request', arguments: '' } }, + ], + }, + }, + ], + }, + { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '{"url":' } }], + }, + }, + ], + }, +] as const diff --git a/apps/sim/providers/agent-events-smokes.test.ts b/apps/sim/providers/agent-events-smokes.test.ts new file mode 100644 index 00000000000..3c7e94891ff --- /dev/null +++ b/apps/sim/providers/agent-events-smokes.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + * + * Agent-events capability smokes: Anthropic thinking+tool fixture, openai-compat thinking, + * and a non-thinking text-only stream — capability-honest expectations. + */ +import { describe, expect, it } from 'vitest' +import { anthropicThinkingTextToolStreamEvents } from '@/providers/__fixtures__/anthropic' +import { + openaiCompatReasoningAndTextChunks, + openaiCompatTextOnlyChunks, +} from '@/providers/__fixtures__/openai-compat' +import { createReadableStreamFromAnthropicStream } from '@/providers/anthropic/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('agent-events provider smokes', () => { + it('Anthropic fixture emits thinking then text (tools ignored on simple util)', async () => { + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicThinkingTextToolStreamEvents as any + })() + ) + const events = await collectEvents(stream) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(true) + expect(events.some((e) => e.type === 'text_delta')).toBe(true) + // Simple Anthropic util does not emit tool_call_* (loop does). + expect(events.some((e) => e.type === 'tool_call_start')).toBe(false) + }) + + it('openai-compat reasoning model emits thinking_delta', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatReasoningAndTextChunks as any + })(), + { providerName: 'DeepSeek' } + ) + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').length).toBeGreaterThan(0) + expect(events.some((e) => e.type === 'text_delta')).toBe(true) + }) + + it('openai-compat non-thinking model stays text-only', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'OpenAI' } + ) + const events = await collectEvents(stream) + expect(events.every((e) => e.type === 'text_delta')).toBe(true) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + }) +}) diff --git a/apps/sim/providers/anthropic/core.thinking.test.ts b/apps/sim/providers/anthropic/core.thinking.test.ts new file mode 100644 index 00000000000..8e20f19a92a --- /dev/null +++ b/apps/sim/providers/anthropic/core.thinking.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + * + * Anthropic thinking config: the summarized-display opt-in is requested only + * on agent-events runs and only for models whose registry marks summarized + * streaming (the omitted-display Claude generations). Legacy runs keep the + * exact pre-agent-events request shape. + */ +import { describe, expect, it } from 'vitest' +import { buildThinkingConfig } from '@/providers/anthropic/core' + +describe('buildThinkingConfig', () => { + it('requests summarized display for omitted-display models on agent-events runs', () => { + for (const model of [ + 'claude-fable-5', + 'claude-sonnet-5', + 'claude-opus-4-8', + 'claude-opus-4-7', + ]) { + const config = buildThinkingConfig(model, 'high', true) + expect(config?.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) + expect(config?.outputConfig).toEqual({ effort: 'high' }) + } + }) + + it('never adds display on legacy runs (no agent events)', () => { + for (const model of [ + 'claude-fable-5', + 'claude-sonnet-5', + 'claude-opus-4-8', + 'claude-opus-4-7', + ]) { + const config = buildThinkingConfig(model, 'high', false) + expect(config?.thinking).toEqual({ type: 'adaptive' }) + } + }) + + it('never adds display for adaptive models that already stream full thinking', () => { + for (const model of ['claude-opus-4-6', 'claude-sonnet-4-6']) { + const config = buildThinkingConfig(model, 'high', true) + expect(config?.thinking).toEqual({ type: 'adaptive' }) + } + }) + + it('keeps budget-token models on the extended thinking path', () => { + const config = buildThinkingConfig('claude-sonnet-4-5', 'high', true) + expect(config?.thinking).toMatchObject({ type: 'enabled' }) + expect(config?.thinking).not.toHaveProperty('display') + }) + + it('returns null for unknown levels and non-thinking models', () => { + expect(buildThinkingConfig('claude-fable-5', 'not-a-level', true)).toBeNull() + expect(buildThinkingConfig('gpt-4o', 'high', true)).toBeNull() + }) +}) diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 27d84febb1d..8dbe657ef50 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -3,8 +3,14 @@ import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { BlockTokens, IterationToolCall, StreamingExecution } from '@/executor/types' +import type { + BlockTokens, + IterationToolCall, + NormalizedBlockOutput, + StreamingExecution, +} from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' import { checkForForcedToolUsage, createReadableStreamFromAnthropicStream, @@ -45,12 +51,12 @@ export interface AnthropicProviderConfig { /** * Custom payload type extending the SDK's base message creation params. - * Adds fields not yet in the SDK: adaptive thinking, output_format, output_config. + * Message params plus `output_format`: Sim's structured outputs ride the + * anthropic-beta header with a top-level `output_format` field, which the SDK + * does not model (it exposes the newer `output_config.format` shape instead). */ -interface AnthropicPayload extends Omit { - thinking?: Anthropic.Messages.ThinkingConfigParam | { type: 'adaptive' } +interface AnthropicPayload extends Anthropic.Messages.MessageStreamParams { output_format?: { type: 'json_schema'; schema: Record } - output_config?: { effort: string } } /** @@ -119,14 +125,21 @@ function supportsAdaptiveThinking(modelId: string): boolean { * - Opus 4.6, Sonnet 4.6: Uses adaptive thinking with effort parameter * - Other models: Uses budget_tokens-based extended thinking * + * The newest Claude generations default `thinking.display` to `omitted` + * (empty thinking blocks, no thinking deltas). Their registry entries mark + * `capabilities.thinking.streamed: 'summary'`, and for those models Sim opts + * back in with `display: 'summarized'` — but only on agent-events runs, so + * legacy runs keep the exact pre-agent-events request shape. + * * Returns both the thinking config and optional output_config for adaptive thinking. */ -function buildThinkingConfig( +export function buildThinkingConfig( modelId: string, - thinkingLevel: string + thinkingLevel: string, + agentEvents: boolean ): { - thinking: { type: 'enabled'; budget_tokens: number } | { type: 'adaptive' } - outputConfig?: { effort: string } + thinking: Anthropic.Messages.ThinkingConfigParam + outputConfig?: Anthropic.Messages.OutputConfig } | null { const capability = getThinkingCapability(modelId) if (!capability || !capability.levels.includes(thinkingLevel)) { @@ -135,9 +148,14 @@ function buildThinkingConfig( // Models with effort support use adaptive thinking if (supportsAdaptiveThinking(modelId)) { + const requestSummarizedDisplay = agentEvents && capability.streamed === 'summary' return { - thinking: { type: 'adaptive' }, - outputConfig: { effort: thinkingLevel }, + thinking: { + type: 'adaptive', + ...(requestSummarizedDisplay ? { display: 'summarized' as const } : {}), + }, + // Levels are validated against the model's capability list above. + outputConfig: { effort: thinkingLevel as Anthropic.Messages.OutputConfig['effort'] }, } } @@ -337,7 +355,11 @@ export async function executeAnthropicProviderRequest( // Add extended thinking configuration if supported and requested // The 'none' sentinel means "disable thinking" — skip configuration entirely. if (request.thinkingLevel && request.thinkingLevel !== 'none') { - const thinkingConfig = buildThinkingConfig(request.model, request.thinkingLevel) + const thinkingConfig = buildThinkingConfig( + request.model, + request.thinkingLevel, + request.agentEvents === true + ) if (thinkingConfig) { payload.thinking = thinkingConfig.thinking if (thinkingConfig.outputConfig) { @@ -403,6 +425,56 @@ export async function executeAnthropicProviderRequest( const shouldStreamToolCalls = request.streamToolCalls ?? false + if (request.stream && shouldStreamToolCalls && anthropicTools && anthropicTools.length > 0) { + logger.info(`Using streaming tool loop for ${providerLabel} request`) + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createAnthropicStreamingToolLoopStream({ + anthropic, + payload, + request, + messages, + logger, + timeSegments, + forcedTools, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + if (request.stream && (!anthropicTools || anthropicTools.length === 0)) { logger.info(`Using streaming response for ${providerLabel} request (no tools)`) @@ -425,10 +497,11 @@ export async function executeAnthropicProviderRequest( initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { total: 0.0, input: 0.0, output: 0.0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, - (content, usage) => { + ({ content, usage, thinking }) => { output.content = content output.tokens = { input: usage.input_tokens, @@ -443,6 +516,13 @@ export async function executeAnthropicProviderRequest( total: costResult.total, } + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } + finalizeTiming() } ), @@ -805,10 +885,11 @@ export async function executeAnthropicProviderRequest( }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, - (streamContent, usage) => { + ({ content: streamContent, usage, thinking }) => { output.content = streamContent output.tokens = { input: tokens.input + usage.input_tokens, @@ -829,6 +910,16 @@ export async function executeAnthropicProviderRequest( total: accumulatedCost.total + streamCost.total + tc, } + if (thinking) { + const segments = output.providerTiming?.timeSegments + const lastModel = segments + ? [...segments].reverse().find((segment) => segment.type === 'model') + : undefined + if (lastModel) { + lastModel.thinkingContent = thinking + } + } + finalizeTiming() } ), @@ -1228,10 +1319,11 @@ export async function executeAnthropicProviderRequest( }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, - (streamContent, usage) => { + ({ content: streamContent, usage, thinking }) => { output.content = streamContent output.tokens = { input: tokens.input + usage.input_tokens, @@ -1252,6 +1344,16 @@ export async function executeAnthropicProviderRequest( total: cost.total + streamCost.total + tc2, } + if (thinking) { + const segments = output.providerTiming?.timeSegments + const lastModel = segments + ? [...segments].reverse().find((segment) => segment.type === 'model') + : undefined + if (lastModel) { + lastModel.thinkingContent = thinking + } + } + finalizeTiming() } ), diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..311b1942cdb --- /dev/null +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -0,0 +1,341 @@ +/** + * @vitest-environment node + * + * Anthropic streaming tool loop — live tool_call_start/end, live `pending` + * text classified by turn_end, abort → cancelled, per-turn usage accumulation. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' +import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { TimeSegment } from '@/providers/types' + +const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), + mockPrepareToolExecution: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers/utils', () => ({ + prepareToolExecution: mockPrepareToolExecution, + calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }), + sumToolCosts: () => 0, + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), +})) + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +function makeFinalMessage(overrides: { + content: unknown[] + usage?: { input_tokens: number; output_tokens: number } + stop_reason?: string | null +}) { + return { + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: overrides.content, + stop_reason: overrides.stop_reason ?? null, + stop_sequence: null, + usage: overrides.usage ?? { input_tokens: 10, output_tokens: 20 }, + } +} + +function makeMessageStream(events: unknown[], finalMessage: ReturnType) { + return { + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event + } + }, + finalMessage: async () => finalMessage, + } +} + +describe('createAnthropicStreamingToolLoopStream', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + beforeEach(() => { + vi.clearAllMocks() + mockPrepareToolExecution.mockReturnValue({ + toolParams: { city: 'San Francisco' }, + executionParams: { city: 'San Francisco' }, + }) + mockExecuteTool.mockResolvedValue({ + success: true, + output: { temp: 68 }, + }) + }) + + it('emits tool_call_start/end, live pending text with turn_end classification, and accumulates usage', async () => { + const toolTurnEvents = anthropicThinkingTextToolStreamEvents + const toolTurnMessage = makeFinalMessage({ + content: [ + { + type: 'thinking', + thinking: anthropicThinkingTextToolExpectedThinking, + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + { type: 'text', text: 'Let me check the weather in San Francisco.' }, + { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: { city: 'San Francisco' }, + }, + ], + usage: { input_tokens: 42, output_tokens: 30 }, + stop_reason: 'tool_use', + }) + + const finalTurnEvents = [ + { + type: 'message_start', + message: { + usage: { input_tokens: 100, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'It is 68°F in San Francisco.' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 12 }, + }, + { type: 'message_stop' }, + ] + const finalTurnMessage = makeFinalMessage({ + content: [{ type: 'text', text: 'It is 68°F in San Francisco.' }], + usage: { input_tokens: 100, output_tokens: 12 }, + stop_reason: 'end_turn', + }) + + let streamCall = 0 + const anthropic = { + messages: { + stream: vi.fn(() => { + streamCall++ + if (streamCall === 1) { + return makeMessageStream(toolTurnEvents as unknown[], toolTurnMessage) + } + return makeMessageStream(finalTurnEvents, finalTurnMessage) + }), + }, + } as any + + const timeSegments: TimeSegment[] = [] + const onComplete = vi.fn() + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'Weather?' }], + tools: [ + { + name: 'get_weather', + description: 'Get weather', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'Weather?' }], + logger, + timeSegments, + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').length).toBeGreaterThan(0) + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + }) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + status: 'success', + }) + + // All text streams live as `pending`; the pump classifies via turn_end. + const textEvents = events.filter((e) => e.type === 'text_delta') + expect(textEvents.every((e) => e.turn === 'pending')).toBe(true) + expect(textEvents.some((e) => e.text.includes('Let me check'))).toBe(true) + expect(textEvents.some((e) => e.text.includes('68°F'))).toBe(true) + + const turnEnds = events.filter((e) => e.type === 'turn_end') + expect(turnEnds.map((e) => e.turn)).toEqual(['intermediate', 'final']) + + // Ordering: the tool turn's pending text precedes its intermediate turn_end, + // and the final turn's text precedes the final turn_end. + const eventKinds = events.map((e) => + e.type === 'turn_end' ? `turn_end:${e.turn}` : e.type === 'text_delta' ? 'text' : e.type + ) + expect(eventKinds.indexOf('text')).toBeLessThan(eventKinds.indexOf('turn_end:intermediate')) + expect(eventKinds.lastIndexOf('text')).toBeLessThan(eventKinds.indexOf('turn_end:final')) + + // Assistant history must keep thinking signature for multi-iteration round-trip. + const secondPayload = anthropic.messages.stream.mock.calls[1][0] + const assistantMsg = secondPayload.messages.find((m: any) => m.role === 'assistant') + expect(assistantMsg.content.some((b: any) => b.type === 'thinking' && b.signature)).toBe(true) + expect(assistantMsg.content.some((b: any) => b.type === 'tool_use')).toBe(true) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete.mock.calls[0][0].tokens).toEqual({ + input: 142, + output: 42, + total: 184, + }) + expect(onComplete.mock.calls[0][0].content).toContain('68°F') + expect(mockExecuteTool).toHaveBeenCalled() + }) + + it('settles in-flight tools as cancelled on abort', async () => { + const abortController = new AbortController() + const toolStartEvents = [ + { + type: 'message_start', + message: { usage: { input_tokens: 5, output_tokens: 0 } }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{}' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { output_tokens: 3 }, + }, + { type: 'message_stop' }, + ] + + mockExecuteTool.mockImplementation(async () => { + abortController.abort() + throw new DOMException('Stream aborted', 'AbortError') + }) + + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + toolStartEvents, + makeFinalMessage({ + content: [ + { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + ], + stop_reason: 'tool_use', + }) + ) + ), + }, + } as any + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + abortSignal: abortController.signal, + } as any, + messages: [{ role: 'user', content: 'x' }], + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch { + // expected — stream errors after abort settlement + } + + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_abort', + name: 'get_weather', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_abort', + name: 'get_weather', + status: 'cancelled', + }) + }) +}) diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.ts b/apps/sim/providers/anthropic/streaming-tool-loop.ts new file mode 100644 index 00000000000..6b604732c38 --- /dev/null +++ b/apps/sim/providers/anthropic/streaming-tool-loop.ts @@ -0,0 +1,544 @@ +/** + * Live Anthropic streaming tool loop. + * + * Each model turn is streamed via `messages.stream` + `finalMessage()` so thinking + * signatures round-trip correctly. Thinking, `tool_call_start`, and `pending` + * text deltas emit live; a `turn_end` event classifies the turn as + * `intermediate` (tool-use turns) or `final` so the pump projects only final + * text to the answer channel. Tool ends emit in actual completion order; abort + * settles in-flight tools as `cancelled`. + */ + +import type Anthropic from '@anthropic-ai/sdk' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { BlockTokens, IterationToolCall } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { checkForForcedToolUsage } from '@/providers/anthropic/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + type StreamingToolLoopComplete, + settleOpenTools, +} from '@/providers/streaming-tool-loop-shared' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { calculateCost, prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { executeTool } from '@/tools' + +/** + * Message params plus `output_format`, shared with `core.ts`: Sim's structured + * outputs ride the anthropic-beta header with a top-level `output_format` + * field, which the SDK does not model (it exposes `output_config.format`). + */ +export type AnthropicStreamingToolLoopPayload = Anthropic.Messages.MessageStreamParams & { + output_format?: { type: 'json_schema'; schema: Record } +} + +export interface CreateAnthropicStreamingToolLoopStreamOptions { + anthropic: Anthropic + payload: AnthropicStreamingToolLoopPayload + request: ProviderRequest + messages: Anthropic.Messages.MessageParam[] + logger: Logger + /** Shared mutable segments; same array reference passed into createStreamingExecution. */ + timeSegments: TimeSegment[] + /** Forced tool names from prepareToolsWithUsageControl (may be empty). */ + forcedTools?: string[] + onComplete: (result: StreamingToolLoopComplete) => void +} + +function buildSegmentTokens(usage: Anthropic.Messages.Usage): BlockTokens { + const input = usage.input_tokens ?? 0 + const output = usage.output_tokens ?? 0 + const cacheRead = usage.cache_read_input_tokens ?? 0 + const cacheWrite = usage.cache_creation_input_tokens ?? 0 + return { + input, + output, + total: input + output + cacheRead + cacheWrite, + ...(cacheRead > 0 && { cacheRead }), + ...(cacheWrite > 0 && { cacheWrite }), + } +} + +function enrichModelSegment( + timeSegments: TimeSegment[], + response: Anthropic.Messages.Message, + textContent: string, + model: string +): void { + const thinkingBlocks = response.content.filter( + (item): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => + item.type === 'thinking' || item.type === 'redacted_thinking' + ) + const thinkingContent = thinkingBlocks + .map((b) => (b.type === 'thinking' ? b.thinking : '[redacted]')) + .join('\n\n') + + const toolUseBlocks = response.content.filter( + (item): item is Anthropic.Messages.ToolUseBlock => item.type === 'tool_use' + ) + const toolCalls: IterationToolCall[] = toolUseBlocks.map((t) => ({ + id: t.id, + name: t.name, + arguments: + t.input && typeof t.input === 'object' && !Array.isArray(t.input) + ? (t.input as Record) + : {}, + })) + + const segmentTokens = response.usage ? buildSegmentTokens(response.usage) : undefined + let cost: { input: number; output: number; total: number } | undefined + if ( + segmentTokens && + typeof segmentTokens.input === 'number' && + typeof segmentTokens.output === 'number' + ) { + const useCached = (segmentTokens.cacheRead ?? 0) > 0 + const full = calculateCost(model, segmentTokens.input, segmentTokens.output, useCached) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: textContent || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: response.stop_reason ?? undefined, + tokens: segmentTokens, + cost, + provider: 'anthropic', + }) +} + +/** + * Multi-turn Anthropic tool loop as an `agent-events-v1` object stream. + */ +export function createAnthropicStreamingToolLoopStream( + options: CreateAnthropicStreamingToolLoopStreamOptions +): ReadableStream { + const { anthropic, payload, request, messages, logger, timeSegments, onComplete } = options + const forcedToolNames = options.forcedTools ?? [] + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...messages] + const originalToolChoice = payload.tool_choice + + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + /** Tools that received start but not yet end (abort settlement). */ + const openToolStarts = new Map() + + const streamOptions = request.abortSignal ? { signal: request.abortSignal } : undefined + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (request.abortSignal?.aborted) { + const abortErr = new DOMException('Stream aborted', 'AbortError') + settleOpenTools(controller, openToolStarts, 'cancelled') + throw abortErr + } + + const turnPayload: AnthropicStreamingToolLoopPayload = { + ...payload, + messages: currentMessages, + } + // Streaming tool loop always streams each turn; never pass stream:true twice. + ;(turnPayload as { stream?: boolean }).stream = undefined + + // Forced tool_choice vs thinking — same rules as silent loop. + const thinkingEnabled = !!payload.thinking + if ( + !thinkingEnabled && + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedToolNames.length > 0 + ) { + const remainingTools = forcedToolNames.filter((tool) => !usedForcedTools.includes(tool)) + if (remainingTools.length > 0) { + turnPayload.tool_choice = { type: 'tool', name: remainingTools[0] } + } else { + turnPayload.tool_choice = undefined + } + } else if ( + !thinkingEnabled && + hasUsedForcedTool && + typeof originalToolChoice === 'object' + ) { + turnPayload.tool_choice = undefined + } + + const modelStart = Date.now() + const messageStream = anthropic.messages.stream(turnPayload, streamOptions) + + const textChunks: string[] = [] + let inputTokens = 0 + let outputTokens = 0 + + try { + for await (const event of messageStream) { + if (event.type === 'message_start') { + inputTokens = event.message.usage?.input_tokens ?? 0 + continue + } + if (event.type === 'message_delta') { + outputTokens = event.usage?.output_tokens ?? outputTokens + continue + } + if (event.type === 'content_block_start') { + const block = event.content_block + if (block.type === 'tool_use' && block.id && block.name) { + openToolStarts.set(block.id, block.name) + controller.enqueue({ + type: 'tool_call_start', + id: block.id, + name: block.name, + }) + } + continue + } + if (event.type !== 'content_block_delta') { + continue + } + const delta = event.delta + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + controller.enqueue({ type: 'thinking_delta', text: delta.thinking }) + continue + } + if (delta.type === 'text_delta' && typeof delta.text === 'string') { + textChunks.push(delta.text) + // Live pending text: sinks render it now; the pump projects it + // to the answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'pending' }) + } + } + + const finalMessage = await messageStream.finalMessage() + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + // Prefer finalMessage.usage when present (includes cache fields). + const turnInput = finalMessage.usage?.input_tokens ?? inputTokens + const turnOutput = finalMessage.usage?.output_tokens ?? outputTokens + tokens.input += turnInput + tokens.output += turnOutput + tokens.total += turnInput + turnOutput + + const textContent = finalMessage.content + .filter((item): item is Anthropic.Messages.TextBlock => item.type === 'text') + .map((item) => item.text) + .join('\n') + + const toolUses = finalMessage.content.filter( + (item): item is Anthropic.Messages.ToolUseBlock => item.type === 'tool_use' + ) + /** + * Only execute tools when the model actually stopped to call them. + * On `max_tokens` / `malformed_tool_use` the assembled inputs are + * truncated best-effort JSON — running tools on them would execute + * with wrong or partial arguments. + */ + const toolsExecutable = finalMessage.stop_reason === 'tool_use' + if (toolUses.length > 0 && !toolsExecutable) { + logger.warn('Skipping tool execution for incomplete turn', { + stopReason: finalMessage.stop_reason, + toolCount: toolUses.length, + }) + settleOpenTools(controller, openToolStarts, 'error') + } + const executableToolUses = toolsExecutable ? toolUses : [] + + const turnTag = executableToolUses.length > 0 ? 'intermediate' : 'final' + // If the SDK assembled text but we somehow missed deltas, still emit it + // before the boundary so the turn_end classification covers it. + if (textChunks.length === 0 && textContent) { + controller.enqueue({ type: 'text_delta', text: textContent, turn: 'pending' }) + } + controller.enqueue({ type: 'turn_end', turn: turnTag }) + if (textChunks.length > 0 || textContent) { + // Streamed deltas are the answer bytes; fall back to assembled text. + // Intermediate text is kept so a MAX_TOOL_ITERATIONS exit still has content. + content = textChunks.length > 0 ? textChunks.join('') : textContent + } + + enrichModelSegment(timeSegments, finalMessage, textContent, request.model) + + const forcedCheck = checkForForcedToolUsage( + finalMessage, + turnPayload.tool_choice, + forcedToolNames, + usedForcedTools + ) + if (forcedCheck) { + hasUsedForcedTool = forcedCheck.hasUsedForcedTool + usedForcedTools = forcedCheck.usedForcedTools + } + + if (executableToolUses.length === 0) { + sawFinalTurn = true + break + } + + const toolsStartTime = Date.now() + + // Emit ends in completion order; keep Promise.all result order (= start order) for history. + const orderedResults = await Promise.all( + executableToolUses.map(async (toolUse) => { + const toolCallStartTime = Date.now() + const toolName = toolUse.name + const toolArgs = (toolUse.input ?? {}) as Record + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUse, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + const value = { + toolUse, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (result.success ? 'success' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: value.status, + }) + return value + } catch (error) { + const toolCallEndTime = Date.now() + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + if (!cancelled) { + logger.error('Error processing tool call:', { error, toolName }) + } + const value = { + toolUse, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (cancelled ? 'cancelled' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: value.status, + }) + return value + } + }) + ) + + const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] + const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] + + for (const value of orderedResults) { + const { + toolUse, + toolName, + toolArgs, + toolParams, + result, + startTime, + endTime, + duration, + } = value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime, + endTime, + duration, + toolCallId: toolUse.id, + }) + + let resultContent: unknown + if (result.success && result.output) { + toolResults.push(result.output as Record) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration, + result: resultContent, + success: result.success, + }) + + toolUseBlocks.push({ + type: 'tool_use', + id: toolUse.id, + name: toolName, + input: toolArgs, + }) + + toolResultBlocks.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: JSON.stringify(resultContent), + }) + } + + const thinkingBlocks = finalMessage.content.filter( + ( + item + ): item is + | Anthropic.Messages.ThinkingBlock + | Anthropic.Messages.RedactedThinkingBlock => + item.type === 'thinking' || item.type === 'redacted_thinking' + ) + + if (toolUseBlocks.length > 0) { + currentMessages.push({ + role: 'assistant', + content: [ + ...thinkingBlocks, + ...toolUseBlocks, + ] as Anthropic.Messages.ContentBlockParam[], + }) + } + if (toolResultBlocks.length > 0) { + currentMessages.push({ + role: 'user', + content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[], + }) + } + + toolsTime += Date.now() - toolsStartTime + iterationCount++ + + if (request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + } catch (error) { + settleOpenTools(controller, openToolStarts, isAbortError(error) ? 'cancelled' : 'error') + throw error + } + } + + /** + * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the + * answer channel would otherwise be empty. Flush the last turn's text + * as the final answer so legacy consumers still receive content. + */ + if (!sawFinalTurn && content) { + controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + } + + const modelCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCostTotal = sumToolCosts(toolResults) + const cost = { + input: modelCost.input, + output: modelCost.output, + total: modelCost.total + (toolCostTotal || 0), + ...(toolCostTotal ? { toolCost: toolCostTotal } : {}), + } + + onComplete({ + content, + tokens, + cost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + + controller.close() + } catch (error) { + const cancelled = isAbortError(error) + settleOpenTools(controller, openToolStarts, cancelled ? 'cancelled' : 'error') + controller.error(toError(error)) + } + }, + }) +} diff --git a/apps/sim/providers/anthropic/utils.test.ts b/apps/sim/providers/anthropic/utils.test.ts new file mode 100644 index 00000000000..7d2fb8d4e4e --- /dev/null +++ b/apps/sim/providers/anthropic/utils.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + * + * Anthropic adapter emits AgentStreamEvent objects (thinking + text) + * from Messages stream fixtures; tool_use deltas are handled by the tool loop. + */ +import { describe, expect, it, vi } from 'vitest' +import { + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' +import { createReadableStreamFromAnthropicStream } from '@/providers/anthropic/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromAnthropicStream', () => { + it('emits thinking_delta then text_delta and ignores tool_use (thinking+text+tool fixture)', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicThinkingTextToolStreamEvents + })() as AsyncIterable, + onComplete + ) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should check the weather before answering. ', + 'Calling get_weather for SF.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { + type: 'text_delta', + text: anthropicThinkingTextToolExpectedText, + turn: 'final', + }, + ]) + expect(events.some((e) => e.type === 'tool_call_start' || e.type === 'tool_call_end')).toBe( + false + ) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: anthropicThinkingTextToolExpectedText, + thinking: anthropicThinkingTextToolExpectedThinking, + usage: { input_tokens: 42, output_tokens: expect.any(Number) }, + }) + }) + + it('records [redacted] for redacted_thinking blocks and streams text', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicRedactedThinkingStreamEvents + })() as AsyncIterable, + onComplete + ) + + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'Visible follow-up reasoning after redaction.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { + type: 'text_delta', + text: anthropicRedactedThinkingExpectedText, + turn: 'final', + }, + ]) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: anthropicRedactedThinkingExpectedText, + thinking: anthropicRedactedThinkingExpectedTraceThinking, + }) + }) + + it('errors the readable stream when the source throws', async () => { + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'partial' }, + } + throw new Error('provider reset') + })() as AsyncIterable + ) + + await expect(collectEvents(stream)).rejects.toThrow('provider reset') + }) +}) diff --git a/apps/sim/providers/anthropic/utils.ts b/apps/sim/providers/anthropic/utils.ts index b9b001bb7a2..c9fcb89ac90 100644 --- a/apps/sim/providers/anthropic/utils.ts +++ b/apps/sim/providers/anthropic/utils.ts @@ -1,11 +1,7 @@ -import type { - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStreamEvent, - Usage, -} from '@anthropic-ai/sdk/resources' +import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' import { createLogger } from '@sim/logger' import { randomFloat } from '@sim/utils/random' +import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' const logger = createLogger('AnthropicUtils') @@ -15,34 +11,93 @@ export interface AnthropicStreamUsage { output_tokens: number } +export interface AnthropicStreamComplete { + content: string + usage: AnthropicStreamUsage + /** Assembled thinking text for traces (redacted blocks become `[redacted]`). */ + thinking: string +} + +/** + * Converts an Anthropic Messages stream into an in-process + * {@link AgentStreamEvent} object stream (`thinking_delta` + `text_delta`). + * Tool_use / input_json deltas are ignored here — use + * {@link createAnthropicStreamingToolLoopStream} for the live tool loop. + */ export function createReadableStreamFromAnthropicStream( anthropicStream: AsyncIterable, - onComplete?: (content: string, usage: AnthropicStreamUsage) => void -): ReadableStream { - let fullContent = '' - let inputTokens = 0 - let outputTokens = 0 - - return new ReadableStream({ + onComplete?: (result: AnthropicStreamComplete) => void +): ReadableStream { + return new ReadableStream({ async start(controller) { try { + let fullContent = '' + const thinkingBlocks: string[] = [] + let currentThinking = '' + let inputTokens = 0 + let outputTokens = 0 + + const flushThinkingBlock = () => { + if (currentThinking) { + thinkingBlocks.push(currentThinking) + currentThinking = '' + } + } + for await (const event of anthropicStream) { if (event.type === 'message_start') { - const startEvent = event as RawMessageStartEvent - const usage: Usage = startEvent.message.usage - inputTokens = usage.input_tokens - } else if (event.type === 'message_delta') { - const deltaEvent = event as RawMessageDeltaEvent - outputTokens = deltaEvent.usage.output_tokens - } else if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { - const text = event.delta.text - fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + inputTokens = event.message.usage.input_tokens + continue + } + + if (event.type === 'message_delta') { + outputTokens = event.usage.output_tokens + continue + } + + if (event.type === 'content_block_start') { + if (event.content_block.type === 'redacted_thinking') { + flushThinkingBlock() + thinkingBlocks.push('[redacted]') + } else if (event.content_block.type === 'thinking') { + flushThinkingBlock() + } + continue + } + + if (event.type === 'content_block_stop') { + flushThinkingBlock() + continue + } + + if (event.type !== 'content_block_delta') { + continue + } + + const delta = event.delta + + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + currentThinking += delta.thinking + controller.enqueue({ type: 'thinking_delta', text: delta.thinking }) + continue + } + + if (delta.type === 'text_delta' && typeof delta.text === 'string') { + flushThinkingBlock() + fullContent += delta.text + controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'final' }) } } + flushThinkingBlock() + if (onComplete) { - onComplete(fullContent, { input_tokens: inputTokens, output_tokens: outputTokens }) + onComplete({ + content: fullContent, + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + // Match enrichLastModelSegmentFromAnthropicResponse: join blocks with blank lines. + thinking: thinkingBlocks.filter(Boolean).join('\n\n'), + }) } controller.close() diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index e9c7cfefb4b..053d375a9c2 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -201,6 +201,7 @@ async function executeChatCompletionsRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -517,6 +518,7 @@ async function executeChatCompletionsRequest( count: toolCalls.length, } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/azure-openai/utils.ts b/apps/sim/providers/azure-openai/utils.ts index fec1e862e51..08a84c0209d 100644 --- a/apps/sim/providers/azure-openai/utils.ts +++ b/apps/sim/providers/azure-openai/utils.ts @@ -3,17 +3,24 @@ import type OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' import type { Stream } from 'openai/streaming' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from an Azure OpenAI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Azure OpenAI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromAzureOpenAIStream( azureOpenAIStream: Stream, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(azureOpenAIStream, 'Azure OpenAI', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(azureOpenAIStream, { + providerName: 'Azure OpenAI', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index a25fb29cb9c..a5f59d818bb 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -167,6 +167,7 @@ export const basetenProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -469,6 +470,7 @@ export const basetenProvider: ProviderConfig = { }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/baseten/utils.ts b/apps/sim/providers/baseten/utils.ts index ca5cf5dc5c0..d277f41a2c9 100644 --- a/apps/sim/providers/baseten/utils.ts +++ b/apps/sim/providers/baseten/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Checks if a model supports native structured outputs (json_schema). @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Baseten streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Baseten streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Baseten', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Baseten', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 4e512d15b48..15ae2bdaa70 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -15,9 +15,10 @@ import { } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { buildBedrockMessageContent } from '@/providers/attachments' +import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import { checkForForcedToolUsage, createReadableStreamFromBedrockStream, @@ -347,7 +348,66 @@ export const bedrockProvider: ProviderConfig = { inferenceConfig.maxTokens = Number.parseInt(String(request.maxTokens)) } - const shouldStreamToolCalls = request.streamToolCalls ?? false + /** + * The live tool loop cannot honor responseFormat — structured output on + * Bedrock rides a final forced `structured_output` tool call that only the + * silent loop performs — so those requests fall back to the silent path. + */ + const shouldStreamToolCalls = (request.streamToolCalls ?? false) && !request.responseFormat + + if (request.stream && shouldStreamToolCalls && bedrockTools && bedrockTools.length > 0) { + logger.info('Using streaming tool loop for Bedrock request') + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createBedrockStreamingToolLoopStream({ + client, + modelId: bedrockModelId, + request, + messages, + system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, + inferenceConfig, + bedrockTools, + toolChoice, + logger, + timeSegments, + forcedTools, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } if (request.stream && (!bedrockTools || bedrockTools.length === 0)) { logger.info('Using streaming response for Bedrock request (no tools)') @@ -380,6 +440,7 @@ export const bedrockProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { total: 0.0, input: 0.0, output: 0.0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromBedrockStream(bedrockStream, (content, usage) => { output.content = content @@ -878,6 +939,7 @@ export const bedrockProvider: ProviderConfig = { toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromBedrockStream(bedrockStream, (streamContent, usage) => { output.content = streamContent diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..40c66c772e6 --- /dev/null +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +vi.mock('@/tools', () => ({ + executeTool: vi.fn(async () => ({ + success: true, + output: { ok: true }, + })), +})) + +vi.mock('@/providers/utils', () => ({ + prepareToolExecution: vi.fn(() => ({ + toolParams: { url: 'https://example.com' }, + executionParams: { url: 'https://example.com' }, + })), + calculateCost: vi.fn(() => ({ + input: 0.01, + output: 0.02, + total: 0.03, + pricing: { input: 1, output: 2, updatedAt: new Date().toISOString() }, + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), +})) + +describe('createBedrockStreamingToolLoopStream', () => { + it('emits tool_call_start/end and final text; no invented thinking', async () => { + const turns = [ + (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":"https://example.com"}' } }, + }, + } + yield { + metadata: { usage: { inputTokens: 11, outputTokens: 4 } }, + } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'Request completed.' }, + }, + } + yield { + metadata: { usage: { inputTokens: 22, outputTokens: 6 } }, + } + yield { messageStop: { stopReason: 'end_turn' } } + })(), + ] + + let turnIdx = 0 + const client = { + send: vi.fn(async () => ({ stream: turns[turnIdx++] })), + } + + const onComplete = vi.fn() + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'call it' }] }], + inferenceConfig: { temperature: 0.7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect(events.filter((e) => e.type === 'tool_call_start')).toEqual([ + { type: 'tool_call_start', id: 'tooluse_1', name: 'http_request' }, + ]) + expect(events.filter((e) => e.type === 'tool_call_end')).toEqual([ + { type: 'tool_call_end', id: 'tooluse_1', name: 'http_request', status: 'success' }, + ]) + // Text streams live as `pending`; the turn_end sequence classifies turns. + expect( + events + .filter((e) => e.type === 'text_delta' && e.turn === 'pending') + .map((e) => e.text) + .join('') + ).toBe('Request completed.') + expect(events.filter((e) => e.type === 'turn_end').map((e) => e.turn)).toEqual([ + 'intermediate', + 'final', + ]) + + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Request completed.', + toolCalls: expect.objectContaining({ count: 1 }), + }) + ) + }) +}) diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts new file mode 100644 index 00000000000..5d9c9cff2ee --- /dev/null +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -0,0 +1,535 @@ +/** + * Live Bedrock ConverseStream tool loop. + * + * Capability-honest: text + tool_call_start/end only — Sim does not request + * Bedrock reasoning, so no thinking is invented. Text emits live as `pending` + * deltas and a `turn_end` event classifies each turn, so the pump projects + * only final-turn text to the answer channel. Abort → cancelled. + */ + +import { + type Message as BedrockMessage, + type BedrockRuntimeClient, + type ContentBlock, + type ConversationRole, + ConverseStreamCommand, + type SystemContentBlock, + type Tool, + type ToolConfiguration, + type ToolResultBlock, + type ToolUseBlock, +} from '@aws-sdk/client-bedrock-runtime' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { checkForForcedToolUsage, generateToolUseId } from '@/providers/bedrock/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + type StreamingToolLoopComplete, + settleOpenTools, +} from '@/providers/streaming-tool-loop-shared' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { calculateCost, prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { executeTool } from '@/tools' + +export interface CreateBedrockStreamingToolLoopStreamOptions { + client: BedrockRuntimeClient + modelId: string + request: ProviderRequest + messages: BedrockMessage[] + system?: SystemContentBlock[] + inferenceConfig: { temperature: number; maxTokens?: number } + bedrockTools: Tool[] + toolChoice: ToolConfiguration['toolChoice'] + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + onComplete: (result: StreamingToolLoopComplete) => void +} + +interface AssembledToolUse { + toolUseId: string + name: string + inputJson: string +} + +type ToolUseInput = NonNullable + +function parseToolInput(inputJson: string): Record { + if (!inputJson.trim()) return {} + try { + const parsed = JSON.parse(inputJson) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } +} + +async function drainBedrockTurn( + stream: AsyncIterable, + controller: ReadableStreamDefaultController, + openTools: Map +): Promise<{ + text: string + toolUses: AssembledToolUse[] + inputTokens: number + outputTokens: number + stopReason?: string +}> { + let text = '' + const toolsByIndex = new Map() + let currentIndex: number | undefined + let inputTokens = 0 + let outputTokens = 0 + let stopReason: string | undefined + + for await (const event of stream) { + if (event.contentBlockStart) { + currentIndex = event.contentBlockStart.contentBlockIndex + const start = event.contentBlockStart.start + if (start && 'toolUse' in start && start.toolUse) { + const id = start.toolUse.toolUseId || generateToolUseId(start.toolUse.name || 'tool') + const name = start.toolUse.name || '' + if (typeof currentIndex === 'number') { + toolsByIndex.set(currentIndex, { toolUseId: id, name, inputJson: '' }) + } + if (id && name && !openTools.has(id)) { + openTools.set(id, name) + controller.enqueue({ type: 'tool_call_start', id, name }) + } + } + continue + } + + if (event.contentBlockDelta) { + const idx = event.contentBlockDelta.contentBlockIndex ?? currentIndex + const delta = event.contentBlockDelta.delta + if (delta?.text) { + text += delta.text + // Live pending text: sinks render it now; the pump projects it to the + // answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'pending' }) + } + if (delta && 'toolUse' in delta && delta.toolUse?.input && typeof idx === 'number') { + const pending = toolsByIndex.get(idx) + if (pending) { + pending.inputJson += delta.toolUse.input + } + } + continue + } + + if (event.metadata?.usage) { + inputTokens = event.metadata.usage.inputTokens ?? inputTokens + outputTokens = event.metadata.usage.outputTokens ?? outputTokens + continue + } + + if (event.messageStop?.stopReason) { + stopReason = event.messageStop.stopReason + } + } + + return { + text, + toolUses: [...toolsByIndex.values()], + inputTokens, + outputTokens, + stopReason, + } +} + +/** + * Multi-turn Bedrock ConverseStream tool loop as agent-events-v1. + */ +export function createBedrockStreamingToolLoopStream( + options: CreateBedrockStreamingToolLoopStreamOptions +): ReadableStream { + const { + client, + modelId, + request, + messages: initialMessages, + system, + inferenceConfig, + bedrockTools, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + const originalToolChoice = options.toolChoice + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...initialMessages] + let toolChoice = originalToolChoice + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + let costInput = 0 + let costOutput = 0 + let costTotal = 0 + let latestPricing: ReturnType['pricing'] | undefined + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const toolConfig: ToolConfiguration | undefined = bedrockTools.length + ? { tools: bedrockTools, toolChoice } + : undefined + + const modelStart = Date.now() + const command = new ConverseStreamCommand({ + modelId, + messages: currentMessages, + system: system && system.length > 0 ? system : undefined, + inferenceConfig, + toolConfig, + }) + + const streamResponse = await client.send( + command, + request.abortSignal ? { abortSignal: request.abortSignal } : undefined + ) + if (!streamResponse.stream) { + throw new Error('No stream returned from Bedrock') + } + + const drained = await drainBedrockTurn(streamResponse.stream, controller, openToolStarts) + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + tokens.input += drained.inputTokens + tokens.output += drained.outputTokens + tokens.total += drained.inputTokens + drained.outputTokens + + const turnCost = calculateCost(request.model, drained.inputTokens, drained.outputTokens) + costInput += turnCost.input + costOutput += turnCost.output + costTotal += turnCost.total + latestPricing = turnCost.pricing + + /** + * Only execute tools when the model actually stopped to call them. + * On `max_tokens` / `malformed_tool_use` the accumulated input JSON + * is truncated and would parse to empty or partial arguments. + */ + const toolsExecutable = drained.stopReason === 'tool_use' + if (drained.toolUses.length > 0 && !toolsExecutable) { + logger.warn('Skipping tool execution for incomplete turn', { + stopReason: drained.stopReason, + toolCount: drained.toolUses.length, + }) + settleOpenTools(controller, openToolStarts, 'error') + } + const executableToolUses = toolsExecutable ? drained.toolUses : [] + + const turnTag = executableToolUses.length > 0 ? 'intermediate' : 'final' + controller.enqueue({ type: 'turn_end', turn: turnTag }) + if (drained.text) { + content = drained.text + } + + const assembledToolUses = executableToolUses.map((t) => ({ + toolUseId: t.toolUseId, + name: t.name, + input: parseToolInput(t.inputJson), + })) + + enrichLastModelSegment(timeSegments, { + assistantContent: drained.text || undefined, + toolCalls: + assembledToolUses.length > 0 + ? assembledToolUses.map((t) => ({ + id: t.toolUseId || '', + name: t.name || '', + arguments: t.input, + })) + : undefined, + finishReason: drained.stopReason, + tokens: { + input: drained.inputTokens, + output: drained.outputTokens, + total: drained.inputTokens + drained.outputTokens, + }, + cost: { + input: turnCost.input, + output: turnCost.output, + total: turnCost.total, + }, + provider: 'bedrock', + }) + + const forcedCheck = checkForForcedToolUsage( + assembledToolUses.map((t) => ({ name: t.name || '' })), + toolChoice, + forcedTools, + usedForcedTools + ) + if (forcedCheck) { + hasUsedForcedTool = forcedCheck.hasUsedForcedTool + usedForcedTools = forcedCheck.usedForcedTools + } + + if (assembledToolUses.length === 0) { + sawFinalTurn = true + break + } + + const toolsStartTime = Date.now() + const orderedResults = await Promise.all( + assembledToolUses.map(async (toolUse) => { + const toolCallStartTime = Date.now() + const toolName = toolUse.name || '' + const toolArgs: Record = toolUse.input + const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUse, + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + const status: ToolCallEndStatus = result.success ? 'success' : 'error' + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status, + }) + return { + toolUse, + toolUseId, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + } + } catch (error) { + const toolCallEndTime = Date.now() + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + if (!cancelled) { + logger.error('Error processing tool call:', { error, toolName }) + } + const status: ToolCallEndStatus = cancelled ? 'cancelled' : 'error' + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status, + }) + return { + toolUse, + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + } + } + }) + ) + + toolsTime += Date.now() - toolsStartTime + + const assistantContent: ContentBlock[] = assembledToolUses.map((toolUse) => ({ + toolUse: { + toolUseId: toolUse.toolUseId, + name: toolUse.name, + input: toolUse.input, + }, + })) + currentMessages.push({ + role: 'assistant' as ConversationRole, + content: assistantContent, + }) + + const toolResultContent: ContentBlock[] = [] + for (const value of orderedResults) { + const { toolUseId, toolName, toolParams, result, startTime, endTime, duration } = value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime, + endTime, + duration, + toolCallId: toolUseId, + }) + + let resultContent: unknown + if (result.success && result.output) { + toolResults.push(result.output as Record) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration, + result: resultContent, + success: result.success, + }) + + const toolResultBlock: ToolResultBlock = { + toolUseId, + content: [{ text: JSON.stringify(resultContent) }], + } + toolResultContent.push({ toolResult: toolResultBlock }) + } + + if (toolResultContent.length > 0) { + currentMessages.push({ + role: 'user' as ConversationRole, + content: toolResultContent, + }) + } + + if ( + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedTools.length > 0 + ) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + toolChoice = + remainingTools.length > 0 ? { tool: { name: remainingTools[0] } } : { auto: {} } + } else if (hasUsedForcedTool && typeof originalToolChoice === 'object') { + toolChoice = { auto: {} } + } + + iterationCount += 1 + } + + /** + * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the + * answer channel would otherwise be empty. Flush the last turn's text + * as the final answer so legacy consumers still receive content. + */ + if (!sawFinalTurn && content) { + controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + } + + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + controller.close() + } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + } else { + settleOpenTools(controller, openToolStarts, 'error') + logger.error('Bedrock streaming tool loop failed', { + error: toError(error).message, + }) + } + controller.error(error) + } + }, + }) +} diff --git a/apps/sim/providers/bedrock/utils.stream.test.ts b/apps/sim/providers/bedrock/utils.stream.test.ts new file mode 100644 index 00000000000..d9407b80018 --- /dev/null +++ b/apps/sim/providers/bedrock/utils.stream.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromBedrockStream } from '@/providers/bedrock/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromBedrockStream', () => { + it('emits text only — no tool events (never executed on this path) and no invented thinking', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromBedrockStream( + (async function* () { + yield { + contentBlockStart: { + start: { + toolUse: { toolUseId: 'tooluse_1', name: 'http_request' }, + }, + }, + } as any + yield { + contentBlockDelta: { delta: { text: 'Done' } }, + } as any + yield { + metadata: { usage: { inputTokens: 2, outputTokens: 3 } }, + } as any + })(), + onComplete + ) + + const events = await collectEvents(stream) + expect(events).toEqual([{ type: 'text_delta', text: 'Done', turn: 'final' }]) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect(events.some((e) => e.type === 'tool_call_start')).toBe(false) + expect(onComplete).toHaveBeenCalledWith('Done', { inputTokens: 2, outputTokens: 3 }) + }) +}) diff --git a/apps/sim/providers/bedrock/utils.ts b/apps/sim/providers/bedrock/utils.ts index 82919624d09..7cdd6b6c125 100644 --- a/apps/sim/providers/bedrock/utils.ts +++ b/apps/sim/providers/bedrock/utils.ts @@ -1,6 +1,7 @@ import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { randomFloat } from '@sim/utils/random' +import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' const logger = createLogger('BedrockUtils') @@ -10,10 +11,17 @@ export interface BedrockStreamUsage { outputTokens: number } +/** + * Bedrock ConverseStream → agent-events-v1 for the legacy (non-tool-loop) + * streaming path. Text deltas only: tools on this path are never executed, so + * emitting `tool_call_start` here would leave a chip running forever with no + * matching end. Sim does not request Bedrock reasoning, so there is no + * thinking to forward either. + */ export function createReadableStreamFromBedrockStream( bedrockStream: AsyncIterable, onComplete?: (content: string, usage: BedrockStreamUsage) => void -): ReadableStream { +): ReadableStream { let fullContent = '' let inputTokens = 0 let outputTokens = 0 @@ -25,7 +33,7 @@ export function createReadableStreamFromBedrockStream( if (event.contentBlockDelta?.delta?.text) { const text = event.contentBlockDelta.delta.text fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + controller.enqueue({ type: 'text_delta', text, turn: 'final' }) } else if (event.metadata?.usage) { inputTokens = event.metadata.usage.inputTokens ?? 0 outputTokens = event.metadata.usage.outputTokens ?? 0 diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index c351ffc4268..992d4ffdb5a 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -133,6 +133,7 @@ export const cerebrasProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { output.content = content @@ -492,6 +493,7 @@ export const cerebrasProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/cerebras/utils.ts b/apps/sim/providers/cerebras/utils.ts index 830d0d67140..96a36bd7be2 100644 --- a/apps/sim/providers/cerebras/utils.ts +++ b/apps/sim/providers/cerebras/utils.ts @@ -1,5 +1,6 @@ import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' interface CerebrasChunk { choices?: Array<{ @@ -15,12 +16,17 @@ interface CerebrasChunk { } /** - * Creates a ReadableStream from a Cerebras streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Cerebras streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromCerebrasStream( cerebrasStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(cerebrasStream as any, 'Cerebras', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(cerebrasStream as any, { + providerName: 'Cerebras', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts new file mode 100644 index 00000000000..1ccaa61b72d --- /dev/null +++ b/apps/sim/providers/deepseek/index.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { mockCreate } = vi.hoisted(() => ({ + mockCreate: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => ['deepseek-chat']), + getProviderDefaultModel: vi.fn(() => 'deepseek-chat'), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/deepseek/utils', () => ({ + createReadableStreamFromDeepseekStream: vi.fn(), +})) + +vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ + createOpenAICompatStreamingToolLoopStream: vi.fn(), +})) + +vi.mock('@/providers/streaming-execution', () => ({ + createStreamingExecution: vi.fn((args) => args), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: vi.fn(() => ({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +import { deepseekProvider } from '@/providers/deepseek/index' + +function request(overrides: Partial = {}): ProviderRequest { + return { + model: 'deepseek-chat', + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hi' }], + ...overrides, + } +} + +describe('deepseekProvider thinking payload', () => { + beforeEach(() => { + mockCreate.mockReset() + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }) + + it('sets thinking: { type: enabled } when thinkingLevel is enabled', async () => { + await deepseekProvider.executeRequest(request({ thinkingLevel: 'enabled' })) + expect(mockCreate).toHaveBeenCalled() + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toEqual({ type: 'enabled' }) + }) + + it('sets thinking: { type: disabled } when thinkingLevel is none (API default is enabled)', async () => { + await deepseekProvider.executeRequest(request({ thinkingLevel: 'none' })) + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toEqual({ type: 'disabled' }) + }) + + it('omits thinking when thinkingLevel is unset', async () => { + await deepseekProvider.executeRequest(request()) + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 37f09254be3..5d942699409 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -1,11 +1,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' -import type { StreamingExecution } from '@/executor/types' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -84,6 +85,17 @@ export const deepseekProvider: ProviderConfig = { if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_tokens = request.maxTokens + /** + * DeepSeek Think mode: reasoning_content streams when enabled (or inherent + * on reasoner). The API default is enabled, so 'none' must explicitly send + * `disabled`; unset sends nothing to preserve the legacy request shape. + */ + if (request.thinkingLevel && request.thinkingLevel !== 'none') { + payload.thinking = { type: 'enabled' } + } else if (request.thinkingLevel === 'none') { + payload.thinking = { type: 'disabled' } + } + let preparedTools: ReturnType | null = null if (tools?.length) { @@ -111,6 +123,67 @@ export const deepseekProvider: ProviderConfig = { } } + const shouldStreamToolCalls = request.streamToolCalls ?? false + + if (request.stream && shouldStreamToolCalls && payload.tools?.length) { + logger.info('Using streaming tool loop for DeepSeek request') + + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request, + basePayload: payload, + messages: + // double-cast-allowed: formatMessagesForProvider returns loosely-typed provider messages that are wire-compatible with the OpenAI chat.completions message params the shared loop expects + formattedMessages as unknown as OpenAI.Chat.Completions.ChatCompletionMessageParam[], + createStream: async (params, options) => + deepseek.chat.completions.create({ ...params, stream: true }, options), + logger, + timeSegments, + forcedTools, + /** + * DeepSeek requires reasoning_content passed back on tool-call + * turns whenever the API returns it (thinking defaults to enabled + * server-side); it is ignored on non-tool turns, so preserving + * unconditionally is always safe. + */ + preserveAssistantReasoning: true, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + if (request.stream && (!tools || tools.length === 0)) { logger.info('Using streaming response for DeepSeek request (no tools)') @@ -130,26 +203,38 @@ export const deepseekProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromDeepseekStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromDeepseekStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } } - }), + ), }) return streamingResult @@ -281,7 +366,17 @@ export const deepseekProvider: ProviderConfig = { const executionResults = await Promise.allSettled(toolExecutionPromises) - currentMessages.push({ + const assistantMessage = currentResponse.choices[0]?.message + const assistantHistory: { + role: string + content: string | null + tool_calls: Array<{ + id: string + type: string + function: { name: string; arguments: string } + }> + reasoning_content?: string + } = { role: 'assistant', content: null, tool_calls: toolCallsInResponse.map((tc) => ({ @@ -292,7 +387,21 @@ export const deepseekProvider: ProviderConfig = { arguments: tc.function.arguments, }, })), - }) + } + /** + * DeepSeek requires reasoning_content passed back on tool-call turns + * whenever the API returns it (thinking defaults to enabled + * server-side, so this applies even without an explicit thinking + * level); it is ignored on non-tool turns. + */ + if (assistantMessage) { + const reasoningContent = (assistantMessage as { reasoning_content?: string }) + .reasoning_content + if (typeof reasoningContent === 'string' && reasoningContent.length > 0) { + assistantHistory.reasoning_content = reasoningContent + } + } + currentMessages.push(assistantHistory) for (const settledResult of executionResults) { if (settledResult.status === 'rejected' || !settledResult.value) continue @@ -480,28 +589,40 @@ export const deepseekProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromDeepseekStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromDeepseekStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + + if (thinking) { + const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') + if (lastModel) { + lastModel.thinkingContent = thinking + } + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/deepseek/utils.ts b/apps/sim/providers/deepseek/utils.ts index 1c7a67488b2..ccfeb9e10fc 100644 --- a/apps/sim/providers/deepseek/utils.ts +++ b/apps/sim/providers/deepseek/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a DeepSeek streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a DeepSeek streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromDeepseekStream( deepseekStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(deepseekStream, 'Deepseek', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(deepseekStream, { + providerName: 'Deepseek', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index c6981d4abf0..f30a58768bb 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -167,6 +167,7 @@ export const fireworksProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -469,6 +470,7 @@ export const fireworksProvider: ProviderConfig = { }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/fireworks/utils.ts b/apps/sim/providers/fireworks/utils.ts index 70444e07b69..c4abd3111bd 100644 --- a/apps/sim/providers/fireworks/utils.ts +++ b/apps/sim/providers/fireworks/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Checks if a model supports native structured outputs (json_schema). @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Fireworks streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Fireworks streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Fireworks', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Fireworks', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 1419df73782..00732bac295 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -13,8 +13,9 @@ import { } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' import { checkForForcedToolUsage, cleanSchemaForGemini, @@ -28,6 +29,8 @@ import { mapToThinkingLevel, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { ensureToolCallId } from '@/providers/tool-call-id' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -228,7 +231,7 @@ async function executeToolCallsBatch( startTime: r.startTime, endTime: r.endTime, duration: r.duration, - toolCallId: r.part.functionCall?.id ?? undefined, + toolCallId: ensureToolCallId(r.part.functionCall?.id, 'gemini'), }) totalToolsTime += r.duration @@ -955,8 +958,10 @@ export async function executeGeminiRequest( } // Gemini 3.x takes thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. + // includeThoughts is required for thought parts to appear; it is requested + // only on agent-events runs so legacy runs keep the pre-agent-events payload. if (request.thinkingLevel && request.thinkingLevel !== 'none') { - const thinkingConfig: ThinkingConfig = { includeThoughts: false } + const thinkingConfig: ThinkingConfig = { includeThoughts: request.agentEvents === true } if (isGemini3Model(model)) { thinkingConfig.thinkingLevel = mapToThinkingLevel(request.thinkingLevel) } else { @@ -1019,8 +1024,69 @@ export async function executeGeminiRequest( } const initialCallTime = Date.now() + /** + * Gemini 2 cannot combine responseSchema with tools, so structured output + * is applied on a final schema-configured request after tools settle — the + * silent path does this; the live loop would break as soon as a turn has + * no calls and skip the schema. Gemini 3 carries responseJsonSchema + * alongside tools, so its live loop keeps structured output. + */ + const responseFormatNeedsFinalPass = Boolean(request.responseFormat) && !isGemini3Model(model) + const shouldStreamToolCalls = + (request.streamToolCalls ?? false) && !responseFormatNeedsFinalPass const shouldStream = request.stream && !tools?.length + // Live streaming tool loop + if (request.stream && shouldStreamToolCalls && tools?.length) { + logger.info('Using streaming tool loop for Gemini request') + + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools ?? [] + + return createStreamingExecution({ + model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createGeminiStreamingToolLoopStream({ + ai, + model, + baseConfig: geminiConfig, + contents, + request, + logger, + timeSegments, + forcedTools, + toolConfig, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + // Streaming without tools if (shouldStream) { logger.info('Handling Gemini streaming response') @@ -1042,7 +1108,7 @@ export async function executeGeminiRequest( const stream = createReadableStreamFromGeminiStream( streamGenerator, - (content: string, usage: GeminiUsage) => { + (content: string, usage: GeminiUsage, thinking?: string) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { input: usage.promptTokenCount, @@ -1057,6 +1123,13 @@ export async function executeGeminiRequest( ) streamingResult.execution.output.cost = costResult + if (thinking) { + const segment = streamingResult.execution.output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } + const streamEndTime = Date.now() if (streamingResult.execution.output.providerTiming) { streamingResult.execution.output.providerTiming.endTime = new Date( @@ -1073,7 +1146,7 @@ export async function executeGeminiRequest( } ) - return { ...streamingResult, stream } + return { ...streamingResult, stream, streamFormat: 'agent-events-v1' as const } } // Non-streaming request @@ -1193,7 +1266,7 @@ export async function executeGeminiRequest( const stream = createReadableStreamFromGeminiStream( streamGenerator, - (streamContent: string, usage: GeminiUsage) => { + (streamContent: string, usage: GeminiUsage, thinking?: string) => { streamingResult.execution.output.content = streamContent streamingResult.execution.output.tokens = { input: accumulatedTokens.input + usage.promptTokenCount, @@ -1215,6 +1288,16 @@ export async function executeGeminiRequest( pricing: streamCost.pricing, } + if (thinking) { + const segments = streamingResult.execution.output.providerTiming?.timeSegments + const lastModel = segments + ? [...segments].reverse().find((s) => s.type === 'model') + : undefined + if (lastModel) { + lastModel.thinkingContent = thinking + } + } + if (streamingResult.execution.output.providerTiming) { streamingResult.execution.output.providerTiming.endTime = new Date().toISOString() streamingResult.execution.output.providerTiming.duration = @@ -1223,7 +1306,7 @@ export async function executeGeminiRequest( } ) - return { ...streamingResult, stream } + return { ...streamingResult, stream, streamFormat: 'agent-events-v1' as const } } // Non-streaming: get next response @@ -1318,7 +1401,7 @@ function enrichLastModelSegmentFromGeminiResponse( Boolean(p.functionCall) ) .map((p) => ({ - id: p.functionCall.id ?? '', + id: ensureToolCallId(p.functionCall.id, 'gemini'), name: p.functionCall.name ?? '', arguments: (p.functionCall.args ?? {}) as Record, })) diff --git a/apps/sim/providers/gemini/streaming-tool-loop.test.ts b/apps/sim/providers/gemini/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..5b2e34244ab --- /dev/null +++ b/apps/sim/providers/gemini/streaming-tool-loop.test.ts @@ -0,0 +1,168 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { resetLocalToolIdCounterForTests } from '@/providers/tool-call-id' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +vi.mock('@/tools', () => ({ + executeTool: vi.fn(async () => ({ + success: true, + output: { ok: true, url: 'https://httpbin.org/get' }, + })), +})) + +vi.mock('@/providers/utils', () => ({ + prepareToolExecution: vi.fn(() => ({ + toolParams: { url: 'https://httpbin.org/get' }, + executionParams: { url: 'https://httpbin.org/get' }, + })), + calculateCost: vi.fn(() => ({ + input: 0.01, + output: 0.02, + total: 0.03, + pricing: { input: 1, output: 2, updatedAt: new Date().toISOString() }, + })), + sumToolCosts: vi.fn(() => 0), + isGemini3Model: vi.fn(() => false), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), +})) + +describe('createGeminiStreamingToolLoopStream', () => { + it('emits thinking, tool lifecycle, then final answer; allocates local tool ids', async () => { + resetLocalToolIdCounterForTests() + + const turns = [ + // Turn 1: thinking + functionCall (no id) + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { text: 'I should call the API. ', thought: true }, + { + functionCall: { + name: 'http_request', + args: { url: 'https://httpbin.org/get' }, + }, + }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + } as any + })(), + // Turn 2: final answer + (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Done: https://httpbin.org/get' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 20, + candidatesTokenCount: 8, + totalTokenCount: 28, + }, + } as any + })(), + ] + + let turnIdx = 0 + const ai = { + models: { + generateContentStream: vi.fn(async () => turns[turnIdx++]), + }, + } + + const onComplete = vi.fn() + const timeSegments: any[] = [] + const stream = createGeminiStreamingToolLoopStream({ + ai: ai as any, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments, + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should call the API. ', + ]) + + const starts = events.filter((e) => e.type === 'tool_call_start') + expect(starts).toHaveLength(1) + expect(starts[0]).toMatchObject({ name: 'http_request' }) + expect((starts[0] as { id: string }).id).toMatch(/^gemini_/) + + const ends = events.filter((e) => e.type === 'tool_call_end') + expect(ends).toEqual([ + { + type: 'tool_call_end', + id: (starts[0] as { id: string }).id, + name: 'http_request', + status: 'success', + }, + ]) + + // Text streams live as `pending`; the turn_end sequence classifies turns. + const textEvents = events.filter((e) => e.type === 'text_delta') + expect(textEvents.every((e) => e.type === 'text_delta' && e.turn === 'pending')).toBe(true) + expect( + textEvents + .filter((e) => e.type === 'text_delta') + .map((e) => e.text) + .join('') + ).toContain('Done:') + expect(events.filter((e) => e.type === 'turn_end').map((e) => e.turn)).toEqual([ + 'intermediate', + 'final', + ]) + + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Done:'), + toolCalls: expect.objectContaining({ count: 1 }), + }) + ) + }) +}) diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts new file mode 100644 index 00000000000..2af1501ef33 --- /dev/null +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -0,0 +1,536 @@ +/** + * Live Gemini streaming tool loop. + * + * Each model turn uses generateContentStream. Thought parts → thinking_delta + * live; functionCall parts → tool_call_start (with local ids when the model + * omits them); text parts → `pending` text deltas live, classified by a + * `turn_end` event as intermediate vs final. Tool ends emit in completion + * order; abort → cancelled. + * + * Function-call parts are echoed back into request history verbatim — Google + * requires signatures/ids to round-trip exactly as received, so local ids are + * used only for agent events and trace segments, never injected into history. + */ + +import { + type Content, + FunctionCallingConfigMode, + type GenerateContentConfig, + type GenerateContentResponse, + type GoogleGenAI, + type Part, + type Schema, + type ToolConfig, +} from '@google/genai' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { IterationToolCall } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + checkForForcedToolUsage, + cleanSchemaForGemini, + convertUsageMetadata, + ensureStructResponse, +} from '@/providers/google/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + type StreamingToolLoopComplete, + settleOpenTools, +} from '@/providers/streaming-tool-loop-shared' +import { ensureToolCallId } from '@/providers/tool-call-id' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { + calculateCost, + isGemini3Model, + prepareToolExecution, + sumToolCosts, +} from '@/providers/utils' +import { executeTool } from '@/tools' +import type { GeminiUsage } from './types' + +export interface CreateGeminiStreamingToolLoopStreamOptions { + ai: GoogleGenAI + model: string + baseConfig: GenerateContentConfig + contents: Content[] + request: ProviderRequest + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + toolConfig?: ToolConfig + onComplete: (result: StreamingToolLoopComplete) => void +} + +/** + * A streamed functionCall part paired with the execution-local id used on the + * agent-events stream. The part itself stays verbatim for history echo. + */ +interface StreamedFunctionCall { + part: Part + localId: string +} + +function buildNextConfig( + baseConfig: GenerateContentConfig, + currentToolConfig: ToolConfig | undefined, + usedForcedTools: string[], + forcedTools: string[], + request: ProviderRequest, + logger: Logger, + model: string +): GenerateContentConfig { + const nextConfig = { ...baseConfig } + const allForcedToolsUsed = forcedTools.length > 0 && usedForcedTools.length === forcedTools.length + + if (allForcedToolsUsed && request.responseFormat) { + nextConfig.tools = undefined + nextConfig.toolConfig = undefined + if (isGemini3Model(model)) { + logger.info('Gemini 3: Stripping tools after forced tool execution, schema already set') + } else { + nextConfig.responseMimeType = 'application/json' + nextConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema + logger.info('Using structured output for final response after tool execution') + } + } else if (currentToolConfig) { + nextConfig.toolConfig = currentToolConfig + } else { + nextConfig.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO } } + } + + return nextConfig +} + +/** + * Drain one generateContentStream turn into live agent events + aggregated parts. + */ +async function drainGeminiTurn( + stream: AsyncGenerator, + controller: ReadableStreamDefaultController, + openTools: Map +): Promise<{ + text: string + thinking: string + functionCalls: StreamedFunctionCall[] + usage: GeminiUsage + finishReason?: string +}> { + let text = '' + let thinking = '' + const functionCalls: StreamedFunctionCall[] = [] + const seenKeys = new Set() + let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } + let finishReason: string | undefined + + for await (const chunk of stream) { + if (chunk.usageMetadata) { + usage = convertUsageMetadata(chunk.usageMetadata) + } + + const candidate = chunk.candidates?.[0] + if (candidate?.finishReason) { + finishReason = String(candidate.finishReason) + } + + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + const fallback = chunk.text + if (fallback) { + text += fallback + controller.enqueue({ type: 'text_delta', text: fallback, turn: 'pending' }) + } + continue + } + + for (const part of parts) { + if (part.functionCall) { + const localId = ensureToolCallId(part.functionCall.id, 'gemini') + const name = part.functionCall.name ?? '' + if (!seenKeys.has(localId) && name) { + seenKeys.add(localId) + functionCalls.push({ part, localId }) + if (!openTools.has(localId)) { + openTools.set(localId, name) + controller.enqueue({ type: 'tool_call_start', id: localId, name }) + } + } + continue + } + + if (!part.text) continue + if (part.thought === true) { + thinking += part.text + controller.enqueue({ type: 'thinking_delta', text: part.text }) + } else { + text += part.text + // Live pending text: sinks render it now; the pump projects it to the + // answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: part.text, turn: 'pending' }) + } + } + } + + return { text, thinking, functionCalls, usage, finishReason } +} + +/** + * Multi-turn Gemini tool loop as an agent-events-v1 object stream. + */ +export function createGeminiStreamingToolLoopStream( + options: CreateGeminiStreamingToolLoopStreamOptions +): ReadableStream { + const { + ai, + model, + baseConfig, + contents: initialContents, + request, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + + return new ReadableStream({ + async start(controller) { + let contents = [...initialContents] + let currentToolConfig = options.toolConfig + let usedForcedTools: string[] = [] + + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + let costInput = 0 + let costOutput = 0 + let costTotal = 0 + let latestPricing: ReturnType['pricing'] | undefined + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const turnConfig = buildNextConfig( + baseConfig, + currentToolConfig, + usedForcedTools, + forcedTools, + request, + logger, + model + ) + + const modelStart = Date.now() + const streamGenerator = await ai.models.generateContentStream({ + model, + contents, + config: turnConfig, + }) + + const drained = await drainGeminiTurn(streamGenerator, controller, openToolStarts) + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + tokens.input += drained.usage.promptTokenCount + tokens.output += drained.usage.candidatesTokenCount + tokens.total += drained.usage.totalTokenCount + + const turnCost = calculateCost( + model, + drained.usage.promptTokenCount, + drained.usage.candidatesTokenCount + ) + costInput += turnCost.input + costOutput += turnCost.output + costTotal += turnCost.total + latestPricing = turnCost.pricing + + const turnTag = drained.functionCalls.length > 0 ? 'intermediate' : 'final' + controller.enqueue({ type: 'turn_end', turn: turnTag }) + if (drained.text) { + content = drained.text + } + + const toolCallsForEnrich: IterationToolCall[] = drained.functionCalls + .filter((fc) => Boolean(fc.part.functionCall)) + .map((fc) => ({ + id: fc.localId, + name: fc.part.functionCall?.name ?? '', + arguments: (fc.part.functionCall?.args ?? {}) as Record, + })) + + enrichLastModelSegment(timeSegments, { + assistantContent: drained.text || undefined, + thinkingContent: drained.thinking || undefined, + toolCalls: toolCallsForEnrich.length > 0 ? toolCallsForEnrich : undefined, + finishReason: drained.finishReason, + tokens: { + input: drained.usage.promptTokenCount, + output: drained.usage.candidatesTokenCount, + total: drained.usage.totalTokenCount, + }, + cost: { + input: turnCost.input, + output: turnCost.output, + total: turnCost.total, + }, + provider: 'google', + }) + + const forcedCheck = checkForForcedToolUsage( + drained.functionCalls + .map((fc) => fc.part.functionCall) + .filter((fc): fc is NonNullable => Boolean(fc)), + currentToolConfig, + forcedTools, + usedForcedTools + ) + if (forcedCheck) { + usedForcedTools = forcedCheck.usedForcedTools + currentToolConfig = forcedCheck.nextToolConfig + } + + if (drained.functionCalls.length === 0) { + sawFinalTurn = true + break + } + + const toolsStartTime = Date.now() + + const orderedResults = await Promise.all( + drained.functionCalls.map(async ({ part, localId }) => { + const functionCall = part.functionCall! + const toolCallId = localId + const toolName = functionCall.name ?? '' + const toolArgs = (functionCall.args ?? {}) as Record + const toolCallStartTime = Date.now() + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + part, + toolCallId, + toolName, + toolArgs, + toolParams: {} as Record, + resultContent: { + error: true, + message: `Tool ${toolName} not found`, + tool: toolName, + }, + result: undefined as + | { success: boolean; output?: unknown; error?: string } + | undefined, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + success: false, + } + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + const resultContent: Record = result.success + ? ensureStructResponse(result.output) + : { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + const status: ToolCallEndStatus = result.success ? 'success' : 'error' + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status, + }) + return { + part, + toolCallId, + toolName, + toolArgs, + toolParams, + resultContent, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: result.success, + } + } catch (error) { + const toolCallEndTime = Date.now() + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + if (!cancelled) { + logger.error('Error processing function call:', { + error: toError(error).message, + functionName: toolName, + }) + } + const status: ToolCallEndStatus = cancelled ? 'cancelled' : 'error' + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status, + }) + return { + part, + toolCallId, + toolName, + toolArgs, + toolParams: {} as Record, + resultContent: { + error: true, + message: getErrorMessage(error, 'Tool execution failed'), + tool: toolName, + }, + result: undefined, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: false, + } + } + }) + ) + + toolsTime += Date.now() - toolsStartTime + + /** + * Echo the model's functionCall parts verbatim (signatures and any + * model-provided ids must round-trip untouched). A functionResponse + * id is attached only when the model itself provided one. + */ + const modelParts: Part[] = orderedResults.map((r) => r.part) + const userParts: Part[] = orderedResults.map((r) => ({ + functionResponse: { + name: r.toolName, + response: r.resultContent, + ...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}), + }, + })) + + contents = [ + ...contents, + { role: 'model', parts: modelParts }, + { role: 'user', parts: userParts }, + ] + + for (const r of orderedResults) { + toolCalls.push({ + name: r.toolName, + arguments: r.toolParams, + startTime: new Date(r.startTime).toISOString(), + endTime: new Date(r.endTime).toISOString(), + duration: r.duration, + result: r.resultContent, + success: r.success, + }) + if (r.success && r.result?.output) { + toolResults.push(r.result.output as Record) + } + timeSegments.push({ + type: 'tool', + name: r.toolName, + startTime: r.startTime, + endTime: r.endTime, + duration: r.duration, + toolCallId: r.toolCallId, + }) + } + + iterationCount += 1 + } + + /** + * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the + * answer channel would otherwise be empty. Flush the last turn's text + * as the final answer so legacy consumers still receive content. + */ + if (!sawFinalTurn && content) { + controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + } + + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + controller.close() + } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + } else { + settleOpenTools(controller, openToolStarts, 'error') + logger.error('Gemini streaming tool loop failed', { + error: toError(error).message, + }) + } + controller.error(error) + } + }, + }) +} diff --git a/apps/sim/providers/google/utils.stream.test.ts b/apps/sim/providers/google/utils.stream.test.ts new file mode 100644 index 00000000000..2e3a79e7ecb --- /dev/null +++ b/apps/sim/providers/google/utils.stream.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromGeminiStream } from '@/providers/google/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromGeminiStream', () => { + it('splits thought parts into thinking_delta and answer into text_delta', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { text: 'Reasoning step. ', thought: true }, + { text: 'Final answer.', thought: false }, + ], + }, + }, + ], + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 7, + totalTokenCount: 12, + }, + } as any + })(), + onComplete + ) + + const events = await collectEvents(stream) + expect(events).toEqual([ + { type: 'thinking_delta', text: 'Reasoning step. ' }, + { type: 'text_delta', text: 'Final answer.', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + 'Final answer.', + expect.objectContaining({ promptTokenCount: 5 }), + 'Reasoning step. ' + ) + }) + + it('does not invent thinking when only answer text is present', async () => { + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + text: 'Just text', + candidates: [{ content: { parts: [{ text: 'Just text' }] } }], + } as any + })() + ) + const events = await collectEvents(stream) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect( + events + .filter((e) => e.type === 'text_delta') + .map((e) => e.text) + .join('') + ).toContain('Just text') + }) +}) diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 05aa74328f7..3a389261011 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { buildGeminiMessageParts } from '@/providers/attachments' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest } from '@/providers/types' import { trackForcedToolUsage } from '@/providers/utils' @@ -280,13 +281,16 @@ export function convertToGeminiFormat( } /** - * Creates a ReadableStream from a Google Gemini streaming response + * Creates an agent-events-v1 stream from a Google Gemini streaming response. + * Thought parts (`part.thought === true`) → thinking_delta; other text → text_delta. + * Capability-honest: thinking only appears when includeThoughts was requested. */ export function createReadableStreamFromGeminiStream( stream: AsyncGenerator, - onComplete?: (content: string, usage: GeminiUsage) => void -): ReadableStream { + onComplete?: (content: string, usage: GeminiUsage, thinking?: string) => void +): ReadableStream { let fullContent = '' + let fullThinking = '' let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } return new ReadableStream({ @@ -297,14 +301,30 @@ export function createReadableStreamFromGeminiStream( usage = convertUsageMetadata(chunk.usageMetadata) } + const parts = chunk.candidates?.[0]?.content?.parts + if (Array.isArray(parts)) { + for (const part of parts) { + if (!part.text) continue + if (part.thought === true) { + fullThinking += part.text + controller.enqueue({ type: 'thinking_delta', text: part.text }) + } else { + fullContent += part.text + controller.enqueue({ type: 'text_delta', text: part.text, turn: 'final' }) + } + } + continue + } + + // Fallback when parts are not exposed — answer text only (no false thinking). const text = chunk.text if (text) { fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + controller.enqueue({ type: 'text_delta', text, turn: 'final' }) } } - onComplete?.(fullContent, usage) + onComplete?.(fullContent, usage, fullThinking || undefined) controller.close() } catch (error) { logger.error('Error reading Google Gemini stream', { diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts new file mode 100644 index 00000000000..0b5ca8e4c84 --- /dev/null +++ b/apps/sim/providers/groq/index.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { mockCreate } = vi.hoisted(() => ({ + mockCreate: vi.fn(), +})) + +vi.mock('groq-sdk', () => ({ + Groq: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => ['groq/openai/gpt-oss-120b']), + getProviderDefaultModel: vi.fn(() => 'groq/openai/gpt-oss-120b'), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/groq/utils', () => ({ + createReadableStreamFromGroqStream: vi.fn(), +})) + +vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ + createOpenAICompatStreamingToolLoopStream: vi.fn(), +})) + +vi.mock('@/providers/streaming-execution', () => ({ + createStreamingExecution: vi.fn((args) => args), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: vi.fn(() => ({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +import { groqProvider } from '@/providers/groq/index' + +function request(overrides: Partial = {}): ProviderRequest { + return { + model: 'groq/openai/gpt-oss-120b', + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hi' }], + ...overrides, + } +} + +describe('groqProvider reasoning payload', () => { + beforeEach(() => { + mockCreate.mockReset() + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }) + + it('GPT-OSS sets include_reasoning and reasoning_effort', async () => { + await groqProvider.executeRequest( + request({ model: 'groq/openai/gpt-oss-120b', reasoningEffort: 'high' }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.model).toBe('openai/gpt-oss-120b') + expect(payload.include_reasoning).toBe(true) + expect(payload.reasoning_effort).toBe('high') + expect(payload.reasoning_format).toBeUndefined() + }) + + it('GPT-OSS sends no reasoning params when effort and thinking are unset (legacy request shape)', async () => { + await groqProvider.executeRequest(request({ model: 'groq/openai/gpt-oss-20b' })) + const payload = mockCreate.mock.calls[0][0] + expect(payload.include_reasoning).toBeUndefined() + expect(payload.reasoning_effort).toBeUndefined() + }) + + it('GPT-OSS defaults reasoning_effort to medium when only a thinking level is set', async () => { + await groqProvider.executeRequest( + request({ model: 'groq/openai/gpt-oss-20b', thinkingLevel: 'enabled' }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.include_reasoning).toBe(true) + expect(payload.reasoning_effort).toBe('medium') + }) + + it('Qwen sets reasoning_format parsed when thinking enabled', async () => { + await groqProvider.executeRequest( + request({ + model: 'groq/qwen/qwen3-32b', + thinkingLevel: 'enabled', + }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_format).toBe('parsed') + expect(payload.include_reasoning).toBeUndefined() + }) + + it('Qwen disables reasoning via reasoning_effort none when thinking is none', async () => { + await groqProvider.executeRequest( + request({ + model: 'groq/qwen/qwen3.6-27b', + thinkingLevel: 'none', + }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_format).toBeUndefined() + expect(payload.reasoning_effort).toBe('none') + }) +}) diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 15d854e145c..539f8eede87 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -1,11 +1,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { Groq } from 'groq-sdk' -import type { StreamingExecution } from '@/executor/types' +import type { ChatCompletionCreateParamsStreaming as GroqChatCompletionCreateParamsStreaming } from 'groq-sdk/resources/chat/completions' +import type { + ChatCompletionChunk, + ChatCompletionMessageParam, +} from 'openai/resources/chat/completions' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -77,6 +83,27 @@ export const groqProvider: ProviderConfig = { if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + /** + * Groq reasoning: GPT-OSS uses include_reasoning + reasoning_effort; Qwen + * uses reasoning_format: parsed (compatible with tools) and disables via + * reasoning_effort: none. Reasoning params are only sent when the user set + * a thinking level or an explicit effort — otherwise the request keeps the + * legacy shape (Groq's server defaults already match what would be sent). + */ + const groqModelId = payload.model as string + const isGptOss = groqModelId.includes('gpt-oss') + const isQwenReasoning = /qwen3/i.test(groqModelId) + const hasExplicitEffort = Boolean(request.reasoningEffort && request.reasoningEffort !== 'auto') + const hasThinkingLevel = Boolean(request.thinkingLevel && request.thinkingLevel !== 'none') + if (isGptOss && (hasExplicitEffort || hasThinkingLevel)) { + payload.include_reasoning = true + payload.reasoning_effort = hasExplicitEffort ? request.reasoningEffort : 'medium' + } else if (isQwenReasoning && hasThinkingLevel) { + payload.reasoning_format = 'parsed' + } else if (isQwenReasoning && request.thinkingLevel === 'none') { + payload.reasoning_effort = 'none' + } + if (request.responseFormat) { payload.response_format = { type: 'json_schema', @@ -112,6 +139,71 @@ export const groqProvider: ProviderConfig = { } } + const shouldStreamToolCalls = request.streamToolCalls ?? false + + if (request.stream && shouldStreamToolCalls && payload.tools?.length) { + logger.info('Using streaming tool loop for Groq request') + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Groq', + request, + basePayload: payload, + // double-cast-allowed: formatMessagesForProvider returns loosely-typed provider messages that are wire-compatible with the OpenAI chat.completions message params the shared loop expects + messages: formattedMessages as unknown as ChatCompletionMessageParam[], + createStream: async (params, options) => { + const groqParams = { + ...params, + stream: true, + // double-cast-allowed: groq-sdk chat params are wire-compatible with the OpenAI-typed payload built by the shared compat tool loop + } as unknown as GroqChatCompletionCreateParamsStreaming + const stream = await groq.chat.completions.create(groqParams, options) + // double-cast-allowed: groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the shared compat loop consumes + return stream as unknown as AsyncIterable + }, + logger, + timeSegments, + forcedTools, + preserveAssistantReasoning: + (!!request.thinkingLevel && request.thinkingLevel !== 'none') || + (payload.model as string).includes('gpt-oss'), + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + if (request.stream && (!tools || tools.length === 0)) { logger.info('Using streaming response for Groq request (no tools)') @@ -134,26 +226,38 @@ export const groqProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromGroqStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes + streamResponse as unknown as AsyncIterable, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } } - }), + ), }) return streamingResult @@ -440,28 +544,40 @@ export const groqProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromGroqStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes + streamResponse as unknown as AsyncIterable, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + + if (thinking) { + const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') + if (lastModel) { + lastModel.thinkingContent = thinking + } + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/groq/utils.ts b/apps/sim/providers/groq/utils.ts index 61e8563e962..cb97a689ea5 100644 --- a/apps/sim/providers/groq/utils.ts +++ b/apps/sim/providers/groq/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Groq streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Groq streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromGroqStream( groqStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(groqStream, 'Groq', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(groqStream, { + providerName: 'Groq', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index 0d02c00710a..8343321e79c 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -202,26 +203,31 @@ export const kimiProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromKimiStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromKimiStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult @@ -564,28 +570,33 @@ export const kimiProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromKimiStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromKimiStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/kimi/utils.ts b/apps/sim/providers/kimi/utils.ts index c52a0cd50ad..8e155fd6dd2 100644 --- a/apps/sim/providers/kimi/utils.ts +++ b/apps/sim/providers/kimi/utils.ts @@ -1,10 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +/** + * Creates an agent-events stream from a Kimi (Moonshot AI) streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. + */ export function createReadableStreamFromKimiStream( kimiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(kimiStream, 'Kimi', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(kimiStream, { + providerName: 'Kimi', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 0f5fc2d3d2c..e8ee2229e6e 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -219,6 +219,7 @@ export const litellmProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { let cleanContent = content @@ -588,6 +589,7 @@ export const litellmProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { let cleanContent = content diff --git a/apps/sim/providers/litellm/utils.ts b/apps/sim/providers/litellm/utils.ts index f779f95c703..bacbd3cacbb 100644 --- a/apps/sim/providers/litellm/utils.ts +++ b/apps/sim/providers/litellm/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a LiteLLM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a LiteLLM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromLiteLLMStream( litellmStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(litellmStream, 'LiteLLM', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(litellmStream, { + providerName: 'LiteLLM', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/meta/index.ts b/apps/sim/providers/meta/index.ts index 96b0e0034ad..ecd8b141481 100644 --- a/apps/sim/providers/meta/index.ts +++ b/apps/sim/providers/meta/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -172,26 +173,31 @@ export const metaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromMetaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromMetaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult @@ -538,28 +544,33 @@ export const metaProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromMetaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromMetaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/meta/utils.ts b/apps/sim/providers/meta/utils.ts index 1f72345ec5c..a8defd82d75 100644 --- a/apps/sim/providers/meta/utils.ts +++ b/apps/sim/providers/meta/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Meta Model API streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Meta Model API streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromMetaStream( metaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(metaStream, 'Meta', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(metaStream, { + providerName: 'Meta', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 95c66422a72..4d95b1e96ad 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -155,6 +155,7 @@ export const mistralProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromMistralStream(streamResponse, (content, usage) => { output.content = content @@ -480,6 +481,7 @@ export const mistralProvider: ProviderConfig = { count: toolCalls.length, } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromMistralStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/mistral/utils.ts b/apps/sim/providers/mistral/utils.ts index 61893928918..b1d16c95304 100644 --- a/apps/sim/providers/mistral/utils.ts +++ b/apps/sim/providers/mistral/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Mistral streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Mistral streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromMistralStream( mistralStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(mistralStream, 'Mistral', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(mistralStream, { + providerName: 'Mistral', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 23ab2236e38..6c046c26489 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -35,6 +35,9 @@ import { } from '@/components/icons' import type { ModelPricing, ProviderId } from '@/providers/types' +/** How a model's thinking appears on the agent-events stream. */ +export type ThinkingStreamVisibility = 'full' | 'summary' | 'none' + export interface ModelCapabilities { temperature?: { min: number @@ -54,6 +57,16 @@ export interface ModelCapabilities { thinking?: { levels: string[] default?: string + /** + * What this model's thinking looks like on the agent-events stream: + * `full` raw thinking deltas, `summary` summaries only, or `none` (the + * provider withholds thinking text entirely, e.g. the newest Claude + * models default to omitted thinking display). Anthropic-family models + * must set this explicitly since visibility varies per model generation — + * `bun run agent-stream-docs:check` enforces it. Other families fall back + * to the per-provider defaults in {@link getThinkingStreamVisibility}. + */ + streamed?: ThinkingStreamVisibility } deepResearch?: boolean /** Whether this model supports conversation memory. Defaults to true if omitted. */ @@ -757,6 +770,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -776,6 +790,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -796,6 +811,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -816,6 +832,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'xhigh', 'max'], default: 'high', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -836,6 +853,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', + streamed: 'full', }, }, contextWindow: 1000000, @@ -856,6 +874,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', + streamed: 'full', }, }, contextWindow: 1000000, @@ -876,6 +895,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -895,6 +915,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -915,6 +936,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -936,6 +958,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -955,6 +978,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -976,6 +1000,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -1343,6 +1368,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', + streamed: 'full', }, }, contextWindow: 1000000, @@ -1363,6 +1389,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -1383,6 +1410,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -1402,6 +1430,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -1423,6 +1452,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', + streamed: 'full', }, }, contextWindow: 200000, @@ -1901,7 +1931,12 @@ export const PROVIDER_DEFINITIONS: Record = { output: 0.87, updatedAt: '2026-06-16', }, - capabilities: {}, + capabilities: { + thinking: { + levels: ['enabled'], + default: 'enabled', + }, + }, contextWindow: 1000000, releaseDate: '2026-04-24', }, @@ -1915,6 +1950,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 1000000, releaseDate: '2026-04-24', @@ -1929,6 +1968,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 1000000, releaseDate: '2024-12-26', @@ -1956,7 +1999,12 @@ export const PROVIDER_DEFINITIONS: Record = { output: 2.19, updatedAt: '2026-04-01', }, - capabilities: {}, + capabilities: { + thinking: { + levels: ['enabled'], + default: 'enabled', + }, + }, contextWindow: 128000, releaseDate: '2025-01-20', sunset: { status: 'deprecated' }, @@ -1969,7 +2017,12 @@ export const PROVIDER_DEFINITIONS: Record = { output: 0.28, updatedAt: '2026-06-11', }, - capabilities: {}, + capabilities: { + thinking: { + levels: ['enabled'], + default: 'enabled', + }, + }, contextWindow: 1000000, releaseDate: '2025-01-20', }, @@ -2299,6 +2352,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-08-05', @@ -2314,6 +2370,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-08-05', @@ -2328,6 +2387,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-10-29', @@ -2341,6 +2403,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 40960, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 131072, releaseDate: '2025-04-29', @@ -2355,6 +2421,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 32768, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 131072, releaseDate: '2026-04-21', @@ -4295,7 +4365,7 @@ export function supportsNativeStructuredOutputs(modelId: string): boolean { */ export function getThinkingCapability( modelId: string -): { levels: string[]; default?: string } | null { +): NonNullable | null { const normalizedModelId = modelId.toLowerCase() for (const provider of Object.values(PROVIDER_DEFINITIONS)) { @@ -4335,6 +4405,49 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null { return capability?.levels ?? null } +/** + * Per-provider defaults for thinking stream visibility, used when a model does + * not declare `capabilities.thinking.streamed` explicitly. Gemini and OpenAI + * stream summaries only; Bedrock never requests reasoning; OpenAI-compat + * vendors that expose reasoning stream the raw chain of thought. + */ +const PROVIDER_THINKING_STREAM_DEFAULTS: Record = { + google: 'summary', + vertex: 'summary', + openai: 'summary', + 'azure-openai': 'summary', + bedrock: 'none', +} + +/** + * What a reasoning-capable model's thinking looks like on the agent-events + * stream (canvas terminal, opted-in deployed chat). Returns null for models + * with no thinking or reasoning-effort capability. Explicit per-model + * `capabilities.thinking.streamed` wins over the provider default; providers + * without a default stream the raw chain of thought when the vendor emits it. + */ +export function getThinkingStreamVisibility(modelId: string): ThinkingStreamVisibility | null { + const normalizedModelId = modelId.toLowerCase() + + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + for (const model of provider.models) { + const baseModelId = model.id.toLowerCase() + if (normalizedModelId !== baseModelId && !normalizedModelId.startsWith(`${baseModelId}-`)) { + continue + } + if (!model.capabilities.thinking && !model.capabilities.reasoningEffort) { + return null + } + return ( + model.capabilities.thinking?.streamed ?? + PROVIDER_THINKING_STREAM_DEFAULTS[provider.id] ?? + 'full' + ) + } + } + return null +} + /** * Get all models that support deep research capability */ diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index ffb7bf0fb25..b767102cf6b 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -152,26 +153,31 @@ export const nvidiaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromNvidiaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromNvidiaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult @@ -512,28 +518,33 @@ export const nvidiaProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromNvidiaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromNvidiaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/nvidia/utils.ts b/apps/sim/providers/nvidia/utils.ts index ef9c37b3a50..45c0b526a4b 100644 --- a/apps/sim/providers/nvidia/utils.ts +++ b/apps/sim/providers/nvidia/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an NVIDIA NIM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an NVIDIA NIM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromNvidiaStream( nvidiaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(nvidiaStream, 'NVIDIA', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(nvidiaStream, { + providerName: 'NVIDIA', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/ollama-cloud/utils.ts b/apps/sim/providers/ollama-cloud/utils.ts index 3ab364c66bb..d768b1d9134 100644 --- a/apps/sim/providers/ollama-cloud/utils.ts +++ b/apps/sim/providers/ollama-cloud/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an Ollama Cloud streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Ollama Cloud streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOllamaCloudStream( ollamaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(ollamaStream, 'Ollama Cloud', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(ollamaStream, { + providerName: 'Ollama Cloud', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index 9e10b7c436a..c41134fac80 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -9,6 +9,7 @@ import type { CompletionUsage } from 'openai/resources/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' +import type { AgentStreamEvent } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -54,8 +55,8 @@ export interface OllamaCoreConfig { createClient: () => OpenAI createStream: ( stream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void - ) => ReadableStream + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void + ) => ReadableStream logger: Logger } @@ -181,6 +182,7 @@ export async function executeOllamaProviderRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => config.createStream(streamResponse, (content, usage) => { output.content = content @@ -489,6 +491,7 @@ export async function executeOllamaProviderRequest( count: toolCalls.length, } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => config.createStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/ollama/utils.ts b/apps/sim/providers/ollama/utils.ts index 45ea5d9957f..71e7890f7a5 100644 --- a/apps/sim/providers/ollama/utils.ts +++ b/apps/sim/providers/ollama/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an Ollama streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Ollama streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOllamaStream( ollamaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(ollamaStream, 'Ollama', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(ollamaStream, { + providerName: 'Ollama', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/openai-compat/stream-events.test.ts b/apps/sim/providers/openai-compat/stream-events.test.ts new file mode 100644 index 00000000000..b1d2b31f5e5 --- /dev/null +++ b/apps/sim/providers/openai-compat/stream-events.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + openaiCompatReasoningAndTextChunks, + openaiCompatTextOnlyChunks, + openaiCompatToolCallStartChunks, +} from '@/providers/__fixtures__/openai-compat' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createOpenAICompatibleAgentEventStream', () => { + it('emits thinking_delta from reasoning_content then text_delta', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatReasoningAndTextChunks as any + })(), + { providerName: 'DeepSeek', onComplete } + ) + + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should compute carefully. ', + 'Answer is 4.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { type: 'text_delta', text: '2+2=', turn: 'final' }, + { type: 'text_delta', text: '4', turn: 'final' }, + ]) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: '2+2=4', + thinking: 'I should compute carefully. Answer is 4.', + usage: { prompt_tokens: 10, completion_tokens: 8 }, + }) + }) + + it('stays text-only when no reasoning fields are present', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'Groq' } + ) + const events = await collectEvents(stream) + expect(events.every((e) => e.type === 'text_delta')).toBe(true) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + }) + + it('emits tool_call_start when enabled and id+name are known', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatToolCallStartChunks as any + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '"https://x"}' } }], + }, + }, + ], + } + })(), + { providerName: 'Groq', emitToolCallStarts: true, onComplete } + ) + const events = await collectEvents(stream) + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 'call_abc', + name: 'http_request', + }) + // Only once even if later deltas omit id + expect(events.filter((e) => e.type === 'tool_call_start')).toHaveLength(1) + expect(onComplete.mock.calls[0][0].toolCalls).toEqual([ + { + id: 'call_abc', + type: 'function', + function: { name: 'http_request', arguments: '{"url":"https://x"}' }, + }, + ]) + }) + + it('keeps a synthesized tool id stable when the vendor id arrives after start', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + // Name first (no id) → start emits with a synthesized id. + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { name: 'lookup', arguments: '{"q"' } }], + }, + }, + ], + } as any + // Vendor id arrives late — must not rename the started call. + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, id: 'call_real', function: { arguments: ':1}' } }], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } as any + })(), + { providerName: 'Loose', emitToolCallStarts: true, onComplete } + ) + + const events = await collectEvents(stream) + const start = events.find((e) => e.type === 'tool_call_start') + expect(start).toBeDefined() + expect(start!.id).not.toBe('call_real') + + // The assembled call keeps the same id as the emitted start. + const assembled = onComplete.mock.calls[0][0].toolCalls + expect(assembled).toHaveLength(1) + expect(assembled[0].id).toBe(start!.id) + expect(assembled[0].function).toEqual({ name: 'lookup', arguments: '{"q":1}' }) + }) + + it('always enqueues text deltas live and assembles content for onComplete', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'DeepSeek', onComplete } + ) + const events = await collectEvents(stream) + expect( + events + .filter((e) => e.type === 'text_delta') + .map((e) => e.text) + .join('') + ).toBe('Hello world') + expect(onComplete.mock.calls[0][0].content).toBe('Hello world') + }) +}) diff --git a/apps/sim/providers/openai-compat/stream-events.ts b/apps/sim/providers/openai-compat/stream-events.ts new file mode 100644 index 00000000000..cf4369bb120 --- /dev/null +++ b/apps/sim/providers/openai-compat/stream-events.ts @@ -0,0 +1,221 @@ +/** + * OpenAI Chat Completions → agent-events-v1. + * + * Capability-honest: emits `thinking_delta` only when the vendor streams a + * reasoning field on the delta (`reasoning_content`, `reasoning`, etc.). + * Non-reasoning models stay text-only. Tool_call starts emit when a name is + * known (ids are synthesized when the vendor omits them, so the assembled + * request stays self-consistent). Tool-call argument deltas are accumulated + * for tool-loop history. + */ + +import { createLogger } from '@sim/logger' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import type { AgentStreamEvent, TextDeltaTurn } from '@/providers/stream-events' +import { ensureToolCallId } from '@/providers/tool-call-id' + +export interface OpenAICompatAssembledToolCall { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +export interface OpenAICompatStreamComplete { + content: string + thinking: string + /** DeepSeek-style CoT field accumulated from deltas. */ + reasoning_content?: string + /** Groq-style reasoning field accumulated from deltas. */ + reasoning?: string + usage: CompletionUsage + /** Assembled when emitToolCallStarts is true (id + name + args). */ + toolCalls?: OpenAICompatAssembledToolCall[] + /** Last finish_reason observed on the stream (e.g. `tool_calls`, `stop`, `length`). */ + finishReason?: string +} + +export interface CreateOpenAICompatibleAgentEventStreamOptions { + providerName: string + /** Tag for answer text (default `final`). */ + turn?: TextDeltaTurn + /** Emit tool_call_start from delta.tool_calls when id+name known. Default false for no-tools path. */ + emitToolCallStarts?: boolean + onComplete?: (result: OpenAICompatStreamComplete) => void +} + +/** + * Chat Completions delta plus the vendor reasoning extensions the OpenAI SDK + * types don't carry (DeepSeek `reasoning_content`; Groq/OpenRouter `reasoning` + * as a string or `{ text }` object). All extensions are optional, so SDK + * deltas assign to this without casts. + */ +type CompatChunkDelta = ChatCompletionChunk.Choice.Delta & { + reasoning_content?: string + reasoning?: string | { text?: string } +} + +function extractDeltaReasoning(delta: CompatChunkDelta | undefined): { + text: string + reasoning_content?: string + reasoning?: string +} { + if (!delta) return { text: '' } + + if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) { + return { text: delta.reasoning_content, reasoning_content: delta.reasoning_content } + } + if (typeof delta.reasoning === 'string' && delta.reasoning) { + return { text: delta.reasoning, reasoning: delta.reasoning } + } + if ( + delta.reasoning && + typeof delta.reasoning === 'object' && + typeof delta.reasoning.text === 'string' + ) { + const text = delta.reasoning.text + return { text, reasoning: text } + } + return { text: '' } +} + +/** + * Converts an OpenAI-compatible chat.completions stream into an in-process + * {@link AgentStreamEvent} object stream. + */ +export function createOpenAICompatibleAgentEventStream( + stream: AsyncIterable, + options: CreateOpenAICompatibleAgentEventStreamOptions +): ReadableStream { + const { providerName, turn = 'final', emitToolCallStarts = false, onComplete } = options + const streamLogger = createLogger(`${providerName}Utils`) + + return new ReadableStream({ + async start(controller) { + let fullContent = '' + let fullThinking = '' + let reasoningContent = '' + let reasoning = '' + let promptTokens = 0 + let completionTokens = 0 + let totalTokens = 0 + let finishReason: string | undefined + const seenToolIds = new Set() + const toolBuffers = new Map< + number, + { id?: string; name?: string; args: string; started: boolean } + >() + + try { + for await (const chunk of stream) { + /** + * Groq puts stream usage under `x_groq.usage` on the final chunk + * instead of the OpenAI `usage` field; accept either shape. + */ + const usage = + chunk.usage ?? + (chunk as { x_groq?: { usage?: CompletionUsage } }).x_groq?.usage ?? + undefined + if (usage) { + promptTokens = usage.prompt_tokens ?? 0 + completionTokens = usage.completion_tokens ?? 0 + totalTokens = usage.total_tokens ?? 0 + } + + const choice = chunk.choices?.[0] + if (choice?.finish_reason) { + finishReason = choice.finish_reason + } + const delta: CompatChunkDelta | undefined = choice?.delta + + const extracted = extractDeltaReasoning(delta) + if (extracted.text) { + fullThinking += extracted.text + if (extracted.reasoning_content) reasoningContent += extracted.reasoning_content + if (extracted.reasoning) reasoning += extracted.reasoning + controller.enqueue({ type: 'thinking_delta', text: extracted.text }) + } + + const content = typeof delta?.content === 'string' ? delta.content : '' + if (content) { + fullContent += content + controller.enqueue({ type: 'text_delta', text: content, turn }) + } + + if (emitToolCallStarts && Array.isArray(delta?.tool_calls)) { + for (const tc of delta.tool_calls) { + // Loose vendors omit index on single-call streams; default to slot 0. + const index = typeof tc.index === 'number' ? tc.index : 0 + const buf = toolBuffers.get(index) ?? { + id: undefined, + name: undefined, + args: '', + started: false, + } + /** + * Never rename a call whose start already went out — a vendor id + * arriving after a synthesized one would orphan the emitted + * tool_call_start (its end would carry a different id). + */ + if (tc.id && !buf.started) buf.id = tc.id + if (tc.function?.name) buf.name = tc.function.name + /** + * Some compat vendors stream tool calls without ids. Synthesize + * an execution-local id as soon as the name is known so the call + * is not dropped — the assembled request only needs ids that are + * self-consistent between `tool_calls` and `tool` messages. + */ + if (!buf.id && buf.name) { + buf.id = ensureToolCallId(undefined, providerName.toLowerCase()) + } + if (typeof tc.function?.arguments === 'string') { + buf.args += tc.function.arguments + } + toolBuffers.set(index, buf) + + if (buf.id && buf.name && !buf.started && !seenToolIds.has(buf.id)) { + buf.started = true + seenToolIds.add(buf.id) + controller.enqueue({ type: 'tool_call_start', id: buf.id, name: buf.name }) + } + } + } + } + + if (onComplete) { + if (promptTokens === 0 && completionTokens === 0) { + streamLogger.warn(`${providerName} stream completed without usage data`) + } + const toolCalls: OpenAICompatAssembledToolCall[] = [] + if (emitToolCallStarts) { + for (const [, buf] of [...toolBuffers.entries()].sort(([a], [b]) => a - b)) { + if (!buf.id || !buf.name) continue + toolCalls.push({ + id: buf.id, + type: 'function', + function: { name: buf.name, arguments: buf.args || '{}' }, + }) + } + } + onComplete({ + content: fullContent, + thinking: fullThinking, + ...(reasoningContent ? { reasoning_content: reasoningContent } : {}), + ...(reasoning ? { reasoning } : {}), + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: totalTokens || promptTokens + completionTokens, + }, + ...(toolCalls.length > 0 ? { toolCalls } : {}), + ...(finishReason ? { finishReason } : {}), + }) + } + + controller.close() + } catch (error) { + controller.error(error) + } + }, + }) +} diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..80e263b9b81 --- /dev/null +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -0,0 +1,375 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { openaiCompatToolCallStartChunks } from '@/providers/__fixtures__/openai-compat' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { TimeSegment } from '@/providers/types' + +const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), + mockPrepareToolExecution: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers/utils', () => ({ + prepareToolExecution: mockPrepareToolExecution, + calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }), + sumToolCosts: () => 0, + /** Minimal faithful tracking: marks the forced tool used when the model called it. */ + trackForcedToolUsage: ( + toolCalls: Array<{ function?: { name?: string } }>, + toolChoice: unknown, + _logger: unknown, + _provider: unknown, + _forcedTools: string[], + usedForcedTools: string[] + ) => { + const forcedName = + toolChoice && typeof toolChoice === 'object' + ? (toolChoice as { function?: { name?: string } }).function?.name + : undefined + const usedNow = Boolean( + forcedName && toolCalls.some((toolCall) => toolCall.function?.name === forcedName) + ) + return { + hasUsedForcedTool: usedNow || usedForcedTools.length > 0, + usedForcedTools: usedNow && forcedName ? [...usedForcedTools, forcedName] : usedForcedTools, + } + }, +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +function toolThenAnswerChunks(toolName: string, args: string, answer: string) { + return [ + { + choices: [ + { + delta: { + reasoning_content: 'I should call the tool. ', + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: toolName, arguments: '' }, + }, + ], + }, + }, + ], + }, + { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: args } }], + }, + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + }, + // Second turn (final answer) — yielded by a separate createStream call + ] as const +} + +describe('createOpenAICompatStreamingToolLoopStream', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + beforeEach(() => { + mockExecuteTool.mockReset() + mockPrepareToolExecution.mockReset() + logger.info.mockReset() + mockPrepareToolExecution.mockImplementation((_tool: unknown, args: unknown) => ({ + toolParams: args, + executionParams: args, + })) + mockExecuteTool.mockResolvedValue({ + success: true, + output: { answer: 42 }, + }) + }) + + it('keeps reasoning_content on assistant history when preserveAssistantReasoning is true', async () => { + const messageHistory: unknown[][] = [] + let call = 0 + + const createStream = vi.fn(async (params: { messages: unknown[] }) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'done', reasoning_content: 'final thought' } }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const timeSegments: TimeSegment[] = [] + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + thinkingLevel: 'enabled', + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as any, + logger, + timeSegments, + preserveAssistantReasoning: true, + onComplete: () => {}, + }) + + await collectEvents(stream) + + expect(createStream).toHaveBeenCalledTimes(2) + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (m) => m.role === 'assistant' && Array.isArray(m.tool_calls) + ) + expect(assistantWithTools).toMatchObject({ + role: 'assistant', + reasoning_content: 'I should call the tool. ', + }) + }) + + it('omits reasoning_content when preserveAssistantReasoning is false', async () => { + const messageHistory: unknown[][] = [] + let call = 0 + + const createStream = vi.fn(async (params: { messages: unknown[] }) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'done' } }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Groq', + request: { + model: 'groq/openai/gpt-oss-120b', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + }, + basePayload: { model: 'openai/gpt-oss-120b' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as any, + logger, + timeSegments: [], + preserveAssistantReasoning: false, + onComplete: () => {}, + }) + + await collectEvents(stream) + + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (m) => m.role === 'assistant' && Array.isArray(m.tool_calls) + ) + expect(assistantWithTools).toBeDefined() + expect(assistantWithTools?.reasoning_content).toBeUndefined() + }) + + it('rotates forced tool_choice to auto after the forced tool is used', async () => { + const toolChoices: unknown[] = [] + let call = 0 + + const createStream = vi.fn(async (params: { tool_choice?: unknown }) => { + toolChoices.push(params.tool_choice) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'final answer' } }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + }, + basePayload: { + model: 'deepseek-chat', + tool_choice: { type: 'function', function: { name: 'lookup' } }, + }, + messages: [{ role: 'user', content: 'force lookup then answer' }], + createStream: createStream as any, + logger, + timeSegments: [], + forcedTools: ['lookup'], + onComplete: () => {}, + }) + ) + + expect(createStream).toHaveBeenCalledTimes(2) + expect(toolChoices[0]).toEqual({ type: 'function', function: { name: 'lookup' } }) + expect(toolChoices[1]).toBe('auto') + // Text streams live as `pending`; turn_end classifies each turn. + expect( + events.some( + (e) => e.type === 'text_delta' && e.text === 'final answer' && e.turn === 'pending' + ) + ).toBe(true) + expect(events.filter((e) => e.type === 'turn_end').map((e) => e.turn)).toEqual([ + 'intermediate', + 'final', + ]) + expect(logger.info).toHaveBeenCalledWith( + 'All forced tools have been used, switching to auto tool_choice' + ) + }) + + it('fails the call without executing when tool argument JSON is malformed', async () => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{"query": "unterminated', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'recovered' } }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as any, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + // Tool must not run with defaulted {} args; the call fails and the model + // gets the error result on the next turn. + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 'call_1', + name: 'lookup', + status: 'error', + }) + expect(createStream).toHaveBeenCalledTimes(2) + }) + + it('streams thinking live and assembles tool args from deltas', async () => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* openaiCompatToolCallStartChunks as any + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '"https://example.com"}' } }], + }, + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 6, total_tokens: 10 }, + } + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'fetched' } }], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, + } + })() + }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [ + { id: 'http_request', name: 'http_request', description: 'd', parameters: {} } as any, + ], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'fetch' }], + createStream: createStream as any, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'http_request', + { url: 'https://example.com' }, + expect.not.objectContaining({ skipPostProcess: true }) + ) + }) +}) diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.ts new file mode 100644 index 00000000000..170a6dbe25d --- /dev/null +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.ts @@ -0,0 +1,486 @@ +/** + * Shared OpenAI Chat Completions streaming tool loop. + * + * Capability-honest: reasoning deltas only when the vendor streams them. + * Tool ends in completion order; abort → cancelled. + * + * Streams each model turn live (thinking + tool_call_start + `pending` text + * deltas) and classifies the turn with a `turn_end` event — same contract as + * the Anthropic/Gemini/Bedrock loops. The pump projects pending text to the + * answer channel only for final turns. Tool args are assembled from streamed + * `tool_calls` deltas (no blocking hybrid). + */ + +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + createOpenAICompatibleAgentEventStream, + type OpenAICompatAssembledToolCall, +} from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + type StreamingToolLoopComplete, + settleOpenTools, +} from '@/providers/streaming-tool-loop-shared' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +export type OpenAICompatCreateCompletion = ( + params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + options?: { signal?: AbortSignal } +) => Promise> + +export interface CreateOpenAICompatStreamingToolLoopOptions { + providerName: string + request: ProviderRequest + /** Base chat.completions payload (messages, tools, model, …) without stream. */ + basePayload: Record + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] + createStream: OpenAICompatCreateCompletion + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + /** + * When true, keep vendor reasoning fields on assistant history messages + * during the tool loop (required by DeepSeek thinking + tools). + */ + preserveAssistantReasoning?: boolean + onComplete: (result: StreamingToolLoopComplete) => void +} + +function nextForcedToolChoice( + forcedTools: string[], + usedForcedTools: string[] +): 'auto' | { type: 'function'; function: { name: string } } { + const remaining = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + if (remaining.length === 0) return 'auto' + return { type: 'function', function: { name: remaining[0] } } +} + +/** + * Multi-turn OpenAI-compat tool loop as an agent-events-v1 object stream. + */ +export function createOpenAICompatStreamingToolLoopStream( + options: CreateOpenAICompatStreamingToolLoopOptions +): ReadableStream { + const { + providerName, + request, + basePayload, + messages, + createStream, + logger, + timeSegments, + onComplete, + preserveAssistantReasoning = false, + } = options + const forcedTools = options.forcedTools ?? [] + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...messages] + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + const streamOpts = request.abortSignal ? { signal: request.abortSignal } : undefined + + let currentToolChoice = basePayload.tool_choice + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const modelStart = Date.now() + const turnPayload = { + ...basePayload, + messages: currentMessages, + ...(currentToolChoice !== undefined ? { tool_choice: currentToolChoice } : {}), + stream: true as const, + } + + const stream = await createStream( + turnPayload as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + streamOpts + ) + + let turnUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } + let turnContent = '' + let turnReasoningContent = '' + let turnReasoning = '' + let turnFinishReason: string | undefined + let assembledTools: OpenAICompatAssembledToolCall[] = [] + const liveText: string[] = [] + + const eventStream = createOpenAICompatibleAgentEventStream(stream, { + providerName, + emitToolCallStarts: true, + onComplete: (result) => { + turnUsage = { + prompt_tokens: result.usage.prompt_tokens ?? 0, + completion_tokens: result.usage.completion_tokens ?? 0, + total_tokens: result.usage.total_tokens ?? 0, + } + turnContent = result.content || '' + turnReasoningContent = result.reasoning_content || '' + turnReasoning = result.reasoning || '' + turnFinishReason = result.finishReason + assembledTools = result.toolCalls ?? [] + }, + }) + + { + const reader = eventStream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + if (value.type === 'thinking_delta' || value.type === 'tool_call_start') { + if (value.type === 'tool_call_start') { + openToolStarts.set(value.id, value.name) + } + controller.enqueue(value) + } else if (value.type === 'text_delta') { + liveText.push(value.text) + // Live pending text: sinks render it now; the pump projects it + // to the answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: value.text, turn: 'pending' }) + } + } + } + + /** + * Only execute tools when the turn completed normally. A `length` + * finish means the stream truncated mid-generation — assembled tool + * arguments would be partial JSON. + */ + const toolsExecutable = turnFinishReason !== 'length' + const assembledPendingTools = assembledTools.filter((tc) => tc.id && tc.function?.name) + if (assembledPendingTools.length > 0 && !toolsExecutable) { + logger.warn('Skipping tool execution for truncated turn', { + finishReason: turnFinishReason, + toolCount: assembledPendingTools.length, + }) + settleOpenTools(controller, openToolStarts, 'error') + } + const pendingTools = toolsExecutable ? assembledPendingTools : [] + const turnTag = pendingTools.length > 0 ? 'intermediate' : 'final' + const turnText = turnContent || liveText.join('') + // If the parser assembled text but we somehow missed deltas, still emit + // it before the boundary so the turn_end classification covers it. + if (turnText && liveText.length === 0) { + controller.enqueue({ type: 'text_delta', text: turnText, turn: 'pending' }) + } + controller.enqueue({ type: 'turn_end', turn: turnTag }) + if (turnText) { + // Keep the latest turn's text so a MAX_TOOL_ITERATIONS exit still has content. + content = turnText + } + + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) firstResponseTime = thisModelTime + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + tokens.input += turnUsage.prompt_tokens + tokens.output += turnUsage.completion_tokens + tokens.total += + turnUsage.total_tokens || turnUsage.prompt_tokens + turnUsage.completion_tokens + + if (pendingTools.length === 0) { + sawFinalTurn = true + break + } + + if ( + typeof currentToolChoice === 'object' && + currentToolChoice !== null && + pendingTools.length > 0 + ) { + const tracked = trackForcedToolUsage( + pendingTools, + currentToolChoice, + logger, + providerName.toLowerCase(), + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = tracked.hasUsedForcedTool + usedForcedTools = tracked.usedForcedTools + } + + const assistantHistory: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam & { + reasoning_content?: string + reasoning?: string + } = { + role: 'assistant', + content: turnText || null, + tool_calls: pendingTools, + } + if (preserveAssistantReasoning) { + if (turnReasoningContent) { + assistantHistory.reasoning_content = turnReasoningContent + } + if (turnReasoning) { + assistantHistory.reasoning = turnReasoning + } + } + currentMessages.push(assistantHistory) + + const toolsStartTime = Date.now() + const orderedResults = await Promise.all( + pendingTools.map(async (tc) => { + const toolCallStartTime = Date.now() + const toolName = tc.function.name + const toolUseId = tc.id + /** + * Malformed argument JSON must not execute the tool — running it + * with defaulted `{}` args could fire side effects with missing + * parameters. Fail the call and let the model react to the error. + */ + let toolArgs: Record = {} + let argsParseError = false + try { + toolArgs = JSON.parse(tc.function.arguments || '{}') + } catch { + argsParseError = true + } + if (argsParseError) { + const endTime = Date.now() + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + return { + toolUseId, + toolName, + toolArgs: {}, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Invalid tool arguments JSON for ${toolName}`, + }, + startTime: toolCallStartTime, + endTime, + duration: endTime - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + } + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + const value = { + toolUseId, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (result.success ? 'success' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: value.status, + }) + return value + } catch (error) { + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + if (!cancelled) { + logger.error('Error processing tool call:', { error, toolName }) + } + const toolCallEndTime = Date.now() + const value = { + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (cancelled ? 'cancelled' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: value.status, + }) + return value + } + }) + ) + + for (const value of orderedResults) { + timeSegments.push({ + type: 'tool', + name: value.toolName, + startTime: value.startTime, + endTime: value.endTime, + duration: value.duration, + toolCallId: value.toolUseId, + }) + + let resultContent: unknown + if (value.result.success && value.result.output) { + toolResults.push(value.result.output as Record) + resultContent = value.result.output + } else { + resultContent = { + error: true, + message: value.result.error || 'Tool execution failed', + tool: value.toolName, + } + } + + toolCalls.push({ + name: value.toolName, + arguments: value.toolParams, + startTime: new Date(value.startTime).toISOString(), + endTime: new Date(value.endTime).toISOString(), + duration: value.duration, + result: resultContent, + success: value.result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: value.toolUseId, + content: JSON.stringify(resultContent), + }) + } + + toolsTime += Date.now() - toolsStartTime + + // Rotate / clear forced tool_choice so the model can answer after forced tools. + if (typeof currentToolChoice === 'object' && currentToolChoice !== null) { + if (hasUsedForcedTool && forcedTools.length > 0) { + const next = nextForcedToolChoice(forcedTools, usedForcedTools) + currentToolChoice = next + if (next === 'auto') { + logger.info('All forced tools have been used, switching to auto tool_choice') + } else { + logger.info(`Forcing next tool: ${next.function.name}`) + } + } + } + + iterationCount++ + } + + /** + * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the + * answer channel would otherwise be empty. Flush the last turn's text + * as the final answer so legacy consumers still receive content. + */ + if (!sawFinalTurn && content) { + controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + } + + const modelCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCostTotal = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: modelCost.input, + output: modelCost.output, + total: modelCost.total + (toolCostTotal || 0), + ...(toolCostTotal ? { toolCost: toolCostTotal } : {}), + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + controller.close() + } catch (error) { + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + settleOpenTools(controller, openToolStarts, cancelled ? 'cancelled' : 'error') + controller.error(toError(error)) + } + }, + }) +} diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts new file mode 100644 index 00000000000..887dcfbf766 --- /dev/null +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -0,0 +1,165 @@ +/** + * @vitest-environment node + * + * OpenAI Responses reasoning payload: summaries are requested on agent-events + * runs and whenever an explicit effort is set (staging parity), legacy runs + * without explicit effort keep a reasoning-free payload, and the + * unverified-organization 400 falls back to a summary-free retry. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeResponsesProviderRequest } from '@/providers/openai/core' +import type { ProviderRequest } from '@/providers/types' + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/utils', () => ({ + calculateCost: () => ({ input: 0, output: 0, total: 0 }), + sumToolCosts: () => 0, + enforceStrictSchema: (schema: unknown) => schema, + prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }), + prepareToolsWithUsageControl: () => ({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), + supportsReasoningEffort: (model: string) => ['gpt-5.5', 'o3'].includes(model), +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +const COMPLETED_RESPONSE = { + id: 'resp_1', + status: 'completed', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'hello' }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, +} + +describe('executeResponsesProviderRequest reasoning payload', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE)) + }) + + function run(request: Partial & { model: string }) { + return executeResponsesProviderRequest( + { + apiKey: 'k', + messages: [{ role: 'user', content: 'hi' }], + ...request, + }, + { + providerId: 'openai', + providerLabel: 'OpenAI', + modelName: request.model, + endpoint: 'https://api.openai.com/v1/responses', + headers: { Authorization: 'Bearer k' }, + logger, + fetch: fetchMock as unknown as typeof fetch, + } + ) + } + + describe('agent-events runs', () => { + it('requests reasoning.summary auto when effort is auto', async () => { + await run({ model: 'gpt-5.5', agentEvents: true, reasoningEffort: 'auto' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto' }) + }) + + it('requests reasoning.summary auto when effort is unset', async () => { + await run({ model: 'o3', agentEvents: true }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto' }) + }) + + it('requests summary and effort when effort is explicit', async () => { + await run({ model: 'gpt-5.5', agentEvents: true, reasoningEffort: 'high' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto', effort: 'high' }) + }) + + it('retries without summary when the organization is not verified', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { + error: { + message: + "Your organization must be verified to generate reasoning summaries. Please go to: https://platform.openai.com/settings/organization/general and click on Verify Organization. (param: 'reasoning.summary')", + param: 'reasoning.summary', + code: 'unsupported_value', + }, + }, + 400 + ) + ) + .mockResolvedValueOnce(jsonResponse(COMPLETED_RESPONSE)) + + const result = await run({ model: 'o3', agentEvents: true, reasoningEffort: 'high' }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body as string) + expect(retryBody.reasoning).toEqual({ effort: 'high' }) + expect((result as { content: string }).content).toBe('hello') + }) + + it('does not retry on unrelated 400s', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ error: { message: 'Invalid value for input' } }, 400) + ) + await expect(run({ model: 'o3', agentEvents: true })).rejects.toThrow('Invalid value') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + }) + + describe('legacy runs (no agent events)', () => { + it('omits reasoning entirely when effort is unset', async () => { + await run({ model: 'o3' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toBeUndefined() + }) + + it('omits reasoning when effort is auto', async () => { + await run({ model: 'gpt-5.5', reasoningEffort: 'auto' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toBeUndefined() + }) + + it('keeps the staging payload (effort + summary) when effort is explicit', async () => { + // Pre-agent-events payloads always paired summary:'auto' with an + // explicit effort — legacy runs must stay byte-identical. + await run({ model: 'gpt-5.5', reasoningEffort: 'high' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto', effort: 'high' }) + }) + }) + + it('omits reasoning for non-reasoning models', async () => { + await run({ model: 'gpt-4o', agentEvents: true }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 913700ef5d7..ce8b1c2284b 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -14,6 +14,7 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, + supportsReasoningEffort, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -98,10 +99,23 @@ export async function executeResponsesProviderRequest( if (request.temperature !== undefined) basePayload.temperature = request.temperature if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens - if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { - basePayload.reasoning = { - effort: request.reasoningEffort, - summary: 'auto', + /** + * Reasoning summaries feed Thinking chrome. They are requested when an + * explicit effort is set (pre-agent-events payload always paired + * `summary: 'auto'` with `effort` — kept for parity) and on agent-events + * runs even without an explicit effort. Summaries require OpenAI + * organization verification; see the strip-and-retry fallback in the + * request helpers below. + */ + if (supportsReasoningEffort(config.modelName)) { + const hasExplicitEffort = + request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto' + const reasoning: Record = { + ...(request.agentEvents === true || hasExplicitEffort ? { summary: 'auto' } : {}), + ...(hasExplicitEffort ? { effort: request.reasoningEffort } : {}), + } + if (Object.keys(reasoning).length > 0) { + basePayload.reasoning = reasoning } } @@ -211,21 +225,69 @@ export async function executeResponsesProviderRequest( } } - const postResponses = async ( + /** + * OpenAI rejects `reasoning.summary` with a 400 for organizations that have + * not completed verification. Summaries are best-effort chrome, so on that + * specific failure the request is retried once without the summary field + * rather than failing the run. + */ + const isReasoningSummaryVerificationError = (status: number, message: string): boolean => + status === 400 && + message.includes('reasoning.summary') && + message.toLowerCase().includes('verif') + + const stripReasoningSummary = (body: Record): Record | null => { + const reasoning = body.reasoning as Record | undefined + if (!reasoning || reasoning.summary === undefined) return null + const { summary: _summary, ...reasoningRest } = reasoning + const { reasoning: _reasoning, ...bodyRest } = body + return Object.keys(reasoningRest).length > 0 + ? { ...bodyRest, reasoning: reasoningRest } + : bodyRest + } + + const fetchResponsesWithSummaryFallback = async ( body: Record - ): Promise => { + ): Promise => { const response = await fetchImpl(config.endpoint, { method: 'POST', headers: config.headers, body: JSON.stringify(body), signal: request.abortSignal, }) + if (response.ok) return response - if (!response.ok) { - const message = await parseErrorResponse(response) + const message = await parseErrorResponse(response) + const strippedBody = isReasoningSummaryVerificationError(response.status, message) + ? stripReasoningSummary(body) + : null + if (!strippedBody) { throw new Error(`${config.providerLabel} API error (${response.status}): ${message}`) } + logger.warn( + `${config.providerLabel} rejected reasoning summaries (organization not verified); retrying without summary`, + { model: config.modelName } + ) + const retryResponse = await fetchImpl(config.endpoint, { + method: 'POST', + headers: config.headers, + body: JSON.stringify(strippedBody), + signal: request.abortSignal, + }) + if (!retryResponse.ok) { + const retryMessage = await parseErrorResponse(retryResponse) + throw new Error( + `${config.providerLabel} API error (${retryResponse.status}): ${retryMessage}` + ) + } + return retryResponse + } + + const postResponses = async ( + body: Record + ): Promise => { + const response = await fetchResponsesWithSummaryFallback(body) return response.json() } @@ -236,17 +298,9 @@ export async function executeResponsesProviderRequest( if (request.stream && (!tools || tools.length === 0)) { logger.info(`Using streaming response for ${config.providerLabel} request`) - const streamResponse = await fetchImpl(config.endpoint, { - method: 'POST', - headers: config.headers, - body: JSON.stringify(createRequestBody(initialInput, { stream: true })), - signal: request.abortSignal, - }) - - if (!streamResponse.ok) { - const message = await parseErrorResponse(streamResponse) - throw new Error(`${config.providerLabel} API error (${streamResponse.status}): ${message}`) - } + const streamResponse = await fetchResponsesWithSummaryFallback( + createRequestBody(initialInput, { stream: true }) + ) const streamingResult = createStreamingExecution({ model: request.model, @@ -255,8 +309,9 @@ export async function executeResponsesProviderRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromResponses(streamResponse, (content, usage) => { + createReadableStreamFromResponses(streamResponse, (content, usage, thinking) => { output.content = content output.tokens = { input: usage?.promptTokens || 0, @@ -275,6 +330,14 @@ export async function executeResponsesProviderRequest( total: costResult.total, } + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + // Label honestly: these are reasoning *summaries*, not raw CoT. + segment.thinkingContent = thinking + } + } + finalizeTiming() }), }) @@ -582,11 +645,8 @@ export async function executeResponsesProviderRequest( // Copy over non-tool related settings if (request.temperature !== undefined) finalPayload.temperature = request.temperature if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens - if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { - finalPayload.reasoning = { - effort: request.reasoningEffort, - summary: 'auto', - } + if (supportsReasoningEffort(config.modelName) && basePayload.reasoning) { + finalPayload.reasoning = basePayload.reasoning } if (request.verbosity !== undefined && request.verbosity !== 'auto') { finalPayload.text = { @@ -650,17 +710,9 @@ export async function executeResponsesProviderRequest( } } - const streamResponse = await fetchImpl(config.endpoint, { - method: 'POST', - headers: config.headers, - body: JSON.stringify(createRequestBody(currentInput, streamOverrides)), - signal: request.abortSignal, - }) - - if (!streamResponse.ok) { - const message = await parseErrorResponse(streamResponse) - throw new Error(`${config.providerLabel} API error (${streamResponse.status}): ${message}`) - } + const streamResponse = await fetchResponsesWithSummaryFallback( + createRequestBody(currentInput, streamOverrides) + ) const streamingResult = createStreamingExecution({ model: request.model, @@ -681,8 +733,9 @@ export async function executeResponsesProviderRequest( total: accumulatedCost.total, }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromResponses(streamResponse, (content, usage) => { + createReadableStreamFromResponses(streamResponse, (content, usage, thinking) => { output.content = content output.tokens = { input: tokens.input + (usage?.promptTokens || 0), @@ -702,6 +755,13 @@ export async function executeResponsesProviderRequest( toolCost: tc || undefined, total: accumulatedCost.total + streamCost.total + tc, } + + if (thinking) { + const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') + if (lastModel) { + lastModel.thinkingContent = thinking + } + } }), }) diff --git a/apps/sim/providers/openai/utils.stream.test.ts b/apps/sim/providers/openai/utils.stream.test.ts new file mode 100644 index 00000000000..ed8b74899a8 --- /dev/null +++ b/apps/sim/providers/openai/utils.stream.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromResponses } from '@/providers/openai/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +function sseResponse(events: Array<{ event?: string; data: unknown }>): Response { + const body = events + .map((e) => { + const lines = [] + if (e.event) lines.push(`event: ${e.event}`) + lines.push(`data: ${JSON.stringify(e.data)}`) + return `${lines.join('\n')}\n\n` + }) + .join('') + return new Response(body, { headers: { 'Content-Type': 'text/event-stream' } }) +} + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromResponses', () => { + it('emits reasoning summary deltas as thinking and output_text as final text', async () => { + const onComplete = vi.fn() + const response = sseResponse([ + { + event: 'response.reasoning_summary_text.delta', + data: { type: 'response.reasoning_summary_text.delta', delta: 'Summary thought. ' }, + }, + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Answer' }, + }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 4, output_tokens: 6 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response, onComplete)) + expect(events).toEqual([ + { type: 'thinking_delta', text: 'Summary thought. ' }, + { type: 'text_delta', text: 'Answer', turn: 'final' }, + ]) + expect(onComplete.mock.calls[0][0]).toBe('Answer') + expect(onComplete.mock.calls[0][2]).toBe('Summary thought. ') + }) + + it('stays text-only when no reasoning summary events arrive', async () => { + const response = sseResponse([ + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Hi' }, + }, + ]) + const events = await collectEvents(createReadableStreamFromResponses(response)) + expect(events).toEqual([{ type: 'text_delta', text: 'Hi', turn: 'final' }]) + }) +}) diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index 495a0eae05b..2aaf3cd0ad9 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type OpenAI from 'openai' import { buildOpenAIMessageContent } from '@/providers/attachments' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { Message } from '@/providers/types' const logger = createLogger('ResponsesUtils') @@ -227,6 +228,7 @@ export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutput const parts: string[] = [] for (const item of output) { if (!item || item.type !== 'reasoning') continue + // double-cast-allowed: the Responses SDK types summary entries as non-null, but the wire can carry null entries/text on partial reasoning items; widen to read defensively const summary = (item as unknown as { summary?: Array<{ text?: string | null } | null> }) .summary if (!Array.isArray(summary)) continue @@ -411,18 +413,23 @@ export function parseResponsesUsage( } /** - * Creates a ReadableStream from a Responses API SSE stream. + * Creates an agent-events-v1 stream from a Responses API SSE stream. + * + * Capability-honest: emits `thinking_delta` only for streamable reasoning + * *summary* deltas (not encrypted_content / raw CoT). If the API only + * surfaces reasoning at completion, live thinking may be empty — traces still + * use extractResponseReasoning post-hoc. */ export function createReadableStreamFromResponses( response: Response, - onComplete?: (content: string, usage?: ResponsesUsageTokens) => void -): ReadableStream { + onComplete?: (content: string, usage?: ResponsesUsageTokens, thinking?: string) => void +): ReadableStream { let fullContent = '' + let fullThinking = '' let finalUsage: ResponsesUsageTokens | undefined let activeEventType: string | undefined - const encoder = new TextEncoder() - return new ReadableStream({ + return new ReadableStream({ async start(controller) { const reader = response.body?.getReader() if (!reader) { @@ -488,6 +495,26 @@ export function createReadableStreamFromResponses( return } + // Reasoning *summaries* only (`response.reasoning_summary_text.delta` + // per the Responses streaming reference) — never raw reasoning + // (`response.reasoning_text.delta`) and never encrypted_content. + if (eventType === 'response.reasoning_summary_text.delta') { + let deltaText = '' + const delta = event.delta as string | Record | undefined + if (typeof delta === 'string') { + deltaText = delta + } else if (delta && typeof delta.text === 'string') { + deltaText = delta.text + } else if (typeof event.text === 'string') { + deltaText = event.text + } + if (deltaText.length > 0) { + fullThinking += deltaText + controller.enqueue({ type: 'thinking_delta', text: deltaText }) + } + continue + } + if ( eventType === 'response.output_text.delta' || eventType === 'response.output_json.delta' @@ -508,7 +535,7 @@ export function createReadableStreamFromResponses( if (deltaText.length > 0) { fullContent += deltaText - controller.enqueue(encoder.encode(deltaText)) + controller.enqueue({ type: 'text_delta', text: deltaText, turn: 'final' }) } } @@ -523,7 +550,7 @@ export function createReadableStreamFromResponses( } if (onComplete) { - onComplete(fullContent, finalUsage) + onComplete(fullContent, finalUsage, fullThinking || undefined) } controller.close() diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 8821483fa4e..0508b28c2ce 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -169,6 +169,7 @@ export const openRouterProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -471,6 +472,7 @@ export const openRouterProvider: ProviderConfig = { }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/openrouter/utils.ts b/apps/sim/providers/openrouter/utils.ts index 51637f5148d..2f8a7850fc9 100644 --- a/apps/sim/providers/openrouter/utils.ts +++ b/apps/sim/providers/openrouter/utils.ts @@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' const logger = createLogger('OpenRouterUtils') @@ -86,9 +88,14 @@ export async function supportsNativeStructuredOutputs(modelId: string): Promise< export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'OpenRouter', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'OpenRouter', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } export function checkForForcedToolUsage( diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 3988d3e96db..5b9e6e54e52 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -150,26 +151,31 @@ export const sakanaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromSakanaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult @@ -516,28 +522,33 @@ export const sakanaProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromSakanaStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/sakana/utils.ts b/apps/sim/providers/sakana/utils.ts index ede98301a12..ba8b42329cf 100644 --- a/apps/sim/providers/sakana/utils.ts +++ b/apps/sim/providers/sakana/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Sakana AI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Sakana AI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromSakanaStream( sakanaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(sakanaStream, 'Sakana', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(sakanaStream, { + providerName: 'Sakana', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/stream-events.test.ts b/apps/sim/providers/stream-events.test.ts new file mode 100644 index 00000000000..6f497d5eb3f --- /dev/null +++ b/apps/sim/providers/stream-events.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type AgentStreamEvent, + createAgentEventReadableStream, + isAgentStreamEvent, + isTextDeltaTurn, + isToolCallEndStatus, +} from '@/providers/stream-events' + +describe('stream-events contract', () => { + describe('isAgentStreamEvent', () => { + it('accepts valid event variants', () => { + const events: AgentStreamEvent[] = [ + { type: 'text_delta', text: 'hi' }, + { type: 'text_delta', text: 'mid', turn: 'intermediate' }, + { type: 'text_delta', text: 'bye', turn: 'final' }, + { type: 'text_delta', text: 'live', turn: 'pending' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'turn_end', turn: 'final' }, + { type: 'thinking_delta', text: 'hmm' }, + { type: 'tool_call_start', id: 't1', name: 'search' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'success' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'error' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'cancelled' }, + ] + + for (const event of events) { + expect(isAgentStreamEvent(event)).toBe(true) + } + }) + + it('rejects malformed events', () => { + expect(isAgentStreamEvent(null)).toBe(false) + expect(isAgentStreamEvent({ type: 'error', message: 'x' })).toBe(false) + expect(isAgentStreamEvent({ type: 'text_delta' })).toBe(false) + expect(isAgentStreamEvent({ type: 'text_delta', text: 'x', turn: 'other' })).toBe(false) + // turn_end classifies a settled turn — 'pending' is not a valid classification. + expect(isAgentStreamEvent({ type: 'turn_end', turn: 'pending' })).toBe(false) + expect(isAgentStreamEvent({ type: 'turn_end' })).toBe(false) + expect(isAgentStreamEvent({ type: 'tool_call_start', id: 't1' })).toBe(false) + expect( + isAgentStreamEvent({ type: 'tool_call_end', id: 't1', name: 'search', status: 'ok' }) + ).toBe(false) + }) + }) + + describe('status and turn guards', () => { + it('validates tool end statuses and text turns', () => { + expect(isToolCallEndStatus('success')).toBe(true) + expect(isToolCallEndStatus('failed')).toBe(false) + expect(isTextDeltaTurn('final')).toBe(true) + expect(isTextDeltaTurn('first')).toBe(false) + }) + }) + + describe('createAgentEventReadableStream', () => { + it('enqueues events in order and closes', async () => { + const events: AgentStreamEvent[] = [ + { type: 'thinking_delta', text: 'a' }, + { type: 'text_delta', text: 'b', turn: 'final' }, + { type: 'tool_call_start', id: '1', name: 'lookup' }, + { type: 'tool_call_end', id: '1', name: 'lookup', status: 'success' }, + ] + + const stream = createAgentEventReadableStream(events) + const reader = stream.getReader() + const received: AgentStreamEvent[] = [] + + while (true) { + const { done, value } = await reader.read() + if (done) break + received.push(value) + } + + expect(received).toEqual(events) + }) + + it('errors when an invalid object is yielded', async () => { + async function* bad() { + yield { type: 'text_delta', text: 'ok' } as AgentStreamEvent + yield { type: 'nope' } as unknown as AgentStreamEvent + } + + const stream = createAgentEventReadableStream(bad()) + const reader = stream.getReader() + + await expect(reader.read()).resolves.toMatchObject({ + done: false, + value: { type: 'text_delta', text: 'ok' }, + }) + await expect(reader.read()).rejects.toThrow(/Invalid AgentStreamEvent/) + }) + }) +}) diff --git a/apps/sim/providers/stream-events.ts b/apps/sim/providers/stream-events.ts new file mode 100644 index 00000000000..f96608dc67d --- /dev/null +++ b/apps/sim/providers/stream-events.ts @@ -0,0 +1,142 @@ +/** + * Canonical agent stream event contract (provider → executor). + * + * Providers with `streamFormat: 'agent-events-v1'` return a + * `ReadableStream` (in-process object stream — not NDJSON). + * Legacy providers keep `streamFormat: 'text'` and `ReadableStream`. + * + * The executor is the only consumer that projects these events into a text + * stream + optional sink; downstream SSE/chat must not re-parse provider streams. + */ + +export type AgentStreamFormat = 'text' | 'agent-events-v1' + +export type ToolCallEndStatus = 'success' | 'error' | 'cancelled' + +export type TextDeltaTurn = 'intermediate' | 'final' + +/** + * Classification of a `text_delta`: + * - `'final'` / omitted — answer text; the pump projects it to the byte stream + * immediately (single-turn adapters, MAX-iterations flush, legacy text streams). + * - `'intermediate'` — pre-tool commentary flushed at turn end; never projected. + * - `'pending'` — live text from a streaming tool loop whose turn is not yet + * classified. The pump buffers it per turn and projects it only when the + * matching `turn_end` arrives with `turn: 'final'`. Sinks receive it live so + * opted-in clients can render the answer as it streams. + */ +export type TextDeltaClassification = TextDeltaTurn | 'pending' + +export type AgentStreamEvent = + | { type: 'text_delta'; text: string; turn?: TextDeltaClassification } + | { + /** + * Emitted by streaming tool loops when a model turn resolves. Classifies + * every `'pending'` text_delta since the previous turn boundary: + * `'final'` keeps the text (pump projects it to the byte stream), + * `'intermediate'` discards it (tools follow; sinks should clear any + * provisionally rendered text). + */ + type: 'turn_end' + turn: TextDeltaTurn + } + | { type: 'thinking_delta'; text: string } + | { type: 'tool_call_start'; id: string; name: string } + | { + type: 'tool_call_end' + id: string + name: string + status: ToolCallEndStatus + } + +/** Optional sink the executor pump pushes the full ordered timeline into. */ +export type AgentStreamSink = { + onEvent: (event: AgentStreamEvent) => void | Promise +} + +export type UnsubscribeAgentStreamSink = () => void + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isToolCallEndStatus(value: unknown): value is ToolCallEndStatus { + return value === 'success' || value === 'error' || value === 'cancelled' +} + +export function isTextDeltaTurn(value: unknown): value is TextDeltaTurn { + return value === 'intermediate' || value === 'final' +} + +export function isTextDeltaClassification(value: unknown): value is TextDeltaClassification { + return isTextDeltaTurn(value) || value === 'pending' +} + +export function isAgentStreamEvent(value: unknown): value is AgentStreamEvent { + if (!isRecord(value) || typeof value.type !== 'string') { + return false + } + + switch (value.type) { + case 'text_delta': + return ( + typeof value.text === 'string' && + (value.turn === undefined || isTextDeltaClassification(value.turn)) + ) + case 'turn_end': + return isTextDeltaTurn(value.turn) + case 'thinking_delta': + return typeof value.text === 'string' + case 'tool_call_start': + return typeof value.id === 'string' && typeof value.name === 'string' + case 'tool_call_end': + return ( + typeof value.id === 'string' && + typeof value.name === 'string' && + isToolCallEndStatus(value.status) + ) + default: + return false + } +} + +/** + * Builds a {@link ReadableStream} that enqueues the given agent events in order. + * Intended for tests and provider adapters that already have an event sequence. + */ +export function createAgentEventReadableStream( + events: Iterable | AsyncIterable +): ReadableStream { + const iterator = + Symbol.asyncIterator in events + ? events[Symbol.asyncIterator]() + : (async function* () { + for (const event of events as Iterable) { + yield event + } + })() + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await iterator.next() + if (done) { + controller.close() + return + } + if (!isAgentStreamEvent(value)) { + controller.error(new Error('Invalid AgentStreamEvent enqueued on object stream')) + return + } + controller.enqueue(value) + } catch (error) { + controller.error(error) + } + }, + async cancel(reason) { + if (typeof iterator.return === 'function') { + await iterator.return(reason) + } + }, + }) +} diff --git a/apps/sim/providers/stream-pump.test.ts b/apps/sim/providers/stream-pump.test.ts new file mode 100644 index 00000000000..ecd7aa64b08 --- /dev/null +++ b/apps/sim/providers/stream-pump.test.ts @@ -0,0 +1,665 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + type AgentStreamEvent, + type AgentStreamSink, + createAgentEventReadableStream, +} from '@/providers/stream-events' +import { createAgentStreamPump, DEFAULT_MAX_THINKING_CHARS } from '@/providers/stream-pump' + +async function readAllText(stream: ReadableStream | null): Promise { + if (!stream) return '' + const reader = stream.getReader() + const decoder = new TextDecoder() + let text = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + text += decoder.decode(value, { stream: true }) + } + text += decoder.decode() + return text +} + +function collectingSink() { + const events: AgentStreamEvent[] = [] + const sink: AgentStreamSink = { + onEvent: async (event) => { + events.push(event) + }, + } + return { sink, events } +} + +describe('createAgentStreamPump', () => { + it('projects agent-events to final-turn answer text and full sink timeline', async () => { + const events: AgentStreamEvent[] = [ + { type: 'thinking_delta', text: 'plan ' }, + { type: 'thinking_delta', text: 'it' }, + { type: 'text_delta', text: 'Looking up…', turn: 'intermediate' }, + { type: 'tool_call_start', id: '1', name: 'search' }, + { type: 'tool_call_end', id: '1', name: 'search', status: 'success' }, + { type: 'text_delta', text: 'Done.', turn: 'final' }, + ] + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream(events), + streamFormat: 'agent-events-v1', + }) + const { sink, events: seen } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + const text = await textPromise + + expect(result.answerText).toBe('Done.') + expect(result.fullyDrained).toBe(true) + expect(text).toBe('Done.') + expect(seen).toEqual(events) + }) + + it('buffers pending text per turn and projects it only on turn_end final', async () => { + const events: AgentStreamEvent[] = [ + { type: 'text_delta', text: 'Let me ', turn: 'pending' }, + { type: 'text_delta', text: 'check…', turn: 'pending' }, + { type: 'tool_call_start', id: '1', name: 'search' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'tool_call_end', id: '1', name: 'search', status: 'success' }, + { type: 'text_delta', text: 'Answer ', turn: 'pending' }, + { type: 'text_delta', text: 'here.', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ] + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream(events), + streamFormat: 'agent-events-v1', + }) + const { sink, events: seen } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + const text = await textPromise + + // Intermediate turn discarded; only the final turn reaches the answer. + expect(result.answerText).toBe('Answer here.') + expect(text).toBe('Answer here.') + // Sinks see the full live timeline including pending deltas + boundaries. + expect(seen).toEqual(events) + }) + + it('folds dangling pending text into answerText when the drain ends without turn_end', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: 'partial answer', turn: 'pending' }, + ]), + streamFormat: 'agent-events-v1', + }) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('partial answer') + expect(await textPromise).toBe('partial answer') + }) + + it('abort releases a drain blocked on byte backpressure (no deadlock)', async () => { + const controller = new AbortController() + // Enough text to exceed the byte stream's high-water mark with no reader pulling. + const bigChunk = 'x'.repeat(4096) + const source = new ReadableStream({ + start(c) { + for (let i = 0; i < 8; i++) { + c.enqueue({ type: 'text_delta', text: bigChunk, turn: 'final' }) + } + // Never closes — the only way out is the abort. + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + abortSignal: controller.signal, + }) + + // Intentionally never read pump.textStream — the consumer stalled without cancelling. + const runPromise = pump.run() + setTimeout(() => controller.abort('user'), 10) + + const result = await runPromise + expect(result.cancelled).toBe(true) + expect(result.cancelReason).toBe('user') + }) + + it('keeps pending text in answerText on abort so logs match what clients rendered', async () => { + const controller = new AbortController() + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'text_delta', text: 'seen live', turn: 'pending' }) + queueMicrotask(() => controller.abort('user')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(result.answerText).toBe('seen live') + }) + + it('treats legacy text streams as final-turn answer bytes and sink text_delta', async () => { + const encoder = new TextEncoder() + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('Hel')) + controller.enqueue(encoder.encode('lo')) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'text' }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('Hello') + expect(await textPromise).toBe('Hello') + expect(events).toEqual([ + { type: 'text_delta', text: 'Hel', turn: 'final' }, + { type: 'text_delta', text: 'lo', turn: 'final' }, + ]) + }) + + it('handles UTF-8 characters split across byte chunks', async () => { + // € in UTF-8 is E2 82 AC — split across two chunks + const source = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(0xe2, 0x82)) + controller.enqueue(Uint8Array.of(0xac, 0x20, 0x6f, 0x6b)) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'text' }) + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('€ ok') + expect(await textPromise).toBe('€ ok') + }) + + it('drops thinking when no sink is subscribed (no unbounded thinking buffer)', async () => { + const hugeThinking = 'x'.repeat(50_000) + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'thinking_delta', text: hugeThinking }, + { type: 'text_delta', text: 'hi', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('hi') + expect(await textPromise).toBe('hi') + }) + + it('caps thinking forwarded to sinks', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'thinking_delta', text: 'abcdef' }, + { type: 'thinking_delta', text: 'ghijkl' }, + { type: 'text_delta', text: 'out', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + maxThinkingChars: 4, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + await pump.run() + await textPromise + + expect(events.filter((e) => e.type === 'thinking_delta')).toEqual([ + { type: 'thinking_delta', text: 'abcd' }, + ]) + expect(DEFAULT_MAX_THINKING_CHARS).toBeGreaterThan(0) + }) + + it('allows late subscribers to receive only future events', async () => { + let pullCount = 0 + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + + const source = new ReadableStream({ + async pull(controller) { + pullCount += 1 + if (pullCount === 1) { + controller.enqueue({ type: 'thinking_delta', text: 'early' }) + await firstGate + return + } + if (pullCount === 2) { + controller.enqueue({ type: 'text_delta', text: 'late', turn: 'final' }) + return + } + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'agent-events-v1' }) + const early = collectingSink() + pump.subscribe(early.sink) + + const runPromise = pump.run() + const textPromise = readAllText(pump.textStream) + + // Wait until first event has been dispatched to early sink + await vi.waitFor(() => { + expect(early.events.length).toBe(1) + }) + + const late = collectingSink() + pump.subscribe(late.sink) + releaseFirst() + + const result = await runPromise + await textPromise + + expect(early.events.map((e) => e.type)).toEqual(['thinking_delta', 'text_delta']) + expect(late.events.map((e) => e.type)).toEqual(['text_delta']) + expect(result.answerText).toBe('late') + }) + + it('unsubscribe mid-stream detaches without failing the pump', async () => { + const seen: AgentStreamEvent[] = [] + const sink: AgentStreamSink = { + onEvent: async (event) => { + seen.push(event) + if (event.type === 'tool_call_start') { + unsubscribe() + } + }, + } + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'tool_call_start', id: '1', name: 'x' }, + { type: 'tool_call_end', id: '1', name: 'x', status: 'success' }, + { type: 'text_delta', text: 'ok', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + const unsubscribe = pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + await textPromise + + expect(result.answerText).toBe('ok') + expect(result.fullyDrained).toBe(true) + expect(seen.map((e) => e.type)).toEqual(['tool_call_start']) + }) + + it('sinkMode skips text stream and still drains', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: 'only-sink', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + expect(pump.textStream).toBeNull() + + const { sink, events } = collectingSink() + pump.subscribe(sink) + const result = await pump.run() + + expect(result.answerText).toBe('only-sink') + expect(events).toEqual([{ type: 'text_delta', text: 'only-sink', turn: 'final' }]) + }) + + it('awaits each sink before pulling the next event (per-event sync)', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let inFlight = 0 + let maxInFlight = 0 + + const sink: AgentStreamSink = { + onEvent: async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await gate + inFlight -= 1 + }, + } + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: '1', turn: 'final' }, + { type: 'text_delta', text: '2', turn: 'final' }, + { type: 'text_delta', text: '3', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + pump.subscribe(sink) + + const runPromise = pump.run() + await vi.waitFor(() => { + expect(maxInFlight).toBe(1) + }) + release() + await runPromise + expect(maxInFlight).toBe(1) + }) + + it('fails the pump on invalid agent-events chunks (no soft success)', async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'nope' }) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + + await expect(pump.run()).rejects.toThrow(/Invalid AgentStreamEvent/) + }) + + it('fails the pump when the provider source errors', async () => { + const source = new ReadableStream({ + start(controller) { + controller.error(new Error('provider down')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + + await expect(pump.run()).rejects.toThrow(/provider down/) + }) + + it('maps abort to cancelled result and distinguishes timeout reason', async () => { + const controller = new AbortController() + let pullCount = 0 + const source = new ReadableStream({ + async pull(ctrl) { + pullCount += 1 + if (pullCount === 1) { + ctrl.enqueue({ type: 'thinking_delta', text: '…' }) + controller.abort('timeout') + return + } + ctrl.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(result.cancelReason).toBe('timeout') + expect(result.fullyDrained).toBe(false) + }) + + it('preserves drained answerText when user abort surfaces as AbortError from reader', async () => { + const controller = new AbortController() + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'text_delta', text: 'kept answer', turn: 'final' }) + c.enqueue({ type: 'thinking_delta', text: 'still thinking' }) + // Abort while the reader is waiting for more — cancel() rejects read(). + queueMicrotask(() => controller.abort('user')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(result.cancelReason).toBe('user') + expect(result.answerText).toBe('kept answer') + expect(result.fullyDrained).toBe(false) + }) + + it('does not start until run() — subscribe-before-pull', async () => { + let pulled = false + const source = new ReadableStream({ + pull(controller) { + pulled = true + controller.enqueue({ type: 'text_delta', text: 'x', turn: 'final' }) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + expect(pulled).toBe(false) + await pump.run() + expect(pulled).toBe(true) + expect(events).toHaveLength(1) + }) + + it('rejects double run()', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([{ type: 'text_delta', text: 'a', turn: 'final' }]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + await pump.run() + await expect(pump.run()).rejects.toThrow(/already started/) + }) + + it('detaches a sink that throws without failing the pump', async () => { + const bad: AgentStreamSink = { + onEvent: async () => { + throw new Error('sink exploded') + }, + } + const good = collectingSink() + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: 'a', turn: 'final' }, + { type: 'text_delta', text: 'b', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + pump.subscribe(bad) + pump.subscribe(good.sink) + + const result = await pump.run() + expect(result.answerText).toBe('ab') + expect(good.events.length).toBe(2) + }) + + it('synthesizes tool_call_end cancelled for open tools on abort', async () => { + const controller = new AbortController() + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'tool_call_start', id: 't1', name: 'search' }) + c.enqueue({ type: 'thinking_delta', text: 'working' }) + // Never emits tool_call_end — abort mid-flight. + setTimeout(() => controller.abort('user'), 5) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 't1', + name: 'search', + status: 'cancelled', + }) + }) + + it('synthesizes tool_call_end error for open tools on source failure', async () => { + let pulled = 0 + const source = new ReadableStream({ + pull(c) { + pulled += 1 + if (pulled === 1) { + c.enqueue({ type: 'tool_call_start', id: 't1', name: 'search' }) + return + } + c.error(new Error('provider reset')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + await expect(pump.run()).rejects.toThrow('provider reset') + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 't1', + name: 'search', + }) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 't1', + name: 'search', + status: 'error', + }) + }) +}) + +describe('projectStreamingExecutionToByteStream', () => { + it('projects agent-events final-turn text to UTF-8 bytes', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + const byteStream = projectStreamingExecutionToByteStream({ + stream: createAgentEventReadableStream([ + { type: 'thinking_delta', text: 'secret' }, + { type: 'text_delta', text: 'Looking…', turn: 'intermediate' }, + { type: 'text_delta', text: 'Answer', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + + expect(await readAllText(byteStream)).toBe('Answer') + }) + + it('projects pending turns classified by turn_end (live loop shape)', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + const byteStream = projectStreamingExecutionToByteStream({ + stream: createAgentEventReadableStream([ + { type: 'text_delta', text: 'Preamble…', turn: 'pending' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'text_delta', text: 'Answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + + expect(await readAllText(byteStream)).toBe('Answer') + }) + + it('passes through legacy text byte streams unchanged', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + const encoder = new TextEncoder() + const source = new ReadableStream({ + start(c) { + c.enqueue(encoder.encode('raw')) + c.close() + }, + }) + const byteStream = projectStreamingExecutionToByteStream({ + stream: source, + streamFormat: 'text', + }) + expect(await readAllText(byteStream)).toBe('raw') + }) + + it('aborts the agent pump when the projected byte stream is cancelled', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + let sourceCancelled = false + let resolveHang!: () => void + const hang = new Promise((resolve) => { + resolveHang = resolve + }) + + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'text_delta', text: 'hi', turn: 'final' }) + }, + async pull() { + // Stay open until the pump cancels the source on client disconnect. + await hang + }, + cancel() { + sourceCancelled = true + resolveHang() + }, + }) + + const byteStream = projectStreamingExecutionToByteStream({ + stream: source, + streamFormat: 'agent-events-v1', + }) + const reader = byteStream.getReader() + const first = await reader.read() + expect(first.done).toBe(false) + expect(new TextDecoder().decode(first.value)).toBe('hi') + + await reader.cancel('client disconnect') + await hang + + expect(sourceCancelled).toBe(true) + }) +}) diff --git a/apps/sim/providers/stream-pump.ts b/apps/sim/providers/stream-pump.ts new file mode 100644 index 00000000000..22d4fcf0548 --- /dev/null +++ b/apps/sim/providers/stream-pump.ts @@ -0,0 +1,505 @@ +/** + * Executor-facing agent stream pump. + * + * Owns a single drain of a provider {@link StreamingExecution} source and: + * - projects final-turn answer text to an optional legacy byte stream; + * `turn: 'pending'` deltas from streaming tool loops are buffered per turn + * and projected only when `turn_end` classifies the turn as final + * - pushes the full ordered timeline (including live pending text deltas and + * turn boundaries, so opted-in surfaces can render the answer as it streams) + * to optional sinks + * - awaits each sink per event (simple backpressure; SSE enqueues are cheap) + * + * Wired into {@link BlockExecutor} `handleStreamingExecution`. + */ + +import { + type AgentStreamEvent, + type AgentStreamFormat, + type AgentStreamSink, + isAgentStreamEvent, + type UnsubscribeAgentStreamSink, +} from '@/providers/stream-events' + +/** + * Default cap on thinking text forwarded to sinks, in UTF-16 code units. + * Enforced twice on purpose, at different scopes: the pump caps per block + * (each agent block gets its own pump) while the chat SSE `sendThinking` caps + * per execution so a many-block workflow cannot flood a public stream. + */ +export const DEFAULT_MAX_THINKING_CHARS = 512 * 1024 + +export type AgentStreamPumpCancelReason = 'user' | 'timeout' | 'unknown' + +export interface CreateAgentStreamPumpOptions { + source: ReadableStream + streamFormat: AgentStreamFormat + /** + * When true, no legacy text {@link ReadableStream} is created — upgraded + * consumers read only the sink. Avoids unbounded buffering into an unread stream. + */ + sinkMode?: boolean + maxThinkingChars?: number + abortSignal?: AbortSignal +} + +export interface AgentStreamPumpResult { + /** Final-turn answer text only (`turn: 'intermediate'` excluded). */ + answerText: string + fullyDrained: boolean + cancelled: boolean + cancelReason?: AgentStreamPumpCancelReason +} + +export interface AgentStreamPump { + subscribe: (sink: AgentStreamSink) => UnsubscribeAgentStreamSink + /** Final-turn answer bytes, or `null` when {@link CreateAgentStreamPumpOptions.sinkMode}. */ + readonly textStream: ReadableStream | null + /** + * Starts draining the provider source. Call only after the synchronous + * subscribe window (e.g. after `onStream` returns). + */ + run: () => Promise +} + +interface SinkState { + sink: AgentStreamSink + detached: boolean +} + +function resolveCancelReason(signal: AbortSignal): AgentStreamPumpCancelReason { + const reason = signal.reason + if (reason === 'timeout' || reason === 'user') { + return reason + } + if (typeof reason === 'string') { + const lower = reason.toLowerCase() + if (lower.includes('timeout')) return 'timeout' + if (lower.includes('abort') || lower.includes('cancel') || lower.includes('user')) { + return 'user' + } + } + if (reason && typeof reason === 'object') { + // Execution aborts carry DOMException('timeout' | 'user', 'AbortError'). + const { name, message } = reason as { name?: unknown; message?: unknown } + if (message === 'timeout' || String(name) === 'TimeoutError') return 'timeout' + if (message === 'user') return 'user' + } + return 'unknown' +} + +function isImmediateFinalText(event: Extract): boolean { + return event.turn === undefined || event.turn === 'final' +} + +/** + * Creates a pump over a provider stream. Subscribe synchronously before {@link AgentStreamPump.run}. + */ +export function createAgentStreamPump(options: CreateAgentStreamPumpOptions): AgentStreamPump { + const { + source, + streamFormat, + sinkMode = false, + maxThinkingChars = DEFAULT_MAX_THINKING_CHARS, + abortSignal, + } = options + + const sinks = new Map() + let started = false + let closedTextStream = false + + let answerText = '' + let thinkingCharsForwarded = 0 + + let textController: ReadableStreamDefaultController | null = null + let textBackpressureWait: Promise | null = null + let resolveTextBackpressure: (() => void) | null = null + const textEncoder = new TextEncoder() + + const textStream = sinkMode + ? null + : new ReadableStream({ + start(controller) { + textController = controller + }, + pull() { + if (resolveTextBackpressure) { + const resolve = resolveTextBackpressure + resolveTextBackpressure = null + textBackpressureWait = null + resolve() + } + }, + cancel() { + closedTextStream = true + textController = null + if (resolveTextBackpressure) { + const resolve = resolveTextBackpressure + resolveTextBackpressure = null + textBackpressureWait = null + resolve() + } + }, + }) + + function subscribe(sink: AgentStreamSink): UnsubscribeAgentStreamSink { + if (sinks.has(sink)) { + return () => { + detachSink(sink) + } + } + + sinks.set(sink, { sink, detached: false }) + return () => detachSink(sink) + } + + function detachSink(sink: AgentStreamSink): void { + const state = sinks.get(sink) + if (!state || state.detached) return + state.detached = true + sinks.delete(sink) + } + + async function dispatchToSinks(event: AgentStreamEvent): Promise { + const active = [...sinks.values()].filter((s) => !s.detached) + if (active.length === 0) return + + await Promise.all( + active.map(async (state) => { + if (state.detached) return + try { + await state.sink.onEvent(event) + } catch { + detachSink(state.sink) + } + }) + ) + } + + async function enqueueAnswerText(text: string): Promise { + if (!text) return + + answerText += text + + if (sinkMode || closedTextStream || !textController) return + + const chunk = textEncoder.encode(text) + + while (!closedTextStream && textController) { + const desired = textController.desiredSize + if (desired === null) return + if (desired > 0) { + try { + textController.enqueue(chunk) + } catch { + closedTextStream = true + textController = null + } + return + } + if (!textBackpressureWait) { + textBackpressureWait = new Promise((resolve) => { + resolveTextBackpressure = resolve + }) + } + await textBackpressureWait + } + } + + function closeTextStream(error?: unknown): void { + if (sinkMode || closedTextStream) return + closedTextStream = true + try { + if (error !== undefined && textController) { + textController.error(error) + } else { + textController?.close() + } + } catch { + // already closed + } + textController = null + if (resolveTextBackpressure) { + resolveTextBackpressure() + resolveTextBackpressure = null + textBackpressureWait = null + } + } + + /** Open tool_call_start ids → names; settled on abort/error so sinks never hang. */ + const openTools = new Map() + + /** + * Live text from the current unclassified turn (`turn: 'pending'`). Projected + * to the byte stream only when `turn_end { turn: 'final' }` arrives; discarded + * on `turn_end { turn: 'intermediate' }` (tools follow — the text is preamble). + */ + let pendingTurnText = '' + + async function settleOpenTools(status: 'cancelled' | 'error'): Promise { + if (openTools.size === 0) return + const pending = [...openTools.entries()] + openTools.clear() + for (const [id, name] of pending) { + await dispatchToSinks({ type: 'tool_call_end', id, name, status }) + } + } + + async function handleEvent(event: AgentStreamEvent): Promise { + if (event.type === 'thinking_delta') { + const remaining = Math.max(0, maxThinkingChars - thinkingCharsForwarded) + if (remaining <= 0) return + const forwarded = event.text.length > remaining ? event.text.slice(0, remaining) : event.text + thinkingCharsForwarded += forwarded.length + if (forwarded.length > 0) { + await dispatchToSinks({ type: 'thinking_delta', text: forwarded }) + } + return + } + + if (event.type === 'text_delta') { + await dispatchToSinks(event) + if (event.turn === 'pending') { + pendingTurnText += event.text + } else if (isImmediateFinalText(event)) { + await enqueueAnswerText(event.text) + } + return + } + + if (event.type === 'turn_end') { + await dispatchToSinks(event) + const buffered = pendingTurnText + pendingTurnText = '' + if (buffered && event.turn === 'final') { + await enqueueAnswerText(buffered) + } + return + } + + if (event.type === 'tool_call_start') { + openTools.set(event.id, event.name) + await dispatchToSinks(event) + return + } + + if (event.type === 'tool_call_end') { + openTools.delete(event.id) + await dispatchToSinks(event) + return + } + + await dispatchToSinks(event) + } + + async function run(): Promise { + if (started) { + throw new Error('Agent stream pump already started') + } + started = true + + let cancelled = false + let cancelReason: AgentStreamPumpCancelReason | undefined + let fullyDrained = false + let drainError: unknown + + const reader = source.getReader() + const decoder = new TextDecoder() + + const onAbort = () => { + cancelled = true + if (abortSignal) { + cancelReason = resolveCancelReason(abortSignal) + } else { + cancelReason = 'unknown' + } + void reader.cancel(abortSignal?.reason ?? 'aborted') + // Release a drain blocked on byte backpressure — a consumer that stopped + // pulling without cancelling would otherwise deadlock the abort. + closeTextStream() + } + + if (abortSignal) { + if (abortSignal.aborted) { + onAbort() + } else { + abortSignal.addEventListener('abort', onAbort, { once: true }) + } + } + + try { + while (!cancelled) { + const { done, value } = await reader.read() + if (done) { + if (streamFormat === 'text') { + const tail = decoder.decode() + if (tail) { + await handleEvent({ type: 'text_delta', text: tail, turn: 'final' }) + } + } + fullyDrained = true + break + } + + if (streamFormat === 'text') { + if (!(value instanceof Uint8Array)) { + throw new Error('text streamFormat expected Uint8Array chunks') + } + const text = decoder.decode(value, { stream: true }) + if (text) { + await handleEvent({ type: 'text_delta', text, turn: 'final' }) + } + continue + } + + if (!isAgentStreamEvent(value)) { + throw new Error('Invalid AgentStreamEvent on agent-events-v1 stream') + } + await handleEvent(value) + } + } catch (error) { + drainError = error + } finally { + abortSignal?.removeEventListener('abort', onAbort) + try { + reader.releaseLock() + } catch { + // ignore + } + } + + // reader.cancel() / aborted read often surfaces as AbortError. That is the + // cancel path, not a hard drain failure — keep accumulated answerText. + const isAbortError = + (drainError instanceof DOMException && drainError.name === 'AbortError') || + (drainError instanceof Error && drainError.name === 'AbortError') + if (!cancelled && isAbortError && abortSignal?.aborted) { + cancelled = true + cancelReason = resolveCancelReason(abortSignal) + } + + // Synthesize tool ends — enqueue-then-error in provider loops can drop + // settlement frames (WHATWG resets the queue on error). + if (cancelled) { + await settleOpenTools('cancelled') + } else if (drainError) { + await settleOpenTools('error') + } + + /** + * Pending text without a closing `turn_end` (cancel/error mid-turn, or a + * loop that ended without classifying). Keep it in `answerText` so logs + * match what live consumers already rendered; project to the byte stream + * only on a clean drain (the stream is closing on cancel/error anyway). + */ + if (pendingTurnText) { + const dangling = pendingTurnText + pendingTurnText = '' + if (!cancelled && !drainError) { + await enqueueAnswerText(dangling) + } else { + answerText += dangling + } + } + + if (drainError && !cancelled) { + closeTextStream(drainError) + throw drainError instanceof Error ? drainError : new Error(String(drainError)) + } + + if (cancelled) { + closeTextStream() + return { + answerText, + fullyDrained: false, + cancelled: true, + cancelReason: cancelReason ?? 'unknown', + } + } + + closeTextStream() + return { + answerText, + fullyDrained, + cancelled: false, + } + } + + return { + subscribe, + textStream, + run, + } +} + +/** + * Project a {@link StreamingExecution} to a byte stream suitable for an HTTP + * `Response` body. Agent-events object streams are pumped to final-turn answer + * bytes; legacy `text` streams pass through unchanged. + * + * Cancelling the returned stream aborts the pump so provider work stops when + * the HTTP client disconnects (billing/provider reads do not continue). + */ +export function projectStreamingExecutionToByteStream(streamingExec: { + stream: ReadableStream + streamFormat?: AgentStreamFormat +}): ReadableStream { + const streamFormat = streamingExec.streamFormat ?? 'text' + if (streamFormat === 'text') { + return streamingExec.stream as ReadableStream + } + + const abortController = new AbortController() + const pump = createAgentStreamPump({ + source: streamingExec.stream, + streamFormat, + abortSignal: abortController.signal, + }) + const textStream = pump.textStream + if (!textStream) { + throw new Error('Agent stream pump expected a text projection stream') + } + + let reader: ReadableStreamDefaultReader | undefined + + return new ReadableStream({ + async start(controller) { + const runPromise = pump.run() + reader = textStream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + controller.enqueue(value) + } + await runPromise + controller.close() + } catch (error) { + if (abortController.signal.aborted) { + try { + controller.close() + } catch { + // already closed/errored + } + return + } + try { + controller.error(error instanceof Error ? error : new Error(String(error))) + } catch { + // already closed/errored + } + } finally { + try { + reader?.releaseLock() + } catch { + // ignore + } + } + }, + cancel(reason) { + abortController.abort(reason ?? 'user') + void reader?.cancel(reason).catch(() => {}) + void textStream.cancel(reason).catch(() => {}) + }, + }) +} diff --git a/apps/sim/providers/streaming-execution.test.ts b/apps/sim/providers/streaming-execution.test.ts index 25e7708b1f0..f5d4c678932 100644 --- a/apps/sim/providers/streaming-execution.test.ts +++ b/apps/sim/providers/streaming-execution.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it, vi } from 'vitest' import type { NormalizedBlockOutput } from '@/executor/types' +import { createAgentEventReadableStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' /** @@ -207,4 +208,42 @@ describe('createStreamingExecution', () => { vi.restoreAllMocks() }) + + it('defaults streamFormat to text and can attach agent-events-v1 object streams', async () => { + const textResult = createStreamingExecution({ + model: 'm', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'm' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: () => new ReadableStream(), + }) + expect(textResult.streamFormat).toBe('text') + + const events = [ + { type: 'thinking_delta' as const, text: 'reason' }, + { type: 'text_delta' as const, text: 'answer', turn: 'final' as const }, + ] + const eventResult = createStreamingExecution({ + model: 'm', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'm' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', + createStream: () => createAgentEventReadableStream(events), + }) + + expect(eventResult.streamFormat).toBe('agent-events-v1') + const reader = eventResult.stream.getReader() + const received = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + received.push(value) + } + expect(received).toEqual(events) + }) }) diff --git a/apps/sim/providers/streaming-execution.ts b/apps/sim/providers/streaming-execution.ts index 7a217af283d..ef020c5d328 100644 --- a/apps/sim/providers/streaming-execution.ts +++ b/apps/sim/providers/streaming-execution.ts @@ -1,4 +1,5 @@ import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent, AgentStreamFormat } from '@/providers/stream-events' import type { TimeSegment } from '@/providers/types' /** @@ -78,13 +79,21 @@ interface CreateStreamingExecutionOptions { toolCalls?: ToolCallSlice /** Marks `execution.isStreaming = true` when set. */ isStreaming?: boolean + /** + * Declares whether {@link createStream} returns UTF-8 answer bytes (`text`) + * or an in-process {@link AgentStreamEvent} object stream (`agent-events-v1`). + * Defaults to `'text'` so existing providers stay unchanged. + */ + streamFormat?: AgentStreamFormat /** * Builds the provider stream. Receives the live `output` object and a * `finalizeTiming` hook. The provider wires its native stream factory and, in * the drain callback, writes final content/tokens/cost onto `output` then * calls `finalizeTiming()`. */ - createStream: (handles: StreamFinalizer) => ReadableStream + createStream: ( + handles: StreamFinalizer + ) => ReadableStream | ReadableStream } /** @@ -104,6 +113,7 @@ export function createStreamingExecution( initialCost, toolCalls, isStreaming, + streamFormat = 'text', createStream, } = options @@ -155,6 +165,7 @@ export function createStreamingExecution( return { stream, + streamFormat, execution: { success: true, output, diff --git a/apps/sim/providers/streaming-tool-loop-shared.ts b/apps/sim/providers/streaming-tool-loop-shared.ts new file mode 100644 index 00000000000..8b4dc9bf11d --- /dev/null +++ b/apps/sim/providers/streaming-tool-loop-shared.ts @@ -0,0 +1,66 @@ +/** + * Shared plumbing for the per-provider live streaming tool loops + * (`providers/{anthropic,openai-compat,gemini,bedrock}/streaming-tool-loop.ts`). + * + * The wire handling in each loop is provider-specific; everything here is the + * provider-agnostic contract they share. + */ + +import type { NormalizedBlockOutput } from '@/executor/types' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' + +/** + * Providers with a live streaming tool loop wired. The executor consults this + * (instead of hardcoding provider ids) when deciding whether an agent-events + * run can stream tool lifecycle; providers not listed simply ignore + * `streamToolCalls` and keep their legacy loop. + */ +export const STREAMING_TOOL_CALL_PROVIDERS: ReadonlySet = new Set([ + 'anthropic', + 'azure-anthropic', + 'groq', + 'deepseek', + 'google', + 'vertex', + 'bedrock', +]) + +/** Whether a provider has a live streaming tool loop wired. */ +export function supportsStreamingToolCalls(providerId: string): boolean { + return STREAMING_TOOL_CALL_PROVIDERS.has(providerId) +} + +/** Aggregate result reported by a streaming tool loop when its stream closes. */ +export interface StreamingToolLoopComplete { + content: string + tokens: { input: number; output: number; total: number } + cost: NormalizedBlockOutput['cost'] + toolCalls?: { list: unknown[]; count: number } + modelTime: number + toolsTime: number + firstResponseTime: number + iterations: number +} + +/** True for user/SDK abort errors raised when a run is cancelled mid-stream. */ +export function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const name = (error as { name?: string }).name + return name === 'AbortError' || name === 'APIUserAbortError' +} + +/** + * Settle every open tool with a terminal status and clear the tracking map. + * Called when a loop aborts, errors, or drains with tools still running so no + * consumer is left with a perpetually "running" tool chip. + */ +export function settleOpenTools( + controller: ReadableStreamDefaultController, + openTools: Map, + status: ToolCallEndStatus +): void { + for (const [id, name] of openTools) { + controller.enqueue({ type: 'tool_call_end', id, name, status }) + } + openTools.clear() +} diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index 34c22f5c18c..c52f383d2ff 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -167,6 +167,7 @@ export const togetherProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -469,6 +470,7 @@ export const togetherProvider: ProviderConfig = { }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/together/utils.ts b/apps/sim/providers/together/utils.ts index 22b7823f342..e187e881490 100644 --- a/apps/sim/providers/together/utils.ts +++ b/apps/sim/providers/together/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Together gates native `json_schema` per-model, so we use the broadly supported @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Together AI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Together AI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Together', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Together', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/tool-call-id.ts b/apps/sim/providers/tool-call-id.ts new file mode 100644 index 00000000000..c9cdf875e5d --- /dev/null +++ b/apps/sim/providers/tool-call-id.ts @@ -0,0 +1,27 @@ +/** + * Stable execution-local tool call ids when a provider omits them (e.g. Gemini). + * Ensures `${blockId}:${toolCallId}` cannot collide within a run. + */ + +import { randomFloat } from '@sim/utils/random' + +let localToolIdCounter = 0 + +/** Reset counter — tests only. */ +export function resetLocalToolIdCounterForTests(): void { + localToolIdCounter = 0 +} + +export function allocateExecutionLocalToolId(prefix = 'local'): string { + localToolIdCounter += 1 + const rand = randomFloat().toString(36).slice(2, 8) + return `${prefix}_${localToolIdCounter}_${rand}` +} + +/** Returns provider id if non-empty, otherwise allocates a local one. */ +export function ensureToolCallId(providerId: string | null | undefined, prefix = 'local'): string { + if (typeof providerId === 'string' && providerId.trim().length > 0) { + return providerId + } + return allocateExecutionLocalToolId(prefix) +} diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index c02dc2ebd08..9d810b7961c 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -168,7 +168,19 @@ export interface ProviderRequest { chatId?: string userId?: string stream?: boolean + /** + * Use the live streaming tool loop (tool lifecycle on the agent-events + * stream). Set by the executor only for agent-events runs on providers in + * {@link STREAMING_TOOL_CALL_PROVIDERS}; providers without a loop ignore it. + */ streamToolCalls?: boolean + /** + * Run-level agent-events opt-in. Lets providers request streamable thinking + * (e.g. OpenAI reasoning summaries, Gemini thought summaries) for the run. + * Never changes answer content; when unset, provider requests are identical + * to the pre-agent-events wire shape. + */ + agentEvents?: boolean environmentVariables?: Record workflowVariables?: Record blockData?: Record diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index cf5778d9db3..cf734280678 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -342,6 +342,8 @@ describe('Model Capabilities', () => { expect(supportsReasoningEffort('o4-mini')).toBe(true) expect(supportsReasoningEffort('azure/gpt-5')).toBe(true) expect(supportsReasoningEffort('azure/o3')).toBe(true) + expect(supportsReasoningEffort('groq/openai/gpt-oss-120b')).toBe(true) + expect(supportsReasoningEffort('groq/openai/gpt-oss-20b')).toBe(true) }) it('should return false for models without reasoning effort capability', () => { @@ -391,6 +393,9 @@ describe('Model Capabilities', () => { expect(supportsThinking('claude-sonnet-4-5')).toBe(true) expect(supportsThinking('claude-haiku-4-5')).toBe(true) expect(supportsThinking('gemini-3-flash-preview')).toBe(true) + expect(supportsThinking('deepseek-chat')).toBe(true) + expect(supportsThinking('deepseek-reasoner')).toBe(true) + expect(supportsThinking('groq/qwen/qwen3.6-27b')).toBe(true) }) it('should return false for models without thinking capability', () => { @@ -467,6 +472,8 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-5') + expect(MODELS_WITH_REASONING_EFFORT).toContain('groq/openai/gpt-oss-120b') + expect(MODELS_WITH_REASONING_EFFORT).toContain('groq/openai/gpt-oss-20b') }) it('should have correct models in MODELS_WITH_VERBOSITY', () => { @@ -508,6 +515,10 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_THINKING).toContain('claude-haiku-4-5') + expect(MODELS_WITH_THINKING).toContain('deepseek-chat') + expect(MODELS_WITH_THINKING).toContain('deepseek-reasoner') + expect(MODELS_WITH_THINKING).toContain('groq/qwen/qwen3.6-27b') + expect(MODELS_WITH_THINKING).not.toContain('gpt-4o') expect(MODELS_WITH_THINKING).not.toContain('gpt-5') expect(MODELS_WITH_THINKING).not.toContain('o3') diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ba0ab5629e8..bb6ce810ff0 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -2,8 +2,6 @@ import { createLogger, type Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import type OpenAI from 'openai' -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { formatCreditCost } from '@/lib/billing/credits/conversion' import { env } from '@/lib/core/config/env' @@ -1468,63 +1466,6 @@ export function prepareToolExecution( return { toolParams, executionParams } } -/** - * Creates a ReadableStream from an OpenAI-compatible streaming response. - * This is a shared utility used by all OpenAI-compatible providers: - * OpenAI, Groq, DeepSeek, xAI, OpenRouter, Mistral, Ollama, vLLM, Azure OpenAI, Cerebras - * - * @param stream - The async iterable stream from the provider - * @param providerName - Name of the provider for logging purposes - * @param onComplete - Optional callback called when stream completes with full content and usage - * @returns A ReadableStream that can be used for streaming responses - */ -export function createOpenAICompatibleStream( - stream: AsyncIterable, - providerName: string, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - const streamLogger = createLogger(`${providerName}Utils`) - let fullContent = '' - let promptTokens = 0 - let completionTokens = 0 - let totalTokens = 0 - - return new ReadableStream({ - async start(controller) { - try { - for await (const chunk of stream) { - if (chunk.usage) { - promptTokens = chunk.usage.prompt_tokens ?? 0 - completionTokens = chunk.usage.completion_tokens ?? 0 - totalTokens = chunk.usage.total_tokens ?? 0 - } - - const content = chunk.choices?.[0]?.delta?.content || '' - if (content) { - fullContent += content - controller.enqueue(new TextEncoder().encode(content)) - } - } - - if (onComplete) { - if (promptTokens === 0 && completionTokens === 0) { - streamLogger.warn(`${providerName} stream completed without usage data`) - } - onComplete(fullContent, { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: totalTokens || promptTokens + completionTokens, - }) - } - - controller.close() - } catch (error) { - controller.error(error) - } - }, - }) -} - /** * Checks if a forced tool was used in an OpenAI-compatible response and updates tracking. * This is a shared utility used by OpenAI-compatible providers: diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 936bf3af633..34ee8d9f256 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -240,6 +240,7 @@ export const vllmProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { let cleanContent = content @@ -582,6 +583,7 @@ export const vllmProvider: ProviderConfig = { count: toolCalls.length, } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { let cleanContent = content diff --git a/apps/sim/providers/vllm/utils.ts b/apps/sim/providers/vllm/utils.ts index 2b1db5bf553..f2580ab481b 100644 --- a/apps/sim/providers/vllm/utils.ts +++ b/apps/sim/providers/vllm/utils.ts @@ -1,16 +1,23 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from a vLLM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a vLLM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromVLLMStream( vllmStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(vllmStream, 'vLLM', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(vllmStream, { + providerName: 'vLLM', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 42ebeb1254c..6d83e03b37d 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -1,7 +1,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' -import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' +import type { + ChatCompletionChunk, + ChatCompletionCreateParamsStreaming, +} from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -128,6 +131,7 @@ export const xAIProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromXAIStream(streamResponse, (content, usage) => { output.content = content @@ -523,28 +527,33 @@ export const xAIProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromXAIStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromXAIStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/xai/utils.ts b/apps/sim/providers/xai/utils.ts index 3a2acd56eca..76a21c7505f 100644 --- a/apps/sim/providers/xai/utils.ts +++ b/apps/sim/providers/xai/utils.ts @@ -1,16 +1,23 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from an xAI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an xAI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromXAIStream( xaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(xaiStream, 'xAI', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(xaiStream, { + providerName: 'xAI', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts index 779b18942ea..e8c960e1a81 100644 --- a/apps/sim/providers/zai/index.ts +++ b/apps/sim/providers/zai/index.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' @@ -183,26 +184,31 @@ export const zaiProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromZaiStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromZaiStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } } - }), + ), }) return streamingResult @@ -547,28 +553,33 @@ export const zaiProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromZaiStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromZaiStream( + // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects + streamResponse as unknown as AsyncIterable, + (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/zai/utils.ts b/apps/sim/providers/zai/utils.ts index 4b86c6b7619..28ab12b1cd0 100644 --- a/apps/sim/providers/zai/utils.ts +++ b/apps/sim/providers/zai/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Z.ai streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Z.ai streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromZaiStream( zaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(zaiStream, 'Z.ai', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(zaiStream, { + providerName: 'Z.ai', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/stores/chat/store.ts b/apps/sim/stores/chat/store.ts index f2e2a9c1cfd..0256d11138a 100644 --- a/apps/sim/stores/chat/store.ts +++ b/apps/sim/stores/chat/store.ts @@ -232,6 +232,14 @@ export const useChatStore = create()( }) }, + setMessageContent: (messageId, content) => { + set((state) => ({ + messages: state.messages.map((message) => + message.id === messageId ? { ...message, content } : message + ), + })) + }, + finalizeMessageStream: (messageId) => { set((state) => { const newMessages = state.messages.map((message) => { diff --git a/apps/sim/stores/chat/types.ts b/apps/sim/stores/chat/types.ts index 77268699909..4ed03e7dba5 100644 --- a/apps/sim/stores/chat/types.ts +++ b/apps/sim/stores/chat/types.ts @@ -64,6 +64,8 @@ export interface ChatState { setSelectedWorkflowOutput: (workflowId: string, outputIds: string[]) => void getSelectedWorkflowOutput: (workflowId: string) => string[] appendMessageContent: (messageId: string, content: string) => void + /** Replaces the message's content (streamed-turn reconciliation). */ + setMessageContent: (messageId: string, content: string) => void finalizeMessageStream: (messageId: string) => void getConversationId: (workflowId: string) => string generateNewConversationId: (workflowId: string) => string diff --git a/apps/sim/stores/terminal/console/store.test.ts b/apps/sim/stores/terminal/console/store.test.ts index 00b2b3bb3cc..4ce6ced4a54 100644 --- a/apps/sim/stores/terminal/console/store.test.ts +++ b/apps/sim/stores/terminal/console/store.test.ts @@ -137,6 +137,35 @@ describe('terminal console store', () => { expect(entry.isRunning).toBe(false) }) + it('settles live agent stream chrome when canceling', () => { + useTerminalConsoleStore.getState().addConsole({ + workflowId: 'wf-1', + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + executionId: 'exec-1', + executionOrder: 1, + isRunning: true, + agentStreamActive: true, + agentStreamThinking: 'drafting…', + agentStreamToolCalls: [ + { + key: 'block-1:t1', + id: 't1', + name: 'http_request', + displayName: 'HTTP Request', + status: 'running', + }, + ], + }) + + useTerminalConsoleStore.getState().cancelRunningEntries('wf-1', 'exec-1') + + const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1') + expect(entry.agentStreamActive).toBe(false) + expect(entry.agentStreamToolCalls?.[0]?.status).toBe('cancelled') + }) + it('only cancels running entries for the requested execution when provided', () => { useTerminalConsoleStore.getState().addConsole({ workflowId: 'wf-1', @@ -190,5 +219,72 @@ describe('terminal console store', () => { expect(entry.isRunning).toBe(false) expect(entry.endedAt).toBeDefined() }) + + it('settles live agent stream chrome when finishing', () => { + useTerminalConsoleStore.getState().addConsole({ + workflowId: 'wf-1', + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + executionId: 'exec-1', + executionOrder: 1, + isRunning: true, + agentStreamActive: true, + agentStreamToolCalls: [ + { + key: 'block-1:t1', + id: 't1', + name: 'http_request', + displayName: 'HTTP Request', + status: 'running', + }, + ], + }) + + useTerminalConsoleStore.getState().finishRunningEntries('wf-1', 'exec-1') + + const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1') + expect(entry.agentStreamActive).toBe(false) + expect(entry.agentStreamToolCalls?.[0]?.status).toBe('success') + }) + }) + + describe('updateConsole agent stream chrome', () => { + it('settles running tools and clears agentStreamActive when a block errors', () => { + useTerminalConsoleStore.getState().addConsole({ + workflowId: 'wf-1', + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + executionId: 'exec-1', + executionOrder: 1, + isRunning: true, + agentStreamActive: true, + agentStreamThinking: 'working…', + agentStreamToolCalls: [ + { + key: 'block-1:t1', + id: 't1', + name: 'http_request', + displayName: 'HTTP Request', + status: 'running', + }, + ], + }) + + useTerminalConsoleStore.getState().updateConsole( + 'block-1', + { + isRunning: false, + success: false, + error: 'timeout', + }, + 'exec-1' + ) + + const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1') + expect(entry.agentStreamActive).toBe(false) + expect(entry.agentStreamToolCalls?.[0]?.status).toBe('error') + }) }) }) diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index a3262620c3c..c21cf67b472 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -4,6 +4,10 @@ import { generateId } from '@sim/utils/id' import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { useShallow } from 'zustand/react/shallow' +import { + type AgentStreamToolTerminalStatus, + settleRunningToolCallList, +} from '@/components/agent-stream/tool-call-lifecycle' import { redactApiKeys } from '@/lib/core/security/redaction' import { sendMothershipMessage } from '@/lib/mothership/events' import { getQueryClient } from '@/app/_shell/providers/query-provider' @@ -75,6 +79,27 @@ const shouldSkipEntry = (output: any): boolean => { const getBlockExecutionKey = (blockId: string, executionId?: string): string => `${executionId ?? 'no-execution'}:${blockId}` +/** + * Clears live thinking/tool chrome and settles any still-running tool chips. + * Failures often skip stream:done, so terminal paths must settle chrome here. + */ +const settleAgentStreamChrome = ( + entry: ConsoleEntry, + status: AgentStreamToolTerminalStatus +): Pick => ({ + agentStreamActive: false, + agentStreamToolCalls: settleRunningToolCallList(entry.agentStreamToolCalls, status), +}) + +const resolveAgentStreamSettleStatus = ( + entry: ConsoleEntry, + update?: ConsoleUpdate +): AgentStreamToolTerminalStatus => { + if (update?.isCanceled === true || entry.isCanceled) return 'cancelled' + if (update?.success === false || entry.success === false) return 'error' + return 'success' +} + const matchesEntryForUpdate = ( entry: ConsoleEntry, blockId: string, @@ -612,6 +637,35 @@ export const useTerminalConsoleStore = create()( updatedEntry.childWorkflowInstanceId = update.childWorkflowInstanceId } + if (update.agentStreamThinking !== undefined) { + updatedEntry.agentStreamThinking = update.agentStreamThinking + } + + if (update.agentStreamToolCalls !== undefined) { + updatedEntry.agentStreamToolCalls = update.agentStreamToolCalls + } + + if (update.agentStreamActive !== undefined) { + updatedEntry.agentStreamActive = update.agentStreamActive + } + + // Settle live chrome whenever an entry stops running or stream activity ends. + // block:error / timeouts often skip stream:done and only flip isRunning. + const shouldSettleAgentStream = + update.isRunning === false || + update.agentStreamActive === false || + update.isCanceled === true + if (shouldSettleAgentStream) { + const settled = settleAgentStreamChrome( + updatedEntry, + resolveAgentStreamSettleStatus(updatedEntry, update) + ) + updatedEntry.agentStreamActive = settled.agentStreamActive + if (update.agentStreamToolCalls === undefined) { + updatedEntry.agentStreamToolCalls = settled.agentStreamToolCalls + } + } + nextEntries[location.index] = updatedEntry } @@ -664,6 +718,7 @@ export const useTerminalConsoleStore = create()( : entry.durationMs return { ...entry, + ...settleAgentStreamChrome(entry, 'cancelled'), isRunning: false, isCanceled: true, endedAt: now.toISOString(), @@ -696,6 +751,7 @@ export const useTerminalConsoleStore = create()( : entry.durationMs return { ...entry, + ...settleAgentStreamChrome(entry, 'success'), isRunning: false, isCanceled: false, endedAt: now.toISOString(), diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index d7a0fdadf2c..08bdafb514b 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -1,3 +1,4 @@ +import type { AgentStreamToolCall } from '@/components/agent-stream/tool-call-lifecycle' import type { ParentIteration } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' import type { SubflowType } from '@/stores/workflows/workflow/types' @@ -32,6 +33,12 @@ export interface ConsoleEntry { childWorkflowName?: string /** Per-invocation unique ID linking this workflow block to its child block events */ childWorkflowInstanceId?: string + /** Live agent thinking text (canvas stream:thinking). Not part of answer content. */ + agentStreamThinking?: string + /** Live tool chips (canvas stream:tool). Name + status only. */ + agentStreamToolCalls?: AgentStreamToolCall[] + /** True while thinking/tool live updates may still arrive for this entry. */ + agentStreamActive?: boolean } export interface ConsoleUpdate { @@ -58,6 +65,9 @@ export interface ConsoleUpdate { childWorkflowBlockId?: string childWorkflowName?: string childWorkflowInstanceId?: string + agentStreamThinking?: string + agentStreamToolCalls?: AgentStreamToolCall[] + agentStreamActive?: boolean } export interface ConsoleEntryLocation { diff --git a/bun.lock b/bun.lock index 1a44dd65c3b..69f9acb71ac 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -48,7 +47,7 @@ "shiki": "4.3.1", "streamdown": "2.5.0", "tailwind-merge": "^3.0.2", - "zod": "^4.3.6", + "zod": "4.3.6", }, "devDependencies": { "@sim/tsconfig": "workspace:*", @@ -104,7 +103,7 @@ "dependencies": { "@1password/sdk": "0.3.1", "@a2a-js/sdk": "1.0.0-alpha.0", - "@anthropic-ai/sdk": "0.71.2", + "@anthropic-ai/sdk": "0.114.0", "@aws-sdk/client-appconfigdata": "3.1032.0", "@aws-sdk/client-athena": "3.1032.0", "@aws-sdk/client-bedrock-runtime": "3.1032.0", @@ -652,7 +651,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.114.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-zRFGTVMFEm77gt70Q0B+CDRKa9AcguydCcp6bD/dXWv8UkfsVFqCbaqU8+4B/pob3+Vy434LgtrBmwSvxfKr3g=="], "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], @@ -3096,7 +3095,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4536,6 +4535,10 @@ "@sim/db/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], + "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + + "@sim/workflow-renderer/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4682,8 +4685,6 @@ "docs/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "docs/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "docx/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], @@ -4740,14 +4741,10 @@ "fumadocs-openapi/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], - "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], - "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4906,6 +4903,8 @@ "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], diff --git a/bunfig.toml b/bunfig.toml index dbb4f93a7fa..70e95c399d7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -11,6 +11,9 @@ minimumReleaseAge = 604800 # July 2026 advisories (SSRF, cache confusion, DoS, middleware bypass — GHSA-89xv-2m56-2m9x # et al.); published 2026-07-21, they age out of the gate on 2026-07-28 — drop these two # entries then. +# @anthropic-ai/sdk is exactly pinned to 0.114.0, vetted for the agent-events +# streaming work (adaptive thinking display types + transform-json-schema); +# published 2026-07-23, it ages out of the gate on 2026-07-30 — drop this entry then. minimumReleaseAgeExcludes = [ "@typescript/native-preview", "@earendil-works/pi-agent-core", @@ -19,6 +22,7 @@ minimumReleaseAgeExcludes = [ "@earendil-works/pi-tui", "next", "@next/env", + "@anthropic-ai/sdk", ] [run] diff --git a/package.json b/package.json index ea7fafc1fd4..374ffb1f23c 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "mship:check": "bun run scripts/generate-mship-contracts.ts --check", "skills:sync": "bun run scripts/sync-skills.ts", "skills:check": "bun run scripts/sync-skills.ts --check", + "agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts", + "agent-stream-docs:check": "bun run scripts/sync-agent-stream-docs.ts --check", "prepare": "bun husky", "type-check": "turbo run type-check", "release": "bun run scripts/create-single-release.ts" diff --git a/packages/auth/src/verify.ts b/packages/auth/src/verify.ts index b1e1eb5ccaf..b535da54df6 100644 --- a/packages/auth/src/verify.ts +++ b/packages/auth/src/verify.ts @@ -11,14 +11,48 @@ export interface VerifyAuthOptions { baseURL: string } +/** + * Session payload returned by one-time-token verification. The session row is + * created by `apps/sim`'s full auth config, so it can carry plugin fields + * (e.g. `activeOrganizationId`) this minimal instance does not configure. + */ +export interface VerifiedOneTimeTokenSession { + user: { + id: string + name: string | null + email: string | null + image?: string | null + } + session: { + activeOrganizationId?: string | null + } +} + +/** + * The verification surface consumers use. Declared explicitly (rather than + * inferring Better Auth's instance type) so this package's emitted + * declarations never reference Better Auth's internal zod instance — the + * inferred type is not portable across install layouts (TS2883). + */ +export interface VerifyAuth { + api: { + verifyOneTimeToken: (input: { + body: { token: string } + }) => Promise + } +} + /** * Minimal Better Auth instance used by services that only need to verify * one-time tokens issued by the main app. Shares the Better Auth DB schema * (`verification` table) and secret with the main app, so tokens issued by - * `apps/sim`'s full auth config are accepted here. + * `apps/sim`'s full auth config are accepted here. The instance is wrapped in + * the {@link VerifyAuth} contract rather than returned directly so consumers + * (and this package's declaration output) never depend on Better Auth's + * inferred endpoint types. */ -export function createVerifyAuth(options: VerifyAuthOptions) { - return betterAuth({ +export function createVerifyAuth(options: VerifyAuthOptions): VerifyAuth { + const auth = betterAuth({ baseURL: options.baseURL, secret: options.secret, database: drizzleAdapter(db, { @@ -31,6 +65,27 @@ export function createVerifyAuth(options: VerifyAuthOptions) { }), ], }) -} -export type VerifyAuth = ReturnType + return { + api: { + verifyOneTimeToken: async (input) => { + const result = await auth.api.verifyOneTimeToken(input) + if (!result?.user?.id) return null + return { + user: { + id: result.user.id, + name: result.user.name ?? null, + email: result.user.email ?? null, + image: result.user.image ?? null, + }, + session: { + activeOrganizationId: + typeof result.session.activeOrganizationId === 'string' + ? result.session.activeOrganizationId + : null, + }, + } + }, + }, + } +} diff --git a/packages/db/migrations/0266_spotty_alex_power.sql b/packages/db/migrations/0266_spotty_alex_power.sql new file mode 100644 index 00000000000..1ddc7cfb989 --- /dev/null +++ b/packages/db/migrations/0266_spotty_alex_power.sql @@ -0,0 +1 @@ +ALTER TABLE "chat" ADD COLUMN IF NOT EXISTS "include_thinking" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0266_snapshot.json b/packages/db/migrations/meta/0266_snapshot.json new file mode 100644 index 00000000000..5e04c786467 --- /dev/null +++ b/packages/db/migrations/meta/0266_snapshot.json @@ -0,0 +1,17413 @@ +{ + "id": "dd2e88e1-ce36-426e-9857-a9f8ab6141fb", + "prevId": "18059bad-80c8-4739-a925-3883824a5cd2", + "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": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": 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": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "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 + }, + "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_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": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "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": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS 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.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 + }, + "inputs": { + "name": "inputs", + "type": "json", + "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 + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_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 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_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 + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "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": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "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": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "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": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "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 + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "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_operation": { + "name": "workflow_deployment_operation", + "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": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_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": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "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": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "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 + }, + "deployment_operation_id": { + "name": "deployment_operation_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_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "organization_assigned_at": { + "name": "organization_assigned_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": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "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_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", + "workflow_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 54732eb975f..39b5fac75ac 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1856,6 +1856,13 @@ "when": 1784756534601, "tag": "0265_org_session_policy", "breakpoints": true + }, + { + "idx": 266, + "version": "7", + "when": 1784838122475, + "tag": "0266_spotty_alex_power", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 083cdb72e72..146284517bd 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1132,6 +1132,13 @@ export const chat = pgTable( // Output configuration outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects + /** + * When true, public chat SSE may expose provider thinking/tool events if the + * client also opts in via `X-Sim-Stream-Protocol: agent-events-v1`. + * Default off — never derived from auth type or isSecureMode. + */ + includeThinking: boolean('include_thinking').notNull().default(false), + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index ca251cba0ee..9f892b1c2ec 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -440,6 +440,7 @@ export const schemaMock = { password: 'password', allowedEmails: 'allowedEmails', outputConfigs: 'outputConfigs', + includeThinking: 'includeThinking', archivedAt: 'archivedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', diff --git a/scripts/sync-agent-stream-docs.ts b/scripts/sync-agent-stream-docs.ts new file mode 100644 index 00000000000..4936ade9dda --- /dev/null +++ b/scripts/sync-agent-stream-docs.ts @@ -0,0 +1,185 @@ +/** + * Generates the "Streamed thinking and tool calls" support tables on the Agent + * block docs page from the provider registry, so the docs can never drift from + * the code: + * + * - Thinking visibility per model comes from `capabilities.thinking.streamed` + * (explicit) or the per-provider defaults in `getThinkingStreamVisibility`. + * - Live tool-call streaming comes from `STREAMING_TOOL_CALL_PROVIDERS`. + * + * Content is rewritten between the `agent-stream-capabilities` markers in + * `apps/docs/content/docs/en/workflows/blocks/agent.mdx`. + * + * Usage: + * bun run scripts/sync-agent-stream-docs.ts # write + * bun run scripts/sync-agent-stream-docs.ts --check # fail on drift or missing metadata + */ + +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { + getThinkingStreamVisibility, + PROVIDER_DEFINITIONS, + type ThinkingStreamVisibility, +} from '../apps/sim/providers/models' +import { STREAMING_TOOL_CALL_PROVIDERS } from '../apps/sim/providers/streaming-tool-loop-shared' + +const __filename = fileURLToPath(import.meta.url) +const rootDir = path.resolve(path.dirname(__filename), '..') + +const AGENT_DOC_PATH = path.join(rootDir, 'apps/docs/content/docs/en/workflows/blocks/agent.mdx') + +const BEGIN_MARKER = + '{/* agent-stream-capabilities:begin — generated by `bun run agent-stream-docs:generate`; do not edit between markers */}' +const END_MARKER = '{/* agent-stream-capabilities:end */}' + +/** + * Providers whose thinking visibility varies per model generation and must + * therefore be declared explicitly on every thinking-capable model. + */ +const EXPLICIT_VISIBILITY_PROVIDERS = new Set(['anthropic', 'azure-anthropic']) + +const VISIBILITY_LABELS: Record = { + full: 'Full thinking deltas', + summary: 'Summaries only', + none: 'Not streamed', +} + +const VISIBILITY_NOTES: Partial> = { + 'openai:summary': 'Requires OpenAI organization verification; falls back to no summaries.', + 'azure-openai:summary': 'Requires OpenAI organization verification; falls back to no summaries.', + 'anthropic:summary': + 'These generations omit full thinking; Sim requests summarized thinking on streaming runs.', + 'azure-anthropic:summary': + 'These generations omit full thinking; Sim requests summarized thinking on streaming runs.', + 'anthropic:none': 'These model generations return thinking with omitted display by default.', + 'azure-anthropic:none': + 'These model generations return thinking with omitted display by default.', + 'bedrock:none': 'Sim does not request reasoning on Bedrock.', +} + +interface VisibilityRow { + providerName: string + visibility: ThinkingStreamVisibility + note: string + models: string[] +} + +function buildVisibilityRows(): { rows: VisibilityRow[]; errors: string[] } { + const rows: VisibilityRow[] = [] + const errors: string[] = [] + + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + const grouped = new Map() + + for (const model of provider.models) { + if (model.sunset?.status === 'deprecated') continue + const reasoningCapable = model.capabilities.thinking || model.capabilities.reasoningEffort + if (!reasoningCapable) continue + + if ( + EXPLICIT_VISIBILITY_PROVIDERS.has(provider.id) && + model.capabilities.thinking && + model.capabilities.thinking.streamed === undefined + ) { + errors.push( + `${provider.id}/${model.id}: thinking-capable models on this provider must declare capabilities.thinking.streamed ('full' | 'summary' | 'none') — visibility varies per Claude generation` + ) + continue + } + + const visibility = getThinkingStreamVisibility(model.id) + if (!visibility) continue + const models = grouped.get(visibility) ?? [] + models.push(model.id) + grouped.set(visibility, models) + } + + for (const visibility of ['full', 'summary', 'none'] as const) { + const models = grouped.get(visibility) + if (!models?.length) continue + rows.push({ + providerName: provider.name, + visibility, + note: VISIBILITY_NOTES[`${provider.id}:${visibility}`] ?? '', + models, + }) + } + } + + return { rows, errors } +} + +function buildGeneratedContent(): { content: string; errors: string[] } { + const { rows, errors } = buildVisibilityRows() + + const liveToolProviders = Object.values(PROVIDER_DEFINITIONS) + .filter((provider) => STREAMING_TOOL_CALL_PROVIDERS.has(provider.id)) + .map((provider) => provider.name) + + const lines: string[] = [] + lines.push('') + lines.push( + `Live tool-call chips stream for **${liveToolProviders.join(', ')}** models. Other providers run tools without live chips — tool results still appear in the block output when the run completes.` + ) + lines.push('') + lines.push('| Provider | Streamed thinking | Models |') + lines.push('|----------|-------------------|--------|') + for (const row of rows) { + const models = row.models.map((id) => `\`${id}\``).join(', ') + const visibility = row.note + ? `${VISIBILITY_LABELS[row.visibility]} — ${row.note}` + : VISIBILITY_LABELS[row.visibility] + lines.push(`| ${row.providerName} | ${visibility} | ${models} |`) + } + lines.push('') + + return { content: lines.join('\n'), errors } +} + +function main(): void { + const checkMode = process.argv.includes('--check') + + const { content, errors } = buildGeneratedContent() + if (errors.length > 0) { + console.error('agent-stream-docs: missing stream-visibility metadata:') + for (const error of errors) { + console.error(` - ${error}`) + } + process.exit(1) + } + + const doc = fs.readFileSync(AGENT_DOC_PATH, 'utf8') + const beginIndex = doc.indexOf(BEGIN_MARKER) + const endIndex = doc.indexOf(END_MARKER) + if (beginIndex === -1 || endIndex === -1 || endIndex < beginIndex) { + console.error( + `agent-stream-docs: markers not found in ${path.relative(rootDir, AGENT_DOC_PATH)}` + ) + process.exit(1) + } + + const next = + doc.slice(0, beginIndex + BEGIN_MARKER.length) + `\n${content}\n` + doc.slice(endIndex) + + if (checkMode) { + if (next !== doc) { + console.error( + 'agent-stream-docs: docs are out of date — run `bun run agent-stream-docs:generate`' + ) + process.exit(1) + } + console.log('agent-stream-docs: up to date.') + return + } + + if (next !== doc) { + fs.writeFileSync(AGENT_DOC_PATH, next) + console.log(`agent-stream-docs: updated ${path.relative(rootDir, AGENT_DOC_PATH)}`) + } else { + console.log('agent-stream-docs: no changes.') + } +} + +main() From 6dcc65be899804447aa4862ba82ebef60498baba Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 19:52:00 -0700 Subject: [PATCH 12/32] feat(skills): add skill editors (#5705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): permissions layer * chore(db): drop skill_member migration 0261 for regeneration on latest staging Co-Authored-By: Claude Fable 5 * feat(db): regenerate skill_member migration as 0262 on latest staging Same DDL as the dropped 0261 (skill_member table, enums, indexes, skill.workspace_shared) plus the hand-written write-user backfill, renumbered after staging's 0261. Co-Authored-By: Claude Fable 5 * feat(db): regenerate skill_member migration as 0263 after staging merge Staging claimed 0262 (strong_storm); same DDL plus the hand-written write-user backfill, renumbered on the merged snapshot chain. Co-Authored-By: Claude Fable 5 * chore(db): drop skill_member migration 0263 for regeneration on latest staging Co-Authored-By: Claude Fable 5 * feat(db): regenerate skill_member migration as 0264 after staging merge Staging claimed 0263 (workflow_fork_sync_excluded); same DDL plus the hand-written write-user backfill, renumbered on the merged snapshot chain. Co-Authored-By: Claude Fable 5 * make editing skills full page * fix disclaimer * edit access msg * fix lint * chore(db): drop skill_member migration 0264 for regeneration on latest staging Co-Authored-By: Claude Fable 5 * feat(db): regenerate skill_member migration as 0265 after staging merge Staging claimed 0264 (fat_ikaris); same DDL plus the hand-written write-user backfill, renumbered on the merged snapshot chain. Co-Authored-By: Claude Fable 5 * fix tests * simplify system * fix * fix lint * add mship skills docs * chore(db): drop skill_member migration 0265 for regeneration on latest staging Co-Authored-By: Claude Fable 5 * feat(db): regenerate skill_member migration as 0266 after staging merge Co-Authored-By: Claude Fable 5 * fix(deps): override zod to 4.3.6 to dedupe nested copies breaking type-check better-auth 1.6.23 and fumadocs-mdx resolve ^4.3.6 to a nested zod 4.4.3, which makes @sim/auth's inferred betterAuth types non-portable (TS2883) and split docs onto a second zod instance. Both ranges accept the repo-wide pinned 4.3.6, so a single hoisted copy satisfies everything. Co-Authored-By: Claude Fable 5 * fix lint * feat(skills,tools): fullscreen skill create + shared custom tool editor Moves the rich-markdown and custom-tool editing surfaces out of modals and onto full-page surfaces, and collapses the duplicated chrome behind shared components. Skills - Add /skills/new, a full-page create surface mirroring the skill detail page (CredentialDetailLayout + DetailSection + unsaved-changes guard). "Add to Sim" navigates there instead of opening a modal. - Import moves to a header action (SkillImportButton) backed by a shared readSkillFile helper; the GitHub-URL import and its /api/skills/import route are removed. - Skill name validation is now one shared validateSkillName, replacing three copies of the kebab-case rule and its messages. - The skill editor roster renders through the shared MemberRow instead of re-deriving its identity block, with a locked role control and a lock-reason tooltip explaining inherited workspace-admin access. Custom tools - Extract the canvas modal's schema/code editors into a shared custom-tool-editor module (fields, wand generation, schema helpers), cutting custom-tool-modal.tsx by ~900 lines. - Settings > Custom tools gains a full-page detail sub-view (SettingsPanel + SettingsSection + saveDiscardActions), deep-linkable via ?custom-tool-id. Rows are clickable; delete now lives only in the detail view. - Replace legacy Button/Input/Badge/Label with the chip family, move chip-field chrome into CodeEditor behind an error prop, and delete its dead wand button. Rich markdown field - maxHeight is now opt-in: omit it on a page and the editor grows with its content so the page owns the only scrollbar. Modals pass explicit caps. - The field variant drops to font-weight 400 to match adjacent chip fields. * fix(skills): address review round on create navigation, 409 copy, and editor audit - Skill create navigated using the first element of the upsert response, but that endpoint returns the caller's whole skill list (built-ins prepended) — match the new skill by its workspace-unique name instead. - The suggested-skill 409 toast claimed the skill existed but was not shared and told the user to ask a skill admin. Every workspace member can already see and use every skill, so a 409 only means the name is taken. - Adding an editor emitted the skill_shared event and SKILL_MEMBER_ADDED audit even when onConflictDoNothing skipped the insert on a concurrent add. Gate both on the insert actually returning a row. * chore: format skills-resolver test import * fix(skills,tools): audit fixes — autocomplete boundary, resize clipping, error routing Two real regressions introduced while simplifying the extracted editor: - The schema-param autocomplete's trigger was rewritten to match a trailing identifier, but the completion still split on separators. The two disagreed, so typing `data.ci` opened the menu and selecting replaced `data.ci` whole — eating the member-access prefix. Both now share one SCHEMA_PARAM_WORD regex. - The uncapped markdown field measured its height only on value change while always setting overflow-hidden, so any width change that re-wrapped lines clipped the tail with no scrollbar to reach it. Now re-measures via ResizeObserver. Also from the audit: - Generation writes bypass the code field's change handler, so an open autocomplete stayed over a disabled streaming editor; close it on busy. - Delete failures rendered in the Schema section's error slot on both custom tool surfaces; route them to a toast instead. - Skill create navigated away while still dirty, stranding the unsaved-changes guard's history sentinel so Back landed on an empty create form. - The Description field on skill create never received its error border. - Drop a double-applied opacity-50 (the editor already dims when disabled), a dead try/catch around a non-throwing call that also shadowed the error prop, and a stale reference to a /tools page that does not exist. - Docs still described the removed GitHub-URL import and the old Add Skill dialog; rewrite for the create page and file/paste import. * feat(tools): read-only tool detail, create lands on the new tool, drop dead wand prompt API - Viewers without edit rights could not open a custom tool at all, while the equivalent skill and custom-block surfaces both offer a read-only view. The detail page now takes `readOnly`: editors inert, no Save/Discard/Delete, no Generate. Creating still requires edit rights. - Creating a tool bounced back to the list while creating a skill lands on the new skill. Tools now do the same. The upsert returns the workspace's whole tool list (newest first) rather than just the new row, so the id is matched by title instead of by index — the same trap that produced the skill-create navigation bug. - Remove `openPrompt`/`closePrompt` from useWand. `closePrompt`'s last callers went away with the custom-tool-modal extraction and `openPrompt` had none before it; nothing reads `isPromptVisible` any more either. * fix(tools): read-only editors, design-system wrench, skills-matching tool identity - readOnly never reached the editors: the prop gated actions and Generate but the schema and code fields were still typable for viewers without edit rights. Wire disabled through both fields into CodeEditor. - The row icon used lucide's Wrench (strokeWidth 2) where @sim/emcn/icons ships one drawn for this system (1.55, tuned viewBox), and it inherited body text colour instead of --text-icon. Swap it. - Give the tool detail page the same identity heading as skill detail: tile, name, and description at the top left, instead of only a header title. - Extract ResourceTile so the skills and tools tiles share one definition (SkillTile now composes it), and add an opt-in `iconFilled` to SettingsResourceRow so the tools list tile matches the skills gallery. Both default to today's behaviour for every existing consumer. * fix(mentions): use the product's own glyph for every @ mention kind The `@` menu and the inserted chip mapped kinds to arbitrary lucide icons — `Sparkles` for a skill, a generic `File` for every file — while the rest of the product has a settled glyph per resource. Mirror CHAT_CONTEXT_KIND_REGISTRY, which Chat's `@` menu already renders from: - skill now uses AgentSkillsIcon, the same glyph SkillTile shows everywhere - workflow / folder / table / knowledge use the @sim/emcn/icons set the sidebar and the chat registry use - file derives its icon from the filename extension, so a .pdf and a .csv are distinguishable, matching the file list and Chat's context chips - integration keeps the block's brand icon from the registry Also drop the generic placeholder. `kind` is untrusted — the node schema defaults it to `''` and a hand-written `sim:` link can carry anything — but an unrecognized kind now yields no icon instead of a meaningless box, which is what the chat registry does. The menu already guarded a missing icon; the chip now does too, so this cannot crash on a malformed link. * chore(db): drop skill_member migration 0266 for regeneration on latest staging * feat(db): regenerate skill_member migration as 0267 after staging merge --------- Co-authored-by: Claude Fable 5 Co-authored-by: Waleed Latif --- apps/docs/content/docs/en/agents/skills.mdx | 37 +- apps/sim/app/api/mothership/execute/route.ts | 61 +- apps/sim/app/api/skills/[id]/members/route.ts | 251 + apps/sim/app/api/skills/import/route.ts | 103 - apps/sim/app/api/skills/route.ts | 76 +- .../[id]/members/[memberId]/route.ts | 2 + .../v1/admin/workspaces/[id]/members/route.ts | 2 + .../app/api/workspaces/members/[id]/route.ts | 2 + .../components/add-people-modal.tsx | 152 +- .../components/credential-members-section.tsx | 73 +- .../components/credential-detail/roles.ts | 15 - .../custom-tool-code-field.tsx | 380 + .../custom-tool-schema-field.tsx | 41 + .../custom-tool-editor/custom-tool-schema.ts | 126 + .../custom-tool-editor/field-error-text.tsx | 10 + .../generate-prompt-control.tsx | 90 + .../components/custom-tool-editor/index.ts | 15 + .../use-custom-tool-generation.ts | 193 + .../[workspaceId]/components/index.ts | 1 + .../components/resource-tile/index.ts | 1 + .../resource-tile/resource-tile.tsx | 20 + .../components/skill-tile/skill-tile.tsx | 9 +- .../mention/mention-chip.tsx | 6 +- .../mention/mention-icon.test.ts | 24 +- .../mention/mention-icon.ts | 42 +- .../mention/use-markdown-mentions.ts | 2 +- .../rich-markdown-editor.css | 6 +- .../rich-markdown-field.tsx | 69 +- .../[block]/integration-skills-section.tsx | 12 +- .../settings/[section]/search-params.ts | 16 + .../custom-tool-detail/custom-tool-detail.tsx | 336 + .../components/custom-tool-detail/index.ts | 1 + .../components/custom-tools/custom-tools.tsx | 256 +- .../settings-resource-row.tsx | 16 +- .../components/skill-editors-card.tsx | 59 + .../[workspaceId]/skills/[skillId]/page.tsx | 15 + .../skills/[skillId]/skill-detail.tsx | 356 + .../skills/components/skill-import/index.ts | 1 + .../skill-import/skill-import-button.tsx | 58 + .../components/skill-import/skill-import.tsx | 170 +- .../skills/components/skill-members/index.ts | 4 + .../use-skill-editors-controller.ts | 76 + .../components/skill-modal/skill-modal.tsx | 71 +- .../[workspaceId]/skills/components/utils.ts | 57 +- .../[workspaceId]/skills/new/page.tsx | 15 + .../[workspaceId]/skills/new/skill-create.tsx | 237 + .../[workspaceId]/skills/search-params.ts | 12 +- .../workspace/[workspaceId]/skills/skills.tsx | 111 +- .../components/skill-input/skill-input.tsx | 23 +- .../components/code-editor/code-editor.tsx | 38 +- .../custom-tool-modal/custom-tool-modal.tsx | 952 +- .../w/[workflowId]/hooks/use-wand.ts | 13 - .../permissions/add-people-modal.tsx | 171 + apps/sim/components/permissions/index.ts | 10 + .../permissions/member-role-options.ts | 28 + .../sim/components/permissions/member-row.tsx | 90 + apps/sim/components/permissions/role-lock.tsx | 9 + .../lib/copy/copy-resources.test.ts | 71 +- .../lib/copy/copy-resources.ts | 46 +- .../handlers/agent/skills-resolver.test.ts | 56 +- .../handlers/agent/skills-resolver.ts | 8 +- apps/sim/executor/handlers/pi/context.ts | 15 +- apps/sim/executor/handlers/pi/pi-handler.ts | 1 - apps/sim/hooks/queries/skills.ts | 120 +- apps/sim/lib/api/contracts/skills.ts | 115 +- .../lib/billing/organizations/membership.ts | 4 + .../sim/lib/copilot/chat/workspace-context.ts | 23 +- .../tools/handlers/management/manage-skill.ts | 54 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 8 +- apps/sim/lib/credentials/access.test.ts | 5 + apps/sim/lib/credentials/access.ts | 19 +- apps/sim/lib/mothership/inbox/executor.ts | 27 +- apps/sim/lib/posthog/events.ts | 10 + apps/sim/lib/skills/access.test.ts | 282 + apps/sim/lib/skills/access.ts | 212 + .../lib/workflows/skills/operations.test.ts | 84 +- apps/sim/lib/workflows/skills/operations.ts | 144 +- apps/sim/lib/workspaces/permissions/utils.ts | 15 + .../workspaces}/sharing.test.ts | 2 +- .../workspaces}/sharing.ts | 5 +- bun.lock | 30 +- package.json | 3 +- packages/audit/src/types.ts | 2 + packages/db/migrations/0267_dark_romulus.sql | 29 + .../db/migrations/meta/0267_snapshot.json | 17529 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 25 + .../components/chip-switch/chip-switch.tsx | 2 +- packages/testing/src/mocks/audit.mock.ts | 2 + packages/testing/src/mocks/schema.mock.ts | 8 + scripts/check-api-validation-contracts.ts | 4 +- scripts/check-migrations-safety.ts | 5 +- 92 files changed, 22045 insertions(+), 1949 deletions(-) create mode 100644 apps/sim/app/api/skills/[id]/members/route.ts delete mode 100644 apps/sim/app/api/skills/import/route.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx create mode 100644 apps/sim/components/permissions/add-people-modal.tsx create mode 100644 apps/sim/components/permissions/member-role-options.ts create mode 100644 apps/sim/components/permissions/member-row.tsx create mode 100644 apps/sim/lib/skills/access.test.ts create mode 100644 apps/sim/lib/skills/access.ts rename apps/sim/{app/workspace/[workspaceId]/components/credential-detail => lib/workspaces}/sharing.test.ts (96%) rename apps/sim/{app/workspace/[workspaceId]/components/credential-detail => lib/workspaces}/sharing.ts (88%) create mode 100644 packages/db/migrations/0267_dark_romulus.sql create mode 100644 packages/db/migrations/meta/0267_snapshot.json diff --git a/apps/docs/content/docs/en/agents/skills.mdx b/apps/docs/content/docs/en/agents/skills.mdx index 027ae0def9c..bd608982d3e 100644 --- a/apps/docs/content/docs/en/agents/skills.mdx +++ b/apps/docs/content/docs/en/agents/skills.mdx @@ -18,13 +18,11 @@ Skills use **progressive disclosure** to keep agent context lean: ## Creating Skills -Skills live on the **Integrations** page: click **Integrations** in the workspace sidebar, then switch to the **Skills** tab. It lists every skill in the workspace, searchable by name. +Skills live on the **Integrations** page: click **Integrations** in the workspace sidebar, then switch to the **Skills** tab. It lists every skill in the workspace, searchable by name. Click a skill to open its detail page, where you edit, share, and delete it. ![The Skills tab on the Integrations page](/static/skills/skills-tab.png) -Click **+ Add to Sim** to open the **Add Skill** dialog. The **Create** tab takes three fields: - -![The Add Skill dialog, Create tab](/static/skills/add-skill-create.png) +Click **+ Add to Sim** to open the skill create page, which takes three fields: | Field | Description | |-------|-------------| @@ -38,13 +36,10 @@ Click **+ Add to Sim** to open the **Add Skill** dialog. The **Create** tab take ### Importing skills -The **Import** tab brings in an existing skill in the open [SKILL.md](https://agentskills.io/specification) format, three ways: - -![The Add Skill dialog, Import tab](/static/skills/add-skill-import.png) +Bring in an existing skill in the open [SKILL.md](https://agentskills.io/specification) format two ways: -- **Upload a file** — a `.md` file with YAML frontmatter, or a `.zip` containing a `SKILL.md`. -- **Import from GitHub** — paste a GitHub URL to a `SKILL.md` and click **Fetch**. -- **Paste content** — paste the `SKILL.md` directly. The frontmatter carries the `name` and `description`; the markdown body is the content. +- **Import** — the **Import** action on the create page takes a `.md` file with YAML frontmatter, or a `.zip` containing a `SKILL.md`. +- **Paste content** — paste the `SKILL.md` straight into **Content**. The frontmatter carries the `name` and `description`; the markdown body is the content. Integration pages suggest **curated skills** for their service — open one (HubSpot, for example) and add a suggested skill with one click. @@ -77,6 +72,24 @@ Use when the user asks you to write, optimize, or debug SQL queries. Keep skills focused and under 500 lines. If a skill grows too large, split it into multiple specialized skills. +## Skill Editors + +Everyone in the workspace sees and uses every skill — including members who join later. Nobody needs to be added to a skill to use it. + +Each skill has an explicit **editors** list. Editors can edit the skill, delete it, and manage the editors list. Workspace admins can always do this too — they are editors of every skill automatically and cannot be removed from the list. Whoever creates a skill becomes an editor. + +Open a skill from the Skills tab to manage it. The detail page has the editable fields, a **Share** action for adding editors from your workspace members, and the **Skill Editors** list at the bottom. + + + The editors list controls who can edit a skill — it never affects who can see, use, or run it. A workflow that references a skill always executes it, no matter who runs the workflow. Treat skill content as shared team instructions, not as a secret. + + +## Using Skills in Chat + +Skills work in Chat too. Type `/` in the message box to open the skills menu, then pick a skill — or keep typing to filter by name. The skill appears in your message as a tag, e.g. `/format-markdown`. + +Tagging a skill loads its full instructions into the conversation, so Sim follows them for that request — no waiting for Sim to decide the skill is relevant on its own. + ## Adding Skills to an Agent Open any **Agent** block and find the **Skills** dropdown below the tools section. Select the skills you want the agent to have access to. @@ -156,5 +169,7 @@ import { FAQ } from '@/components/ui/faq' { question: "When should I use skills vs. agent instructions?", answer: "Use skills for knowledge that applies across multiple workflows or changes frequently. Skills are reusable packages that can be attached to any agent. Use agent instructions for task-specific context that is unique to a single agent and workflow. If you find yourself copying the same instructions into multiple agents, that content should be a skill instead." }, { question: "Can permission groups disable skills for certain users?", answer: "Yes. On Enterprise-entitled workspaces, any workspace admin can create a permission group with the disableSkills option enabled. When a user is assigned to such a group in a workspace, the skills dropdown in agent blocks is disabled and they cannot add or use skills in workflows belonging to that workspace." }, { question: "What is the recommended maximum length for skill content?", answer: "Keep skills focused and under 500 lines. If a skill grows too large, split it into multiple specialized skills. Shorter, focused skills are more effective because the agent can load exactly what it needs. A broad skill with too much content can overwhelm the agent and reduce the quality of its responses." }, - { question: "Where do I create and manage skills?", answer: "Click Integrations in the workspace sidebar and switch to the Skills tab. Add Skill creates one from a name (kebab-case, max 64 characters), description (max 1024 characters), and markdown content — or imports an existing SKILL.md from a file, a GitHub URL, or pasted content. Existing skills are edited and deleted from the same tab." }, + { question: "Where do I create and manage skills?", answer: "Click Integrations in the workspace sidebar and switch to the Skills tab. Add to Sim opens a create page taking a name (kebab-case, max 64 characters), description (max 1024 characters), and markdown content — or imports an existing SKILL.md from a file or pasted content. Click any existing skill to open its detail page, where you edit, share, and delete it." }, + { question: "Who can edit a skill, and who can use it?", answer: "Everyone in the workspace — including people who join later — sees and uses every skill without being added to anything. Each skill has an editors list: editors and workspace admins (who are always editors, automatically) can edit, delete, and share the skill, and whoever creates a skill becomes an editor. The editors list never affects who can use or run a skill: a workflow that references a skill always executes it." }, + { question: "Can I use skills in Chat?", answer: "Yes. Type / in the Chat message box to open the skills menu and pick a skill — for example /format-markdown. The tag loads the skill's full instructions into the conversation, so Sim follows them for that request without having to decide on its own to load the skill." }, ]} /> diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 9904802b644..36641a775f2 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -22,7 +22,6 @@ import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { assertActiveWorkspaceAccess, - getUserEntityPermissions, isWorkspaceAccessDeniedError, } from '@/lib/workspaces/permissions/utils' import type { ChatContext } from '@/stores/panel' @@ -130,7 +129,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } const userId = auth.userId ?? bodyUserId - await assertActiveWorkspaceAccess(workspaceId, userId) + const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) const billingAttribution = requireBillingAttributionHeader(req.headers, { actorUserId: userId, workspaceId, @@ -152,38 +151,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => { context.kind === 'mcp' && context.serverId ? [context.serverId] : [] ) const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp') - const [ - workspaceContext, - integrationTools, - mothershipTools, - userPermission, - entitlements, - agentContexts, - ] = await Promise.all([ - generateWorkspaceContext(workspaceId, userId), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - Promise.all([ - buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []), - buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds), - ]).then((groups) => { - const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) - return [...byName.values()] - }), - getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), - computeWorkspaceEntitlements(workspaceId, userId), - processContextsServer( - nonMcpAgentMentions, - userId, - lastUserMessage, - workspaceId, - effectiveChatId - ).catch((error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - }), - ]) + const userPermission = workspaceAccess.permission + const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] = + await Promise.all([ + generateWorkspaceContext(workspaceId, userId, { workspaceAccess }), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + Promise.all([ + buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []), + buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds), + ]).then((groups) => { + const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) + return [...byName.values()] + }), + computeWorkspaceEntitlements(workspaceId, userId), + processContextsServer( + nonMcpAgentMentions, + userId, + lastUserMessage, + workspaceId, + effectiveChatId + ).catch((error) => { + reqLogger.warn('Failed to resolve agent contexts for execution', { + error: toError(error).message, + }) + return [] + }), + ]) const requestPayload: Record = { messages, responseFormat, diff --git a/apps/sim/app/api/skills/[id]/members/route.ts b/apps/sim/app/api/skills/[id]/members/route.ts new file mode 100644 index 00000000000..27920ac9a70 --- /dev/null +++ b/apps/sim/app/api/skills/[id]/members/route.ts @@ -0,0 +1,251 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { skillMember } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { removeSkillMemberContract, upsertSkillMemberContract } from '@/lib/api/contracts/skills' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { getSkillActorContext, listSkillEditors } from '@/lib/skills/access' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('SkillMembersAPI') + +interface RouteContext { + params: Promise<{ id: string }> +} + +type SkillEditorGate = + | { ok: true; workspaceId: string } + | { ok: false; reason: 'not-found' | 'not-editor' } + +/** + * Resolves the skill and asserts the actor can edit it (explicit editor row or + * derived workspace admin). Skills the actor cannot reach at all (missing, + * builtin, no workspace, no workspace access) read as not-found; + * visible-but-not-editor reads as forbidden. + */ +async function requireSkillEditor(skillId: string, userId: string): Promise { + if (isBuiltinSkillId(skillId)) return { ok: false, reason: 'not-found' } + + const actor = await getSkillActorContext(skillId, userId) + if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) { + return { ok: false, reason: 'not-found' } + } + if (!actor.canEdit) return { ok: false, reason: 'not-editor' } + + return { ok: true, workspaceId: actor.skill.workspaceId } +} + +function skillEditorGateResponse(reason: 'not-found' | 'not-editor'): NextResponse { + return reason === 'not-found' + ? NextResponse.json({ error: 'Not found' }, { status: 404 }) + : NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) +} + +export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: skillId } = await context.params + + if (isBuiltinSkillId(skillId)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const actor = await getSkillActorContext(skillId, session.user.id) + if (!actor.skill?.workspaceId || !actor.hasWorkspaceAccess) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const editors = await listSkillEditors({ + id: actor.skill.id, + workspaceId: actor.skill.workspaceId, + }) + + return NextResponse.json({ editors }) + } catch (error) { + logger.error('Failed to fetch skill editors', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) + +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: skillId } = await context.params + + const gate = await requireSkillEditor(skillId, session.user.id) + if (!gate.ok) { + logger.warn('Skill editor add denied', { + skillId, + actorId: session.user.id, + reason: gate.reason, + }) + return skillEditorGateResponse(gate.reason) + } + + const parsed = await parseRequest(upsertSkillMemberContract, request, context) + if (!parsed.success) return parsed.response + + const { userId } = parsed.data.body + + const targetWorkspacePerm = await getUserEntityPermissions( + userId, + 'workspace', + gate.workspaceId + ) + if (targetWorkspacePerm === null) { + return NextResponse.json({ error: 'User is not a member of this workspace' }, { status: 400 }) + } + if (targetWorkspacePerm === 'admin') { + return NextResponse.json( + { error: 'Workspace admins can always edit skills' }, + { status: 400 } + ) + } + + const [existing] = await db + .select({ id: skillMember.id }) + .from(skillMember) + .where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, userId))) + .limit(1) + + if (existing) { + return NextResponse.json({ success: true }) + } + + const now = new Date() + // Conflict-safe against a concurrent add racing the unique (skillId, userId) index. + const [inserted] = await db + .insert(skillMember) + .values({ + id: generateId(), + skillId, + userId, + invitedBy: session.user.id, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] }) + .returning({ id: skillMember.id }) + + // A concurrent request won the race and created the row. The editor exists, + // so this is still a success — but this request added nothing, and emitting + // the share event or audit entry here would record an add that never happened. + if (!inserted) { + return NextResponse.json({ success: true }) + } + + captureServerEvent( + session.user.id, + 'skill_shared', + { skill_id: skillId, workspace_id: gate.workspaceId }, + { groups: { workspace: gate.workspaceId } } + ) + + recordAudit({ + workspaceId: gate.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.SKILL_MEMBER_ADDED, + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + description: 'Added skill editor', + metadata: { targetUserId: userId }, + request, + }) + + return NextResponse.json({ success: true }, { status: 201 }) + } catch (error) { + logger.error('Failed to add skill editor', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: skillId } = await context.params + + const gate = await requireSkillEditor(skillId, session.user.id) + if (!gate.ok) { + logger.warn('Skill editor removal denied', { + skillId, + actorId: session.user.id, + reason: gate.reason, + }) + return skillEditorGateResponse(gate.reason) + } + + const parsed = await parseRequest(removeSkillMemberContract, request, context) + if (!parsed.success) return parsed.response + + const { userId: targetUserId } = parsed.data.query + + const targetWorkspacePerm = await getUserEntityPermissions( + targetUserId, + 'workspace', + gate.workspaceId + ) + if (targetWorkspacePerm === 'admin') { + return NextResponse.json( + { error: 'Workspace admins can always edit skills' }, + { status: 400 } + ) + } + + // Hard delete — no deny markers and no last-editor guard: workspace admins + // always remain derived editors, so a skill can never be orphaned. + const removed = await db + .delete(skillMember) + .where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, targetUserId))) + .returning({ id: skillMember.id }) + + if (removed.length === 0) { + return NextResponse.json({ error: 'Editor not found' }, { status: 404 }) + } + + captureServerEvent( + session.user.id, + 'skill_unshared', + { skill_id: skillId, workspace_id: gate.workspaceId }, + { groups: { workspace: gate.workspaceId } } + ) + + recordAudit({ + workspaceId: gate.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.SKILL_MEMBER_REMOVED, + resourceType: AuditResourceType.SKILL, + resourceId: skillId, + description: 'Removed skill editor', + metadata: { targetUserId }, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to remove skill editor', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/skills/import/route.ts b/apps/sim/app/api/skills/import/route.ts deleted file mode 100644 index 571f4764078..00000000000 --- a/apps/sim/app/api/skills/import/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { importSkillContract } from '@/lib/api/contracts' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('SkillsImportAPI') - -const FETCH_TIMEOUT_MS = 15_000 - -/** - * Converts a standard GitHub file URL to its raw.githubusercontent.com equivalent. - * - * Supported formats: - * github.com/{owner}/{repo}/blob/{branch}/{path} - * raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} (passthrough) - */ -function toRawGitHubUrl(url: string): string { - const parsed = new URL(url) - - if (parsed.hostname === 'raw.githubusercontent.com') { - return url - } - - if (parsed.hostname !== 'github.com') { - throw new Error('Only GitHub URLs are supported') - } - - const segments = parsed.pathname.split('/').filter(Boolean) - if (segments.length < 5 || segments[2] !== 'blob') { - throw new Error( - 'Invalid GitHub URL format. Expected: https://github.com/{owner}/{repo}/blob/{branch}/{path}' - ) - } - - const [owner, repo, , branch, ...pathParts] = segments - return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathParts.join('/')}` -} - -/** POST - Fetch a SKILL.md from a GitHub URL and return its raw content */ -export const POST = withRouteHandler(async (req: NextRequest) => { - const requestId = generateRequestId() - - try { - const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized skill import attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const validation = await parseRequest(importSkillContract, req, {}) - if (!validation.success) return validation.response - const { url } = validation.data.body - - let rawUrl: string - try { - rawUrl = toRawGitHubUrl(url) - } catch (err) { - const message = getErrorMessage(err, 'Invalid URL') - return NextResponse.json({ error: message }, { status: 400 }) - } - - const response = await fetch(rawUrl, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - headers: { Accept: 'text/plain' }, - }) - - if (!response.ok) { - logger.warn(`[${requestId}] GitHub fetch failed`, { - status: response.status, - url: rawUrl, - }) - return NextResponse.json( - { error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` }, - { status: 502 } - ) - } - - const contentLength = response.headers.get('content-length') - if (contentLength && Number.parseInt(contentLength, 10) > 100_000) { - return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 }) - } - - const content = await response.text() - - if (content.length > 100_000) { - return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 }) - } - - return NextResponse.json({ content }) - } catch (error) { - if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { - logger.warn(`[${requestId}] GitHub fetch timed out`) - return NextResponse.json({ error: 'Request timed out' }, { status: 504 }) - } - - logger.error(`[${requestId}] Error importing skill`, error) - return NextResponse.json({ error: 'Failed to import skill' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 5a945101be7..f31f0881e71 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -11,9 +11,10 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { checkSkillsUpdateAccess, getSkillActorContext } from '@/lib/skills/access' import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' -import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsAPI') @@ -41,13 +42,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { workspaceId } = query.data - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!userPermission) { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.hasAccess) { logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const result = await listSkills({ workspaceId }) + const result = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) const data = result.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) return NextResponse.json({ data }, { status: 200 }) @@ -85,8 +86,40 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { skills, workspaceId, source } = parsed.data.body - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.hasAccess) { + logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + if (skills.some((s) => s.id && isBuiltinSkillId(s.id))) { + return NextResponse.json({ error: 'Built-in skills are read-only' }, { status: 400 }) + } + + // Updating an existing skill requires editor access (explicit editor row + // or derived workspace admin); creating a new one requires workspace write. + const requestedIds = skills.flatMap((s) => (s.id ? [s.id] : [])) + const { existingIds, denied } = await checkSkillsUpdateAccess({ + workspaceId, + userId, + skillIds: requestedIds, + workspaceAccess, + }) + + if (denied.length > 0) { + logger.warn(`[${requestId}] User ${userId} is not an editor of skills being updated`, { + deniedSkillIds: denied.map((s) => s.id), + }) + return NextResponse.json( + { + error: `Skill editor access required to update: ${denied.map((s) => s.name).join(', ')}`, + }, + { status: 403 } + ) + } + + const hasCreates = skills.some((s) => !s.id || !existingIds.has(s.id)) + if (hasCreates && !workspaceAccess.canWrite) { logger.warn( `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` ) @@ -94,11 +127,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => { } try { - const { skills: resultSkills, touched } = await upsertSkills({ + const { touched } = await upsertSkills({ skills, workspaceId, userId, requestId, + returnSkills: false, }) for (const { id, name, operation } of touched) { @@ -123,11 +157,17 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - return NextResponse.json({ success: true, data: resultSkills }) + const resultSkills = await listSkillsForUser({ workspaceId, userId, workspaceAccess }) + const data = resultSkills.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) + + return NextResponse.json({ success: true, data }) } catch (upsertError) { - if (upsertError instanceof Error && upsertError.message.includes('already exists')) { + if (upsertError instanceof Error && upsertError.message.includes('is unavailable')) { return NextResponse.json({ error: upsertError.message }, { status: 409 }) } + if (upsertError instanceof Error && upsertError.message.startsWith('Skill not found')) { + return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) + } throw upsertError } } catch (error) { @@ -160,12 +200,16 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const { id: skillId, workspaceId, source } = query.data - const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) { - logger.warn( - `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) + if (!isBuiltinSkillId(skillId)) { + const actor = await getSkillActorContext(skillId, userId) + if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { + logger.warn(`[${requestId}] Skill not found: ${skillId}`) + return NextResponse.json({ error: 'Skill not found' }, { status: 404 }) + } + if (!actor.canEdit) { + logger.warn(`[${requestId}] User ${userId} is not an editor of skill ${skillId}`) + return NextResponse.json({ error: 'Skill editor access required' }, { status: 403 }) + } } const deleted = await deleteSkill({ skillId, workspaceId }) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts index fc3082427ab..bcc8df9dcfb 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -34,6 +34,7 @@ import { import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -286,6 +287,7 @@ export const DELETE = withRouteHandler( await tx.delete(permissions).where(eq(permissions.id, memberId)) await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, existingMember.userId) + await removeWorkspaceSkillMembershipsTx(tx, workspaceId, existingMember.userId) }) logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, { diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts index 0be8954b693..85523cd6c11 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts @@ -45,6 +45,7 @@ import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -371,6 +372,7 @@ export const DELETE = withRouteHandler( await tx.delete(permissions).where(eq(permissions.id, existingPermission.id)) await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) + await removeWorkspaceSkillMembershipsTx(tx, workspaceId, userId) }) logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`) diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 51a1e038c63..5472850c5e6 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -12,6 +12,7 @@ import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, @@ -144,6 +145,7 @@ export const DELETE = withRouteHandler( ) await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) + await removeWorkspaceSkillMembershipsTx(tx, workspaceId, userId) return { ownershipTransferred: didTransferOwnership, workflowOwnershipReassignment } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx index f66a45b2335..0a7a331deb9 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx @@ -1,26 +1,14 @@ 'use client' - -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, -} from '@sim/emcn' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' + type AddPeopleTarget, + type MemberRole, + AddPeopleModal as SharedAddPeopleModal, +} from '@/components/permissions' import { useUpsertWorkspaceCredentialMember, useWorkspaceCredentialMembers, - type WorkspaceCredentialRole, } from '@/hooks/queries/credentials' -import { ROLE_OPTIONS } from '../roles' -import { partitionSettledFailures, resolveAddEmail } from '../sharing' - -const logger = createLogger('AddPeopleModal') interface AddPeopleModalProps { credentialId: string @@ -29,28 +17,12 @@ interface AddPeopleModalProps { } /** - * Shared "Add people" modal: grants existing workspace members access to a - * credential with a chosen role. Emails are validated against the workspace - * roster and current membership; each add is an idempotent upsert and partial - * failures keep only the people that still need adding. + * "Add people" for a credential: wires the shared modal to credential + * membership. Active members count as already having access. */ export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleModalProps) { - const { workspacePermissions } = useWorkspacePermissionsContext() const { data: members = [] } = useWorkspaceCredentialMembers(credentialId) - const upsertMember = useUpsertWorkspaceCredentialMember() - - const [emailsToAdd, setEmailsToAdd] = useState([]) - const [roleToAdd, setRoleToAdd] = useState('member') - const [isAdding, setIsAdding] = useState(false) - const [submitError, setSubmitError] = useState(null) - - const workspaceUserIdByEmail = useMemo( - () => - new Map( - (workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId]) - ), - [workspacePermissions?.users] - ) + const { mutateAsync: upsertMemberAsync } = useUpsertWorkspaceCredentialMember() const existingMemberEmails = useMemo( () => @@ -63,108 +35,18 @@ export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleMo [members] ) - const validateAddEmail = useCallback( - (email: string): string | null => { - const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails }) - return 'error' in result ? result.error : null - }, - [workspaceUserIdByEmail, existingMemberEmails] + const addMember = useCallback( + (target: AddPeopleTarget, role: MemberRole) => + upsertMemberAsync({ credentialId, userId: target.userId, role }), + [upsertMemberAsync, credentialId] ) - const handleClose = useCallback(() => { - setEmailsToAdd([]) - setRoleToAdd('member') - setSubmitError(null) - onOpenChange(false) - }, [onOpenChange]) - - const handleAddPeople = useCallback(async () => { - if (emailsToAdd.length === 0 || isAdding) return - setSubmitError(null) - const targets = emailsToAdd - .map((email) => { - const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails }) - return 'userId' in result ? { email, userId: result.userId } : null - }) - .filter((target): target is { email: string; userId: string } => target !== null) - if (targets.length === 0) return - - setIsAdding(true) - try { - const results = await Promise.allSettled( - targets.map((target) => - upsertMember.mutateAsync({ credentialId, userId: target.userId, role: roleToAdd }) - ) - ) - const failures = partitionSettledFailures(targets, results) - if (failures.length === 0) { - handleClose() - return - } - setEmailsToAdd(failures.map((target) => target.email)) - const firstError = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - logger.error('Failed to add some credential members', firstError?.reason) - const reason = getErrorMessage(firstError?.reason, 'Please try again in a moment.') - setSubmitError( - failures.length === targets.length - ? `Couldn't add people. ${reason}` - : `Couldn't add ${failures.length} of ${targets.length} people. ${reason}` - ) - } finally { - setIsAdding(false) - } - }, [ - credentialId, - emailsToAdd, - isAdding, - workspaceUserIdByEmail, - existingMemberEmails, - roleToAdd, - upsertMember, - handleClose, - ]) - return ( - { - if (!next) handleClose() - }} - srTitle='Add people' - > - Add people - - - setRoleToAdd(role as WorkspaceCredentialRole)} - disabled={isAdding} - /> - {submitError} - - - + onOpenChange={onOpenChange} + existingMemberEmails={existingMemberEmails} + addMember={addMember} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx index 2d8a0e9c548..b08907f2cc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx @@ -1,15 +1,16 @@ 'use client' -import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn' import { createLogger } from '@sim/logger' -import { credentialRoleLockReason, RoleLockTooltip } from '@/components/permissions' -import { getUserColor } from '@/lib/workspaces/colors' +import { + credentialRoleLockReason, + MEMBER_ROLE_OPTIONS, + type MemberRole, + MemberRow, +} from '@/components/permissions' import { useRemoveWorkspaceCredentialMember, useUpsertWorkspaceCredentialMember, useWorkspaceCredentialMembers, - type WorkspaceCredentialRole, } from '@/hooks/queries/credentials' -import { ROLE_OPTIONS } from '../roles' import { DetailSection } from './detail-section' const logger = createLogger('CredentialMembersSection') @@ -35,7 +36,7 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe (member) => member.role === 'admin' && member.roleSource !== 'workspace-admin' ).length - const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => { + const handleChangeMemberRole = async (userId: string, role: MemberRole) => { const current = activeMembers.find((member) => member.userId === userId) if (current?.role === role) return try { @@ -63,58 +64,18 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe member.role === 'admin' && member.roleSource !== 'workspace-admin' && explicitAdminCount <= 1 - const roleDisabled = !isAdmin || roleLocked || lockReason !== null - const removeDisabled = roleLocked || lockReason !== null return ( -
-
- - - {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()} - - -
- - {member.userName || member.userEmail || member.userId} - - - {member.userEmail || member.userId} - -
-
- - - handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole) - } - /> - - {isAdmin && ( - handleRemoveMember(member.userId)} - disabled={removeDisabled} - flush - className='justify-self-end' - > - Remove - - )} -
+ member={member} + roleOptions={MEMBER_ROLE_OPTIONS} + lockReason={lockReason} + canManage={isAdmin} + roleDisabled={!isAdmin || roleLocked || lockReason !== null} + removeDisabled={roleLocked || lockReason !== null} + onRoleChange={(role) => handleChangeMemberRole(member.userId, role)} + onRemove={() => handleRemoveMember(member.userId)} + /> ) })}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts deleted file mode 100644 index 2956f8ed07d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { WorkspaceCredentialRole } from '@/hooks/queries/credentials' - -export interface CredentialRoleOption { - value: WorkspaceCredentialRole - label: string -} - -/** - * Roles assignable to a credential member. Shared by every credential detail - * surface (Integrations, Secrets) so role choices never drift between them. - */ -export const ROLE_OPTIONS: readonly CredentialRoleOption[] = [ - { value: 'member', label: 'Member' }, - { value: 'admin', label: 'Admin' }, -] as const diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx new file mode 100644 index 00000000000..9290ca41b2b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field.tsx @@ -0,0 +1,380 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { + ChipTag, + CODE_LINE_HEIGHT_PX, + Popover, + PopoverAnchor, + PopoverContent, + PopoverItem, + PopoverScrollArea, + PopoverSection, +} from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { + CODE_PLACEHOLDER, + type SchemaParameter, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' +import type { useCodeGeneration } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation' +import { + checkEnvVarTrigger, + EnvVarDropdown, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' +import { + checkTagTrigger, + TagDropdown, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor' + +const logger = createLogger('CustomToolCodeField') + +interface CustomToolCodeFieldProps { + value: string + onChange: (value: string) => void + error: boolean + generation: ReturnType + schemaParameters: SchemaParameter[] + workspaceId: string + /** + * Workflow block the editor is embedded in. Only present on the canvas — + * without it there is no upstream block output to reference, so the `<` + * tag autocomplete is not offered. + */ + blockId?: string + /** Renders the editor inert for viewers without edit rights. */ + disabled?: boolean +} + +interface TriggerState { + show: boolean + searchTerm: string +} + +/** Trailing identifier under the caret — the unit both the trigger and the completion act on. */ +const SCHEMA_PARAM_WORD = /[a-zA-Z_]\w*$/ + +function checkSchemaParamTrigger( + text: string, + cursorPos: number, + parameters: SchemaParameter[] +): TriggerState { + if (parameters.length === 0) return { show: false, searchTerm: '' } + + const currentWord = text.slice(0, cursorPos).match(SCHEMA_PARAM_WORD)?.[0] ?? '' + if (!currentWord) return { show: false, searchTerm: '' } + + const lower = currentWord.toLowerCase() + const hasMatch = parameters.some((param) => param.name.toLowerCase().startsWith(lower)) + return { show: hasMatch, searchTerm: currentWord } +} + +/** + * The code half of the custom tool editor: the available-parameters strip, the + * JavaScript editor, and its three caret-anchored autocompletes (environment + * variables, upstream block tags, and schema parameters). The surrounding + * surface owns the section label, the "Generate" action, and the error message. + */ +export function CustomToolCodeField({ + value, + onChange, + error, + generation, + schemaParameters, + workspaceId, + blockId, + disabled = false, +}: CustomToolCodeFieldProps) { + const codeEditorRef = useRef(null) + const schemaParamItemRefs = useRef | null>(null) + schemaParamItemRefs.current ??= new Map() + + const [showEnvVars, setShowEnvVars] = useState(false) + const [showTags, setShowTags] = useState(false) + const [showSchemaParams, setShowSchemaParams] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [cursorPosition, setCursorPosition] = useState(0) + const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }) + const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0) + + const busy = disabled || generation.isLoading || generation.isStreaming + const resolvedMinHeight = schemaParameters.length > 0 ? '380px' : '420px' + + /** Generation writes bypass `handleChange`, so close any open menu here instead. */ + useEffect(() => { + if (!busy) return + setShowEnvVars(false) + setShowTags(false) + setShowSchemaParams(false) + }, [busy]) + + useEffect(() => { + if (!showSchemaParams || schemaParamSelectedIndex < 0) return + const element = schemaParamItemRefs.current?.get(schemaParamSelectedIndex) + element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, [schemaParamSelectedIndex, showSchemaParams]) + + const handleChange = (newValue: string) => { + onChange(newValue) + if (busy) return + + const container = codeEditorRef.current + const textarea = container?.querySelector('textarea') + if (!container || !textarea) return + + const pos = textarea.selectionStart + setCursorPosition(pos) + + const textBeforeCursor = newValue.substring(0, pos) + const lines = textBeforeCursor.split('\n') + const currentLine = lines.length + const currentCol = lines[lines.length - 1].length + + const editorRect = container.getBoundingClientRect() + setDropdownPosition({ + top: currentLine * CODE_LINE_HEIGHT_PX + 5, + left: Math.min(currentCol * 8, editorRect.width - 260), + }) + + const envVarTrigger = checkEnvVarTrigger(newValue, pos) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '') + + if (blockId) { + const tagTrigger = checkTagTrigger(newValue, pos) + setShowTags(tagTrigger.show) + if (!tagTrigger.show) setActiveSourceBlockId(null) + } + + if (schemaParameters.length > 0) { + const schemaParamTrigger = checkSchemaParamTrigger(newValue, pos, schemaParameters) + if (schemaParamTrigger.show && !showSchemaParams) { + setShowSchemaParams(true) + setSchemaParamSelectedIndex(0) + } else if (!schemaParamTrigger.show && showSchemaParams) { + setShowSchemaParams(false) + } + } + } + + const handleSchemaParamSelect = (paramName: string) => { + const textarea = codeEditorRef.current?.querySelector('textarea') + if (!textarea) return + + const pos = textarea.selectionStart + const beforeCursor = value.substring(0, pos) + const afterCursor = value.substring(pos) + + // Must match checkSchemaParamTrigger's boundary exactly — a looser split + // here would replace text the trigger never matched (e.g. eat `data.`). + const currentWord = beforeCursor.match(SCHEMA_PARAM_WORD)?.[0] ?? '' + const wordStart = pos - currentWord.length + + onChange(beforeCursor.substring(0, wordStart) + paramName + afterCursor) + setShowSchemaParams(false) + + requestAnimationFrame(() => { + textarea.focus() + const caret = wordStart + paramName.length + textarea.setSelectionRange(caret, caret) + }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + if (showEnvVars || showTags || showSchemaParams) { + setShowEnvVars(false) + setShowTags(false) + setShowSchemaParams(false) + e.preventDefault() + e.stopPropagation() + return + } + } + + if (generation.isStreaming) { + e.preventDefault() + return + } + + if (showSchemaParams && schemaParameters.length > 0) { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + e.stopPropagation() + setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1)) + return + case 'ArrowUp': + e.preventDefault() + e.stopPropagation() + setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0)) + return + case 'Enter': { + e.preventDefault() + e.stopPropagation() + const selectedParam = schemaParameters[schemaParamSelectedIndex] + if (selectedParam) handleSchemaParamSelect(selectedParam.name) + return + } + case 'Escape': + e.preventDefault() + e.stopPropagation() + setShowSchemaParams(false) + return + case ' ': + case 'Tab': + setShowSchemaParams(false) + return + } + } + + if (showEnvVars || showTags) { + if (['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) { + e.preventDefault() + e.stopPropagation() + } + } + } + + return ( +
+ {schemaParameters.length > 0 && ( +
+ Available parameters: + {schemaParameters.map((param) => ( + + {param.name} + + ))} + + Start typing a parameter name for autocomplete. + +
+ )} + +
+ + + {showEnvVars && ( + { + onChange(newValue) + setShowEnvVars(false) + }} + searchTerm={searchTerm} + inputValue={value} + cursorPosition={cursorPosition} + workspaceId={workspaceId} + onClose={() => { + setShowEnvVars(false) + setSearchTerm('') + }} + className='w-64' + style={{ + position: 'absolute', + top: `${dropdownPosition.top}px`, + left: `${dropdownPosition.left}px`, + }} + /> + )} + + {showTags && blockId && ( + { + onChange(newValue) + setShowTags(false) + setActiveSourceBlockId(null) + }} + blockId={blockId} + activeSourceBlockId={activeSourceBlockId} + inputValue={value} + cursorPosition={cursorPosition} + onClose={() => { + setShowTags(false) + setActiveSourceBlockId(null) + }} + className='w-64' + style={{ + position: 'absolute', + top: `${dropdownPosition.top}px`, + left: `${dropdownPosition.left}px`, + }} + /> + )} + + {showSchemaParams && schemaParameters.length > 0 && ( + { + if (!open) setShowSchemaParams(false) + }} + colorScheme='inverted' + > + +
+ + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + Available Parameters + {schemaParameters.map((param, index) => ( + setSchemaParamSelectedIndex(index)} + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + handleSchemaParamSelect(param.name) + }} + ref={(el) => { + if (el) schemaParamItemRefs.current?.set(index, el) + }} + > + {param.name} + {param.type && param.type !== 'any' && ( + + {param.type} + + )} + + ))} + + + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field.tsx new file mode 100644 index 00000000000..2fb38059dd4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field.tsx @@ -0,0 +1,41 @@ +'use client' + +import { SCHEMA_PLACEHOLDER } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' +import type { useSchemaGeneration } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation' +import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor' + +interface CustomToolSchemaFieldProps { + value: string + onChange: (value: string) => void + error: boolean + generation: ReturnType + /** Renders the editor inert for viewers without edit rights. */ + disabled?: boolean +} + +/** + * The JSON-schema half of the custom tool editor. The surrounding surface owns + * the section label, the "Generate" action, and the error message — this field + * is just the editor, so both consumers can frame it however they need. + */ +export function CustomToolSchemaField({ + value, + onChange, + error, + generation, + disabled = false, +}: CustomToolSchemaFieldProps) { + const busy = disabled || generation.isLoading || generation.isStreaming + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema.ts b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema.ts new file mode 100644 index 00000000000..542709bf2d6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema.ts @@ -0,0 +1,126 @@ +/** + * Shared parsing/validation for a custom tool's OpenAI function-calling JSON + * schema. Used by both custom-tool editing surfaces (the canvas modal and the + * Settings > Custom Tools detail page) so they agree on what a valid schema is. + */ + +export interface SchemaParameter { + name: string + type: string + description: string + required: boolean +} + +interface SchemaValidation { + isValid: boolean + error: string | null +} + +export const SCHEMA_PLACEHOLDER = `{ + "type": "function", + "function": { + "name": "addItemToOrder", + "description": "Add one quantity of a food item to the order.", + "parameters": { + "type": "object", + "properties": { + "itemName": { + "type": "string", + "description": "The name of the food item to add to order" + } + }, + "required": ["itemName"] + } + } +}` + +export const CODE_PLACEHOLDER = 'return schemaVariable + {{environmentVariable}}' + +/** Shown when the server rejects a rename — `function.name` is immutable after creation. */ +export const FUNCTION_NAME_LOCKED = + 'Function name cannot be changed after creation. To use a different name, delete this tool and create a new one.' + +/** Delete-confirmation copy, shared by both editing surfaces. */ +export const CUSTOM_TOOL_DELETE_CONFIRM_TEXT = [ + { + text: 'This will permanently delete the tool and remove it from any workflows that are using it.', + error: true, + }, + ' This action cannot be undone.', +] as const + +/** Validates the shape the executor and providers expect. */ +export function validateCustomToolSchema(schema: string): SchemaValidation { + if (!schema) return { isValid: false, error: null } + + try { + const parsed = JSON.parse(schema) + + if (!parsed.type || parsed.type !== 'function') { + return { isValid: false, error: 'Missing "type": "function"' } + } + if (!parsed.function || !parsed.function.name) { + return { isValid: false, error: 'Missing function.name field' } + } + if (!parsed.function.parameters) { + return { isValid: false, error: 'Missing function.parameters object' } + } + if (!parsed.function.parameters.type) { + return { isValid: false, error: 'Missing parameters.type field' } + } + if (parsed.function.parameters.properties === undefined) { + return { isValid: false, error: 'Missing parameters.properties field' } + } + if ( + typeof parsed.function.parameters.properties !== 'object' || + parsed.function.parameters.properties === null + ) { + return { isValid: false, error: 'parameters.properties must be an object' } + } + + return { isValid: true, error: null } + } catch { + return { isValid: false, error: 'Invalid JSON format' } + } +} + +/** + * The tool's identity as declared inside its schema. Name and description have + * no separate storage — `schema.function.name` IS the tool's title (the save + * path derives it), so surfaces read them back out rather than offering a second + * place to edit them. + */ +export function extractSchemaIdentity(jsonSchema: string): { + name: string | null + description: string | null +} { + try { + const fn = JSON.parse(jsonSchema)?.function + return { + name: typeof fn?.name === 'string' && fn.name ? fn.name : null, + description: typeof fn?.description === 'string' && fn.description ? fn.description : null, + } + } catch { + return { name: null, description: null } + } +} + +/** Flattens a schema's properties into the parameter list the code editor autocompletes against. */ +export function extractSchemaParameters(jsonSchema: string): SchemaParameter[] { + try { + if (!jsonSchema) return [] + const parsed = JSON.parse(jsonSchema) + const properties = parsed?.function?.parameters?.properties + if (!properties) return [] + + const required = new Set(parsed?.function?.parameters?.required ?? []) + return Object.keys(properties).map((key) => ({ + name: key, + type: properties[key].type || 'any', + description: properties[key].description || '', + required: required.has(key), + })) + } catch { + return [] + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text.tsx new file mode 100644 index 00000000000..6f8bdddffad --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' + +/** + * Inline error text for a custom-tool editor section header. Lives in the + * header rather than under the editor because a tall editor plus a message + * below it shifts everything after it as the message appears while typing. + */ +export function FieldErrorText({ children }: { children: ReactNode }) { + return {children} +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx new file mode 100644 index 00000000000..a0d07c1e511 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useRef, useState } from 'react' +import { Chip, ChipInput } from '@sim/emcn' +import { ArrowUp } from 'lucide-react' + +interface GeneratePromptControlProps { + isLoading: boolean + isStreaming: boolean + onSubmit: (prompt: string) => void +} + +/** + * The "Generate" affordance above a custom-tool editor: a chip that swaps into + * an inline prompt field, then hands the trimmed prompt to the caller's wand + * stream. Owns only its own transient input state so both the schema and code + * fields can reuse it. + */ +export function GeneratePromptControl({ + isLoading, + isStreaming, + onSubmit, +}: GeneratePromptControlProps) { + const [isActive, setIsActive] = useState(false) + const [prompt, setPrompt] = useState('') + const inputRef = useRef(null) + + const activate = () => { + if (isLoading || isStreaming) return + setIsActive(true) + setPrompt('') + requestAnimationFrame(() => inputRef.current?.focus()) + } + + const submit = () => { + const trimmed = prompt.trim() + if (!trimmed || isLoading || isStreaming) return + onSubmit(trimmed) + setPrompt('') + setIsActive(false) + } + + if (!isActive) { + return ( + + Generate + + ) + } + + return ( +
+ setPrompt(e.target.value)} + onBlur={() => { + if (!prompt.trim() && !isStreaming) setIsActive(false) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submit() + } else if (e.key === 'Escape') { + e.preventDefault() + setPrompt('') + setIsActive(false) + } + }} + disabled={isStreaming} + className='w-[220px]' + placeholder='Describe what to generate...' + /> + { + e.preventDefault() + e.stopPropagation() + }} + onClick={(e) => { + e.stopPropagation() + submit() + }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/index.ts b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/index.ts new file mode 100644 index 00000000000..8ab25cc5e03 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/index.ts @@ -0,0 +1,15 @@ +export { CustomToolCodeField } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-code-field' +export { + CUSTOM_TOOL_DELETE_CONFIRM_TEXT, + extractSchemaIdentity, + extractSchemaParameters, + FUNCTION_NAME_LOCKED, + validateCustomToolSchema, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' +export { CustomToolSchemaField } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema-field' +export { FieldErrorText } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/field-error-text' +export { GeneratePromptControl } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/generate-prompt-control' +export { + useCodeGeneration, + useSchemaGeneration, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation' diff --git a/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation.ts b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation.ts new file mode 100644 index 00000000000..6bb9427943b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/custom-tool-editor/use-custom-tool-generation.ts @@ -0,0 +1,193 @@ +'use client' + +import { useMemo } from 'react' +import type { SchemaParameter } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' +import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' + +const SCHEMA_PROMPT = `You are an expert programmer specializing in creating OpenAI function calling format JSON schemas for custom tools. +Generate ONLY the JSON schema based on the user's request. +The output MUST be a single, valid JSON object, starting with { and ending with }. +The JSON schema MUST follow this specific format: +1. Top-level property "type" must be set to "function" +2. A "function" object containing: + - "name": A concise, camelCase name for the function + - "description": A clear description of what the function does + - "parameters": A JSON Schema object describing the function's parameters with: + - "type": "object" + - "properties": An object containing parameter definitions + - "required": An array of required parameter names + +Current schema: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON object. + +Valid Schema Examples: + +Example 1: +{ + "type": "function", + "function": { + "name": "getWeather", + "description": "Fetches the current weather for a specific location.", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g., San Francisco, CA" + }, + "unit": { + "type": "string", + "description": "Temperature unit", + "enum": ["celsius", "fahrenheit"] + } + }, + "required": ["location"], + "additionalProperties": false + } + } +} + +Example 2: +{ + "type": "function", + "function": { + "name": "addItemToOrder", + "description": "Add one quantity of a food item to the order.", + "parameters": { + "type": "object", + "properties": { + "itemName": { + "type": "string", + "description": "The name of the food item to add to order" + }, + "quantity": { + "type": "integer", + "description": "The quantity of the item to add", + "default": 1 + } + }, + "required": ["itemName"], + "additionalProperties": false + } + } +}` + +function buildCodePrompt(schemaContext: string): string { + return `You are an expert JavaScript programmer. +Generate ONLY the raw body of a JavaScript function based on the user's request. +The code should be executable within an 'async function(params, environmentVariables) {...}' context. +- 'params' (object): Contains input parameters derived from the JSON schema. Reference these directly by name (e.g., 'userId', 'cityName'). Do NOT use 'params.paramName'. +- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use 'environmentVariables.VAR_NAME' or env. + +${schemaContext} + +Current code: {context} + +IMPORTANT FORMATTING RULES: +1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes (e.g., use 'const apiKey = {{SERVICE_API_KEY}};' not 'const apiKey = "{{SERVICE_API_KEY}}";'). Our system replaces these placeholders before execution. +2. Reference Input Parameters/Workflow Variables: Reference them directly by name (e.g., 'const city = cityName;' or use directly in template strings like \`\${cityName}\`). Do NOT wrap in quotes or angle brackets. +3. Function Body ONLY: Do NOT include the function signature (e.g., 'async function myFunction() {' or the surrounding '}'). +4. Imports: Do NOT include import/require statements unless they are standard Node.js built-in modules (e.g., 'crypto', 'fs'). External libraries are not supported in this context. +5. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'. +6. Clarity: Write clean, readable code. +7. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw JavaScript code for the function body. + +Example Scenario: +User Prompt: "Fetch weather data from OpenWeather API. Use the city name passed in as 'cityName' and an API Key stored as the 'OPENWEATHER_API_KEY' environment variable." + +Generated Code: +const apiKey = {{OPENWEATHER_API_KEY}}; +const url = \`https://api.openweathermap.org/data/2.5/weather?q=\${cityName}&appid=\${apiKey}\`; + +try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(\`API request failed with status \${response.status}: \${await response.text()}\`); + } + + const weatherData = await response.json(); + return weatherData; +} catch (error) { + console.error(\`Error fetching weather data: \${error.message}\`); + throw error; +}` +} + +interface UseSchemaGenerationParams { + jsonSchema: string + setJsonSchema: (updater: (prev: string) => string) => void + replaceJsonSchema: (value: string) => void +} + +/** Wand-driven generation for a custom tool's JSON schema. */ +export function useSchemaGeneration({ + jsonSchema, + setJsonSchema, + replaceJsonSchema, +}: UseSchemaGenerationParams) { + return useWand({ + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: SCHEMA_PROMPT, + placeholder: 'Describe the function parameters and structure...', + generationType: 'custom-tool-schema', + }, + currentValue: jsonSchema, + onStreamStart: () => replaceJsonSchema(''), + onGeneratedContent: (content) => replaceJsonSchema(content), + onStreamChunk: (chunk) => setJsonSchema((prev) => prev + chunk), + }) +} + +interface UseCodeGenerationParams { + functionCode: string + schemaParameters: SchemaParameter[] + setFunctionCode: (updater: (prev: string) => string) => void + replaceFunctionCode: (value: string) => void +} + +/** Wand-driven generation for a custom tool's function body, aware of the schema's parameters. */ +export function useCodeGeneration({ + functionCode, + schemaParameters, + setFunctionCode, + replaceFunctionCode, +}: UseCodeGenerationParams) { + const prompt = useMemo(() => { + if (schemaParameters.length === 0) { + return buildCodePrompt( + 'Schema parameters: (none defined yet — the user has not added any parameters to the schema)' + ) + } + const lines = schemaParameters.map((p) => { + const requiredLabel = p.required ? 'required' : 'optional' + const description = p.description ? `: ${p.description}` : '' + return `- ${p.name} (${p.type}, ${requiredLabel})${description}` + }) + return buildCodePrompt( + `Schema parameters (reference these directly by name in the generated code):\n${lines.join('\n')}` + ) + }, [schemaParameters]) + + return useWand({ + wandConfig: { + enabled: true, + maintainHistory: true, + prompt, + placeholder: 'Describe the JavaScript function to generate...', + generationType: 'javascript-function-body', + }, + currentValue: functionCode, + onStreamStart: () => replaceFunctionCode(''), + onGeneratedContent: (content) => replaceFunctionCode(content), + onStreamChunk: (chunk) => setFunctionCode((prev) => prev + chunk), + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 4d5ef5a38d8..02855214e23 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -36,4 +36,5 @@ export type { SelectableConfig, } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' +export { ResourceTile } from './resource-tile' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts new file mode 100644 index 00000000000..507fe07a2f8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts @@ -0,0 +1 @@ +export { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx new file mode 100644 index 00000000000..90907001430 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx @@ -0,0 +1,20 @@ +import type { ComponentType } from 'react' + +interface ResourceTileProps { + icon: ComponentType<{ className?: string }> +} + +/** + * Square glyph tile identifying a workspace resource — the leading visual on a + * resource's row and on its detail heading. Single source for that chrome so + * the skills and custom tools surfaces cannot drift apart. + */ +export function ResourceTile({ icon: Icon }: ResourceTileProps) { + return ( +
+
+ +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/skill-tile/skill-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/skill-tile/skill-tile.tsx index 7839dac4554..7ac1095a2ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/skill-tile/skill-tile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/skill-tile/skill-tile.tsx @@ -1,4 +1,5 @@ import { AgentSkillsIcon } from '@/components/icons' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile' /** * Square tile bearing the agent-skills glyph. Shared chrome for any surface @@ -6,11 +7,5 @@ import { AgentSkillsIcon } from '@/components/icons' * do not drift. */ export function SkillTile() { - return ( -
-
- -
-
- ) + return } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx index b851c2a86fa..4f261851c4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx @@ -36,8 +36,8 @@ export function MentionChipView({ node, editor }: ReactNodeViewProps) { const router = useRouter() const params = useParams() const { kind, id, label } = node.attrs as MentionAttrs - const Icon = mentionIcon(kind, id) as StyleableIcon - const iconStyle = getBareIconStyle(Icon) + const Icon = mentionIcon(kind, id, label) as StyleableIcon | undefined + const iconStyle = Icon ? getBareIconStyle(Icon) : undefined const navigable = editor.storage.mention?.navigable === true const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null @@ -55,7 +55,7 @@ export function MentionChipView({ node, editor }: ReactNodeViewProps) { onClick={path ? handleClick : undefined} title={label} > - + {Icon && } {label} ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts index de6f15e6136..1b8960913ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.test.ts @@ -1,17 +1,29 @@ /** @vitest-environment node */ -import { Box, File } from 'lucide-react' +import { Workflow } from '@sim/emcn/icons' import { describe, expect, it } from 'vitest' +import { AgentSkillsIcon } from '@/components/icons' +import { getDocumentIcon } from '@/components/icons/document-icons' import { mentionIcon } from './mention-icon' import type { MentionKind } from './types' describe('mentionIcon', () => { - it('returns the category icon for a known kind', () => { - expect(mentionIcon('file', 'x')).toBe(File) + it('uses the product-wide glyph for a known kind', () => { + expect(mentionIcon('workflow', 'x')).toBe(Workflow) }) - it('falls back to a generic icon for an empty or unrecognized kind (never undefined)', () => { + it('uses the shared skills glyph, not a one-off icon', () => { + // The same glyph SkillTile and the chat context registry render. + expect(mentionIcon('skill', 'x')).toBe(AgentSkillsIcon) + }) + + it('derives the file icon from the filename extension', () => { + expect(mentionIcon('file', 'x', 'report.pdf')).toBe(getDocumentIcon('', 'report.pdf')) + expect(mentionIcon('file', 'x', 'data.csv')).toBe(getDocumentIcon('', 'data.csv')) + }) + + it('returns undefined for an unrecognized kind so callers render no icon', () => { // The schema default is '' and a sim: link could carry a future kind — neither may crash render. - expect(mentionIcon('' as unknown as MentionKind, 'x')).toBe(Box) - expect(mentionIcon('dataset' as unknown as MentionKind, 'x')).toBe(Box) + expect(mentionIcon('' as unknown as MentionKind, 'x')).toBeUndefined() + expect(mentionIcon('dataset' as unknown as MentionKind, 'x')).toBeUndefined() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts index c655ed0f74d..828328efe6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-icon.ts @@ -1,29 +1,43 @@ import type { ComponentType } from 'react' -import { Box, Database, File, Folder, Sparkles, Table, Workflow } from 'lucide-react' +import { Database, Folder, Table, Workflow } from '@sim/emcn/icons' +import { AgentSkillsIcon } from '@/components/icons' +import { getDocumentIcon } from '@/components/icons/document-icons' import { getBlock } from '@/blocks/registry' import type { MentionKind } from './types' -/** Icon component shape both the lucide kind-icons and the brand block icons satisfy. */ +/** Icon component shape both the kind icons and the brand block icons satisfy. */ export type MentionIcon = ComponentType<{ className?: string }> -const KIND_ICONS: Record, MentionIcon> = { - file: File, +/** + * The glyph each mention kind uses elsewhere in the product, so a mention reads + * as the resource it links to. Mirrors `CHAT_CONTEXT_KIND_REGISTRY`, the same + * mapping Chat's `@` menu renders. + */ +const KIND_ICONS: Record, MentionIcon> = { folder: Folder, table: Table, knowledge: Database, workflow: Workflow, - skill: Sparkles, + skill: AgentSkillsIcon, } /** - * Resolves the icon for a mention. Integrations use their brand icon from the block registry (keyed by - * blockType, which is the mention `id`), falling back to a generic icon if the block was since removed; - * every other kind uses a lucide category icon, falling back to the same generic icon for an empty or - * unrecognized kind (the schema default is `''`, and a `sim:` link could carry a kind a future version - * adds) — so the result is always a real component and the chip is never icon-less. Shared by the menu - * rows and the inserted chip so both render the same icon. + * Resolves the icon for a mention: + * + * - `integration` uses the block's brand icon from the registry, keyed by the + * mention `id` (the blockType). + * - `file` uses the extension-derived document icon, so a `.pdf` and a `.csv` + * look different — matching the file list and Chat's context chips. + * - every other kind uses its product-wide glyph. + * + * Returns `undefined` when nothing sensible applies — an unrecognized kind (the + * node schema defaults `kind` to `''`, and a hand-written `sim:` link can carry + * anything) or a block that has since been removed. Callers render no icon in + * that case rather than a meaningless placeholder, which is what the chat + * context registry does too. */ -export function mentionIcon(kind: MentionKind, id: string): MentionIcon { - if (kind === 'integration') return (getBlock(id)?.icon as MentionIcon | undefined) ?? Box - return KIND_ICONS[kind] ?? Box +export function mentionIcon(kind: MentionKind, id: string, label = ''): MentionIcon | undefined { + if (kind === 'integration') return getBlock(id)?.icon as MentionIcon | undefined + if (kind === 'file') return getDocumentIcon('', label) + return KIND_ICONS[kind as Exclude] } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts index 023faacc43c..1451fffe7c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts @@ -55,7 +55,7 @@ export function useMarkdownMentions( id: file.id, label: file.name, group: 'Files', - icon: mentionIcon('file', file.id), + icon: mentionIcon('file', file.id, file.name), }) for (const folder of folders.data ?? []) items.push({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 5c698d25835..cfb5e9a2ec3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -438,12 +438,16 @@ } /* - * Field variant (modal embed): match the surrounding chip fields' typography exactly — + * Field variant (form embed): match the surrounding chip fields' typography exactly — * body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the * lighter document `--text-subtle`), so the editor reads as one of the form's fields. + * The weight drops to a normal 400 as well: the document scale's 430 reads visibly + * thicker than a `ChipInput`/`ChipTextarea` beside it. Headings, `strong`, and `th` + * set their own 600, so only body text and the placeholder are affected. */ .rich-markdown-field-prose { font-size: 14px; + font-weight: 400; line-height: 22px; } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 8936c9a489b..e0723b4d5ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ChipTextarea, chipFieldSurfaceClass, cn } from '@sim/emcn' import type { JSONContent } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' @@ -30,9 +30,14 @@ interface RichMarkdownFieldProps { /** True while `value` is being pushed in externally (AI generation) — the editor turns read-only and mirrors each update. */ isStreaming?: boolean autoFocus?: boolean - /** Min height of the scroll box in px. */ + /** Min height of the editor box in px. */ minHeight?: number - /** Max height of the scroll box in px before it scrolls. */ + /** + * Max height in px before the box scrolls internally. Set this inside a modal, + * where the surface itself cannot grow. Omit on a full-page surface so the + * editor grows with its content and the page owns the only scrollbar — a + * capped box stranded above empty page is worse than a long document. + */ maxHeight?: number /** Swaps the border to the error token (the message itself is rendered by the surrounding field). */ error?: boolean @@ -61,7 +66,7 @@ function LoadedRichMarkdownField({ isStreaming = false, autoFocus = false, minHeight = 140, - maxHeight = 360, + maxHeight, error = false, workspaceId, disableTagging, @@ -176,10 +181,17 @@ function LoadedRichMarkdownField({
@@ -206,12 +218,44 @@ function RawMarkdownField({ disabled = false, isStreaming = false, minHeight = 140, - maxHeight = 360, + maxHeight, error = false, onPasteText, }: RichMarkdownFieldProps) { + // Disabled-look without the `disabled` attribute — a disabled textarea is + // inert to wheel/scrollbar, but locked content must stay scrollable. + const lockedView = disabled && !isStreaming + + /** + * Uncapped, the textarea grows with its content so it matches the WYSIWYG + * path on a full-page surface — a textarea has no intrinsic auto-height, so + * the height is synced to `scrollHeight` on every value change. Capped (in a + * modal) it keeps its own scrollbar and this is skipped. + */ + const textareaRef = useRef(null) + const autoGrow = maxHeight === undefined + useLayoutEffect(() => { + if (!autoGrow) return + const el = textareaRef.current + if (!el) return + + const measure = () => { + el.style.height = 'auto' + el.style.height = `${Math.max(el.scrollHeight, minHeight)}px` + } + measure() + + // The box is `overflow-hidden` while uncapped, so a width change that + // re-wraps lines without touching `value` would clip the tail with no + // scrollbar to reach it — re-measure whenever the element resizes. + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, [autoGrow, value, minHeight]) + return ( onChange(event.target.value)} onPaste={(event) => { @@ -220,15 +264,20 @@ function RawMarkdownField({ }} placeholder={placeholder} error={error} - readOnly={disabled || isStreaming} + viewOnly={lockedView} + readOnly={isStreaming} + tabIndex={lockedView ? -1 : undefined} + className={cn(lockedView && 'select-none opacity-50', autoGrow && 'overflow-hidden')} style={{ minHeight, maxHeight }} /> ) } /** - * A controlled, string-valued markdown editor for modal fields. Drop it inside a `ChipModalField - * type='custom'`. Mirrors the file editor's safety gate (decided once from the initial value): + * A controlled, string-valued markdown editor. Inside a modal, drop it in a `ChipModalField + * type='custom'` and pass a `maxHeight` so it scrolls within the modal; on a full-page surface omit + * `maxHeight` so it grows with its content and the page owns the only scrollbar. + * Mirrors the file editor's safety gate (decided once from the initial value): * round-trip-safe content opens in the WYSIWYG editor, while lossy markdown (raw HTML, footnotes, * comments) falls back to raw-text editing so an edit can't silently drop those constructs. */ diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx index 734d4a14c85..ea4fa847a2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx @@ -6,6 +6,7 @@ import { Check, Plus } from 'lucide-react' import { usePostHog } from 'posthog-js/react' import { captureEvent } from '@/lib/posthog/client' import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { isSkillNameConflictError } from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SuggestedSkill } from '@/blocks/types' import { useCreateSkill, useSkills } from '@/hooks/queries/skills' @@ -77,8 +78,15 @@ export function IntegrationSkillsSection({ position, skill_count: skills.length, }) - } catch { - toast.error(`Failed to add "${skill.name}" — please try again`) + } catch (error) { + // A name conflict just means the skill is already in this workspace — + // everyone with workspace access can already see and use it, so there is + // nothing to request and retrying can never succeed. + if (isSkillNameConflictError(error)) { + toast.error(`"${skill.name}" is already in this workspace`) + } else { + toast.error(`Failed to add "${skill.name}" — please try again`) + } } finally { inFlightRef.current.delete(skill.name) setPendingNames((prev) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index 85b4a56b913..94aae591694 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -96,6 +96,22 @@ export const customBlockIdUrlKeys = { clearOnDefault: true, } as const +/** + * `custom-tool-id` deep-links the Custom Tools settings tab to a specific + * tool's detail sub-view. The "create new" flow stays in local state — only + * existing entities are deep-linkable. + */ +export const customToolIdParam = { + key: 'custom-tool-id', + parser: parseAsString, +} as const + +/** Opening a tool's detail is a destination → push to history; clear on close. */ +export const customToolIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + /** * `fork-direction` is the sync direction (push/pull) on the parent fork's detail * page — shareable view state, so a copied link opens the same side of the sync. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx new file mode 100644 index 00000000000..db3b2f47db2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail.tsx @@ -0,0 +1,336 @@ +'use client' + +import { useMemo, useState } from 'react' +import { ChipConfirmModal, toast } from '@sim/emcn' +import { ArrowLeft, Wrench } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { ResourceTile } from '@/app/workspace/[workspaceId]/components' +import { + CredentialDetailHeading, + UnsavedChangesModal, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + CUSTOM_TOOL_DELETE_CONFIRM_TEXT, + CustomToolCodeField, + CustomToolSchemaField, + extractSchemaIdentity, + extractSchemaParameters, + FieldErrorText, + FUNCTION_NAME_LOCKED, + GeneratePromptControl, + useCodeGeneration, + useSchemaGeneration, + validateCustomToolSchema, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor' +import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +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 type { CustomToolDefinition } from '@/hooks/queries/custom-tools' +import { + useCreateCustomTool, + useDeleteCustomTool, + useUpdateCustomTool, +} from '@/hooks/queries/custom-tools' + +const logger = createLogger('CustomToolDetail') + +interface CustomToolDetailProps { + workspaceId: string + /** `null` on the create flow, which starts from an empty draft. */ + tool: CustomToolDefinition | null + /** Viewers without edit rights get the same page with every control inert. */ + readOnly?: boolean + onBack: () => void + /** Lands the caller on the tool it just created, matching the skill create flow. */ + onCreated?: (toolId: string) => void +} + +/** + * Full-page custom tool editor rendered as a settings detail sub-view: a back + * chip, dirty-gated Discard/Save, Delete, and the Schema and Code editors + * stacked (no tabs — the page has room for both). Uses the same fields as the + * canvas modal so the two surfaces never drift. + */ +export function CustomToolDetail({ + workspaceId, + tool, + readOnly = false, + onBack, + onCreated, +}: CustomToolDetailProps) { + const isEditing = !!tool + + const createTool = useCreateCustomTool() + const updateTool = useUpdateCustomTool() + const deleteTool = useDeleteCustomTool() + + /** + * The dirty baseline. Seeded once at mount — the list keys this view by tool + * id, so picking a different tool remounts it — and moved only by an explicit + * save. A background list refetch must never shift it out from under + * in-progress edits. + */ + const [seededSchema, setSeededSchema] = useState(() => + tool ? JSON.stringify(tool.schema, null, 2) : '' + ) + const [seededCode, setSeededCode] = useState(() => tool?.code ?? '') + + const [jsonSchema, setJsonSchema] = useState(seededSchema) + const [functionCode, setFunctionCode] = useState(seededCode) + const [schemaError, setSchemaError] = useState(null) + const [codeError, setCodeError] = useState(null) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const schemaParameters = useMemo(() => extractSchemaParameters(jsonSchema), [jsonSchema]) + + /** + * Heading reflects the draft schema, so the tool names itself as you type + * rather than hiding its identity inside the JSON. Falls back to the saved + * tool while the draft is mid-edit and unparseable. + */ + const identity = useMemo(() => extractSchemaIdentity(jsonSchema), [jsonSchema]) + + const schemaGeneration = useSchemaGeneration({ + jsonSchema, + setJsonSchema: (updater) => { + setJsonSchema(updater) + setSchemaError(null) + }, + replaceJsonSchema: (value) => { + setJsonSchema(value) + setSchemaError(null) + }, + }) + + const codeGeneration = useCodeGeneration({ + functionCode, + schemaParameters, + setFunctionCode: (updater) => { + setFunctionCode(updater) + setCodeError(null) + }, + replaceFunctionCode: (value) => { + setFunctionCode(value) + setCodeError(null) + }, + }) + + const dirty = isEditing + ? jsonSchema !== seededSchema || functionCode !== seededCode + : jsonSchema.trim().length > 0 || functionCode.trim().length > 0 + + const guard = useSettingsUnsavedGuard({ isDirty: dirty }) + + const saving = createTool.isPending || updateTool.isPending + const isSchemaValid = useMemo(() => validateCustomToolSchema(jsonSchema).isValid, [jsonSchema]) + const streaming = schemaGeneration.isStreaming || codeGeneration.isStreaming + + const handleDiscard = () => { + setJsonSchema(seededSchema) + setFunctionCode(seededCode) + setSchemaError(null) + setCodeError(null) + } + + const handleSave = async () => { + if (saving) return + + if (!jsonSchema.trim()) { + setSchemaError('Schema cannot be empty') + return + } + + const { isValid, error } = validateCustomToolSchema(jsonSchema) + if (!isValid) { + setSchemaError(error) + return + } + + setSchemaError(null) + setCodeError(null) + + const schema = JSON.parse(jsonSchema) + const title = schema.function.name + + try { + if (tool) { + await updateTool.mutateAsync({ + workspaceId, + toolId: tool.id, + updates: { title, schema, code: functionCode }, + }) + // Saving an edit keeps you on the tool (matching the other settings + // detail views); re-baseline so Discard/Save drop back out of the header. + setSeededSchema(jsonSchema) + setSeededCode(functionCode) + } else { + const created = await createTool.mutateAsync({ + workspaceId, + tool: { title, schema, code: functionCode }, + }) + // The upsert responds with the workspace's whole tool list (newest + // first), not just the new row — match by title rather than index. + const createdId = created.find((t) => t.title === title)?.id + if (createdId) onCreated?.(createdId) + else onBack() + } + } catch (error) { + logger.error('Failed to save custom tool', error) + const message = getErrorMessage(error, 'Failed to save custom tool') + setSchemaError( + message.includes('Cannot change function name') ? FUNCTION_NAME_LOCKED : message + ) + } + } + + const handleConfirmDelete = async () => { + if (!tool) return + setShowDeleteConfirm(false) + try { + await deleteTool.mutateAsync({ workspaceId, toolId: tool.id }) + onBack() + } catch (error) { + logger.error('Failed to delete custom tool', error) + toast.error("Couldn't delete tool", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + } + + /** + * On create, the primary action is always visible so the page announces what + * it is for — disabled until the schema is a valid function definition. + * (`saveDiscardActions` is dirty-gated and would render nothing on an empty + * draft.) Discard still only appears once there is something to discard. + */ + const createToolActions: SettingsAction[] = [ + ...(dirty ? [{ text: 'Discard', onSelect: handleDiscard, disabled: saving }] : []), + { + text: saving ? 'Creating...' : 'Create', + variant: 'primary' as const, + onSelect: handleSave, + disabled: saving || streaming || !isSchemaValid, + }, + ] + + return ( + <> + guard.guardBack(onBack) }} + title={identity.name || tool?.title || 'New tool'} + actions={[ + ...(readOnly + ? [] + : isEditing + ? saveDiscardActions({ + dirty, + saving, + onSave: handleSave, + onDiscard: handleDiscard, + saveDisabled: !isSchemaValid || streaming, + }) + : createToolActions), + ...(tool && !readOnly + ? [ + { + text: deleteTool.isPending ? 'Deleting...' : 'Delete', + variant: 'destructive' as const, + onSelect: () => setShowDeleteConfirm(true), + disabled: deleteTool.isPending, + }, + ] + : []), + ]} + > +
+ } + title={identity.name || tool?.title || 'New tool'} + subtitle={ + identity.description || + tool?.schema.function.description || + 'Define the JSON schema your agents call, and the code that runs.' + } + /> + + {schemaError} : undefined + } + action={ + readOnly ? undefined : ( + schemaGeneration.generateStream({ prompt })} + /> + ) + } + > + { + setJsonSchema(value) + setSchemaError(value.trim() ? validateCustomToolSchema(value).error : null) + }} + error={!!schemaError} + generation={schemaGeneration} + disabled={readOnly} + /> + + + {codeError} : undefined} + action={ + readOnly ? undefined : ( + codeGeneration.generateStream({ prompt })} + /> + ) + } + > + { + setFunctionCode(value) + if (codeError) setCodeError(null) + }} + error={!!codeError} + generation={codeGeneration} + schemaParameters={schemaParameters} + workspaceId={workspaceId} + disabled={readOnly} + /> + +
+
+ + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/index.ts new file mode 100644 index 00000000000..ffc8ae4ff17 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/index.ts @@ -0,0 +1 @@ +export { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail/custom-tool-detail' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index 2a5e789da06..95ce91a808f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -1,22 +1,24 @@ 'use client' import { useState } from 'react' -import { ChipConfirmModal } from '@sim/emcn' -import { createLogger } from '@sim/logger' +import { Wrench } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' -import { Plus } from 'lucide-react' +import { ArrowRight, Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + customToolIdParam, + customToolIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { CustomToolDetail } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/components/custom-tool-detail' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' -import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' -import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools' - -const logger = createLogger('CustomToolsSettings') +import { useCustomTools } from '@/hooks/queries/custom-tools' export function CustomTools() { const params = useParams() @@ -24,15 +26,22 @@ export function CustomTools() { const workspacePermissions = useUserPermissionsContext() const canEdit = canMutateWorkspaceSettingsSection('custom-tools', workspacePermissions) - const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId) - const deleteToolMutation = useDeleteCustomTool() + const { data: tools = [], isLoading, error } = useCustomTools(workspaceId) const [searchTerm, setSearchTerm] = useSettingsSearch() - const [deletingTools, setDeletingTools] = useState>(() => new Set()) - const [editingTool, setEditingTool] = useState(null) - const [showAddForm, setShowAddForm] = useState(false) - const [toolToDelete, setToolToDelete] = useState<{ id: string; name: string } | null>(null) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [selectedToolId, setSelectedToolId] = useQueryState(customToolIdParam.key, { + ...customToolIdParam.parser, + ...customToolIdUrlKeys, + }) + /** The create flow has no entity id and is not deep-linkable — stays local. */ + const [isCreating, setIsCreating] = useState(false) + + const selectedTool = selectedToolId ? tools.find((t) => t.id === selectedToolId) : undefined + + const closeDetail = () => { + setIsCreating(false) + void setSelectedToolId(null, { history: 'replace' }) + } const filteredTools = tools.filter((tool) => { if (!searchTerm.trim()) return true @@ -44,52 +53,7 @@ export function CustomTools() { ) }) - const handleDeleteClick = (toolId: string) => { - const tool = tools.find((t) => t.id === toolId) - if (!tool) return - - setToolToDelete({ - id: toolId, - name: tool.title || tool.schema?.function?.name || 'this custom tool', - }) - setShowDeleteDialog(true) - } - - const handleDeleteTool = async () => { - if (!toolToDelete) return - - const tool = tools.find((t) => t.id === toolToDelete.id) - if (!tool) return - - setDeletingTools((prev) => new Set(prev).add(toolToDelete.id)) - setShowDeleteDialog(false) - - try { - await deleteToolMutation.mutateAsync({ - workspaceId: tool.workspaceId ?? null, - toolId: toolToDelete.id, - }) - logger.info(`Deleted custom tool: ${toolToDelete.id}`) - } catch (error) { - logger.error('Error deleting custom tool:', error) - } finally { - setDeletingTools((prev) => { - const next = new Set(prev) - next.delete(toolToDelete.id) - return next - }) - setToolToDelete(null) - } - } - - const handleToolSaved = () => { - setShowAddForm(false) - setEditingTool(null) - refetchTools() - } - - const hasTools = tools && tools.length > 0 - const showEmptyState = !hasTools && !showAddForm && !editingTool + const showEmptyState = tools.length === 0 const showNoResults = searchTerm.trim() && filteredTools.length === 0 && tools.length > 0 const actions: SettingsAction[] = canEdit @@ -98,115 +62,81 @@ export function CustomTools() { text: 'Add tool', icon: Plus, variant: 'primary', - onSelect: () => setShowAddForm(true), + onSelect: () => setIsCreating(true), disabled: isLoading, }, ] : [] - return ( - <> - - {error ? ( -
-

- {getErrorMessage(error, 'Failed to load tools')} -

-
- ) : isLoading ? null : showEmptyState ? ( - - {canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'} - - ) : ( -
- {filteredTools.map((tool) => ( -
-
- - {tool.title || 'Unnamed Tool'} - - {tool.schema?.function?.description && ( -

- {tool.schema.function.description} -

- )} -
- {canEdit && ( -
- setEditingTool(tool.id) }, - { - label: 'Delete', - destructive: true, - disabled: deletingTools.has(tool.id), - onSelect: () => handleDeleteClick(tool.id), - }, - ]} - /> -
- )} -
- ))} - {showNoResults && ( - - No tools found matching "{searchTerm}" - - )} -
- )} -
+ /** + * Hold the first paint while a deep-linked id could still resolve — the tools + * query and the workspace permissions context both gate the detail, so a valid + * link never flashes the list before jumping to it. A dead id still falls back + * to the list. + */ + if (selectedToolId !== null && (isLoading || workspacePermissions.isLoading)) return null - {canEdit && ( - { - if (!open) { - setShowAddForm(false) - setEditingTool(null) - } - }} - onSave={handleToolSaved} - onDelete={() => {}} - blockId='' - initialValues={ - editingTool - ? (() => { - const tool = tools.find((t) => t.id === editingTool) - return tool?.schema - ? { id: tool.id, schema: tool.schema, code: tool.code } - : undefined - })() - : undefined - } - /> - )} + if ((isCreating && canEdit) || selectedTool) { + return ( + { + setIsCreating(false) + void setSelectedToolId(toolId) + }} + /> + ) + } - {canEdit && ( - { - if (!open) setShowDeleteDialog(false) - }} - srTitle='Delete Custom Tool' - title='Delete Custom Tool' - text={[ - 'Are you sure you want to delete ', - { text: toolToDelete?.name ?? 'this tool', bold: true }, - '? This action cannot be undone.', - ]} - confirm={{ label: 'Delete', onClick: handleDeleteTool }} - /> + return ( + + {error ? ( +
+

+ {getErrorMessage(error, 'Failed to load tools')} +

+
+ ) : isLoading ? null : showEmptyState ? ( + + {canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'} + + ) : ( +
+ {filteredTools.map((tool) => ( + + ))} + {showNoResults && ( + + No tools found matching "{searchTerm}" + + )} +
)} - +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index 88d9365e65a..7e31036010d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -20,6 +20,11 @@ interface SettingsResourceRowProps { * normalize to 20px so a fallback icon doesn't balloon. */ iconFill?: boolean + /** + * Fills the tile like the skills/tools resource tiles instead of the default + * page-background tile, so a settings list can match its gallery counterpart. + */ + iconFilled?: boolean /** Primary line — truncates. */ title: ReactNode /** Secondary muted line — truncates. */ @@ -29,11 +34,12 @@ interface SettingsResourceRowProps { } const TILE_BASE = - 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] [&_svg]:size-5' + 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5' export function SettingsResourceRow({ icon, iconFill = false, + iconFilled = false, title, description, trailing, @@ -41,7 +47,13 @@ export function SettingsResourceRow({ return (
-
+
{icon}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx new file mode 100644 index 00000000000..4cdb2d4a9ec --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx @@ -0,0 +1,59 @@ +'use client' + +import { + MemberRow, + SKILL_EDITOR_ROLE_OPTIONS, + type SkillEditorRole, + skillEditorLockReason, +} from '@/components/permissions' +import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail' +import type { SkillEditorsController } from '@/app/workspace/[workspaceId]/skills/components/skill-members' + +interface SkillEditorsCardProps { + editors: SkillEditorsController + /** Whether the viewer can edit the skill (and therefore manage its editors). */ + canEdit: boolean +} + +/** + * Page-styled editor roster for the skill detail page: workspace admins + * (derived, always editors) and explicitly added editors. Everyone in the + * workspace can already see and use the skill — this list gates editing only. + * Adding people happens through the header Share action. + * + * Rows are the shared {@link MemberRow} so the roster is chrome-identical to the + * credential detail surface. Skill membership is binary, so the role control + * carries a single `Editor` option and is disabled on every row; a derived + * (workspace-admin) row also locks Remove and explains the inheritance on hover. + */ +export function SkillEditorsCard({ editors, canEdit }: SkillEditorsCardProps) { + return ( + + {editors.editorsError ? ( + + Couldn't load editors. You may no longer have access to this skill. + + ) : editors.editorsLoading ? null : ( +
+ {editors.editors.map((editor) => ( + + key={editor.id} + member={{ + userId: editor.userId, + userName: editor.userName, + userEmail: editor.userEmail, + role: 'editor', + }} + roleOptions={SKILL_EDITOR_ROLE_OPTIONS} + lockReason={skillEditorLockReason(editor.isWorkspaceAdmin)} + canManage={canEdit} + roleDisabled + removeDisabled={editor.isWorkspaceAdmin} + onRemove={() => editors.removeEditor(editor.userId)} + /> + ))} +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx new file mode 100644 index 00000000000..5f22dccfcf4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' +import { SkillDetail } from '@/app/workspace/[workspaceId]/skills/[skillId]/skill-detail' + +export const metadata: Metadata = { + title: 'Skill', +} + +export default async function SkillDetailPage({ + params, +}: { + params: Promise<{ workspaceId: string; skillId: string }> +}) { + const { workspaceId, skillId } = await params + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx new file mode 100644 index 00000000000..9029bbda79e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -0,0 +1,356 @@ +'use client' + +import { type ReactNode, useState } from 'react' +import { + Chip, + ChipConfirmModal, + ChipInput, + ChipLink, + ChipTextarea, + chipFieldSurfaceClass, + cn, + Send, + Tooltip, + toast, +} from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import dynamic from 'next/dynamic' +import { useRouter } from 'next/navigation' +import { AddPeopleModal } from '@/components/permissions' +import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { + CredentialDetailHeading, + CredentialDetailLayout, + DetailSection, + UnsavedChangesModal, + useUnsavedChangesGuard, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { SkillEditorsCard } from '@/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card' +import { useSkillEditorsController } from '@/app/workspace/[workspaceId]/skills/components/skill-members' +import { + isSkillNameConflictError, + parseSkillMarkdown, + validateSkillName, +} from '@/app/workspace/[workspaceId]/skills/components/utils' +import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills' + +const RichMarkdownField = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + ).then((m) => m.RichMarkdownField), + { + ssr: false, + loading: () =>
, + } +) + +const logger = createLogger('SkillDetail') + +interface FieldErrors { + name?: string + description?: string + content?: string +} + +interface FieldLockTooltipProps { + reason: string | null + children: ReactNode +} + +/** + * Wraps a read-only field so hovering it explains why editing is locked. + * Renders children unchanged when the field is editable. The wrapper div + * receives the hover events a disabled control swallows. + */ +function FieldLockTooltip({ reason, children }: FieldLockTooltipProps) { + if (!reason) return <>{children} + return ( + + +
{children}
+
+ {reason} +
+ ) +} + +interface SkillDetailProps { + workspaceId: string + skillId: string +} + +/** + * Full-page skill detail, mirroring the integration credential detail surface: + * a fixed action bar (Share / Delete / Save), a heading, editable Name / + * Description / Content sections, and the Skill Editors roster. Non-editors + * and built-in template skills render read-only. + */ +export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { + const router = useRouter() + const skillsHref = `/workspace/${workspaceId}/skills` + + const { data: skills = [], isPending: skillsLoading } = useSkills(workspaceId) + const updateSkill = useUpdateSkill() + const deleteSkill = useDeleteSkill() + const skill = skills.find((s) => s.id === skillId) ?? null + const isBuiltin = !!skill?.readOnly + const editors = useSkillEditorsController({ + skillId, + workspaceId, + // Built-ins have no editors; skip the roster fetch (it would 404). + enabled: !!skill && !isBuiltin, + }) + const canEdit = !isBuiltin && !!skill?.canEdit + + const [nameDraft, setNameDraft] = useState('') + const [descriptionDraft, setDescriptionDraft] = useState('') + const [contentDraft, setContentDraft] = useState('') + /** Bumped to remount the seed-once rich Content editor on programmatic sets. */ + const [contentSeed, setContentSeed] = useState(0) + const [errors, setErrors] = useState({}) + const [shareOpen, setShareOpen] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [prevSkillId, setPrevSkillId] = useState(null) + + // Seed drafts when the skill first resolves (or the route id changes); a + // background refetch of the same skill must not clobber an in-progress edit. + if (skill && skill.id !== prevSkillId) { + setPrevSkillId(skill.id) + setNameDraft(skill.name) + setDescriptionDraft(skill.description) + setContentDraft(skill.content) + setErrors({}) + setContentSeed((seed) => seed + 1) + } + + const isDirty = + !!skill && + !isBuiltin && + (nameDraft !== skill.name || + descriptionDraft !== skill.description || + contentDraft !== skill.content) + + const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref }) + + const handleSave = async () => { + if (!skill || !canEdit || !isDirty || updateSkill.isPending) return + + const newErrors: FieldErrors = {} + const nameError = validateSkillName(nameDraft) + if (nameError) newErrors.name = nameError + if (!descriptionDraft.trim()) newErrors.description = 'Description is required' + if (!contentDraft.trim()) newErrors.content = 'Content is required' + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + return + } + + try { + // Partial update: only the fields that changed go over the wire. + await updateSkill.mutateAsync({ + workspaceId, + skillId: skill.id, + updates: { + ...(nameDraft !== skill.name ? { name: nameDraft } : {}), + ...(descriptionDraft !== skill.description ? { description: descriptionDraft } : {}), + ...(contentDraft !== skill.content ? { content: contentDraft } : {}), + }, + }) + setErrors({}) + } catch (error) { + if (isSkillNameConflictError(error)) { + setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') }) + } else { + toast.error("Couldn't save skill", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + logger.error('Failed to save skill', error) + } + } + + const handleConfirmDelete = async () => { + if (!skill) return + setShowDeleteConfirm(false) + try { + await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id }) + router.push(skillsHref) + } catch (error) { + logger.error('Failed to delete skill', error) + } + } + + /** + * Pasting a full SKILL.md destructures it into the fields. Gated on a real + * YAML `name:` key so a stray `---` or heading-only snippet pastes as + * ordinary content instead of silently overwriting all three fields. + */ + const handleContentPaste = (text: string): boolean => { + const parsed = parseSkillMarkdown(text) + if (!parsed.nameFromFrontmatter) return false + setNameDraft(parsed.name) + setDescriptionDraft(parsed.description) + setContentDraft(parsed.content) + setErrors({}) + setContentSeed((seed) => seed + 1) + return true + } + + const back = ( + + Skills + + ) + + const actions = + skill && canEdit ? ( + <> + setShareOpen(true)}> + Share + + setShowDeleteConfirm(true)} disabled={deleteSkill.isPending}> + Delete + + + {updateSkill.isPending ? 'Saving...' : 'Save'} + + + ) : null + + if (skillsLoading && !skill) { + return ( + +

Loading…

+
+ ) + } + + if (!skill) { + return ( + +

Skill not found.

+
+ ) + } + + const readOnly = isBuiltin || !canEdit + const lockReason = !readOnly + ? null + : isBuiltin + ? 'Built-in skills are read-only' + : 'You need to be a skill editor to edit this skill' + + return ( + <> + + } + title={skill.name} + subtitle={isBuiltin ? 'Built-in skill' : skill.description} + /> + + + + { + setNameDraft(event.target.value) + if (errors.name) setErrors((prev) => ({ ...prev, name: undefined })) + }} + placeholder='my-skill-name' + autoComplete='off' + data-lpignore='true' + disabled={readOnly} + error={!!errors.name} + /> + + {errors.name && ( +

{errors.name}

+ )} +
+ + + + { + setDescriptionDraft(event.target.value) + if (errors.description) setErrors((prev) => ({ ...prev, description: undefined })) + }} + placeholder='What this skill does and when to use it...' + maxLength={1024} + autoComplete='off' + data-lpignore='true' + disabled={readOnly} + /> + + {errors.description && ( +

{errors.description}

+ )} +
+ + + + { + setContentDraft(value) + if (errors.content) setErrors((prev) => ({ ...prev, content: undefined })) + }} + placeholder='Skill instructions in markdown...' + minHeight={260} + disabled={readOnly} + error={!!errors.content} + workspaceId={workspaceId} + onPasteText={handleContentPaste} + /> + + {errors.content && ( +

{errors.content}

+ )} +
+ + {!isBuiltin && } +
+ + + + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts index a3408dbd9eb..425a026a858 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/index.ts @@ -1 +1,2 @@ export { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import/skill-import' +export { SkillImportButton } from '@/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button' diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx new file mode 100644 index 00000000000..02d7e616262 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import-button.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useRef, useState } from 'react' +import { Chip, Loader, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { + type ParsedSkill, + readSkillFile, + SKILL_IMPORT_ACCEPT, +} from '@/app/workspace/[workspaceId]/skills/components/utils' + +interface SkillImportButtonProps { + onImport: (data: ParsedSkill) => void + disabled?: boolean +} + +/** + * Header action that imports an existing SKILL.md into the create form. Opens + * the OS file picker directly — no field on the page — and reports failures as + * a toast, since the action bar has no room for an inline error. + */ +export function SkillImportButton({ onImport, disabled = false }: SkillImportButtonProps) { + const inputRef = useRef(null) + const [importing, setImporting] = useState(false) + + const handleFile = async (file: File) => { + setImporting(true) + try { + onImport(await readSkillFile(file)) + } catch (error) { + toast.error("Couldn't import skill", { + description: getErrorMessage(error, 'Please try a .md or .zip file.'), + }) + } finally { + setImporting(false) + } + } + + return ( + <> + inputRef.current?.click()} disabled={disabled || importing}> + {importing ? : 'Import'} + + { + const file = event.target.files?.[0] + event.target.value = '' + if (file) void handleFile(file) + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx index a49e5c2280a..4a06071c85b 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-import/skill-import.tsx @@ -1,156 +1,52 @@ 'use client' -import { useCallback, useState } from 'react' -import { Chip, ChipInput, ChipModalField, Loader } from '@sim/emcn' +import { useState } from 'react' +import { ChipModalField } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { requestJson } from '@/lib/api/client/request' -import { importSkillContract } from '@/lib/api/contracts' import { - extractSkillFromZip, - parseSkillMarkdown, + type ParsedSkill, + readSkillFile, + SKILL_IMPORT_ACCEPT, } from '@/app/workspace/[workspaceId]/skills/components/utils' -interface ImportedSkill { - name: string - description: string - content: string -} - interface SkillImportProps { - onImport: (data: ImportedSkill) => void -} - -type ImportState = 'idle' | 'loading' | 'error' - -const ACCEPTED_EXTENSIONS = ['.md', '.zip'] - -function isAcceptedFile(file: File): boolean { - const name = file.name.toLowerCase() - return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext)) + onImport: (data: ParsedSkill) => void } +/** + * The canvas modal's Import tab: a single file field that reads a SKILL.md (or + * a ZIP containing one) into the create form. The full-page create surface uses + * {@link SkillImportButton} in its action bar instead of a field. + */ export function SkillImport({ onImport }: SkillImportProps) { - const [githubUrl, setGithubUrl] = useState('') - const [githubState, setGithubState] = useState('idle') - const [githubError, setGithubError] = useState('') + const [importing, setImporting] = useState(false) + const [error, setError] = useState('') - const [fileState, setFileState] = useState('idle') - const [fileError, setFileError] = useState('') - - const handleGithubImport = useCallback(async () => { - const trimmed = githubUrl.trim() - if (!trimmed) { - setGithubError('Please enter a GitHub URL') - setGithubState('error') - return - } - - setGithubState('loading') - setGithubError('') + const handleFiles = async (files: File[]) => { + const file = files[0] + if (!file) return + setImporting(true) + setError('') try { - const data = await requestJson(importSkillContract, { body: { url: trimmed } }) - const parsed = parseSkillMarkdown(data.content) - setGithubState('idle') - onImport(parsed) + onImport(await readSkillFile(file)) } catch (err) { - const message = getErrorMessage(err, 'Failed to import from GitHub') - setGithubError(message) - setGithubState('error') + setError(getErrorMessage(err, 'Failed to process file')) + } finally { + setImporting(false) } - }, [githubUrl, onImport]) - - const processFile = useCallback( - async (file: File) => { - if (!isAcceptedFile(file)) { - setFileError('Unsupported file type. Use .md or .zip files.') - setFileState('error') - return - } - - setFileState('loading') - setFileError('') - - try { - let rawContent: string - - if (file.name.toLowerCase().endsWith('.zip')) { - if (file.size > 5 * 1024 * 1024) { - setFileError('ZIP file is too large (max 5 MB)') - setFileState('error') - return - } - rawContent = await extractSkillFromZip(file) - } else { - rawContent = await file.text() - } - - const parsed = parseSkillMarkdown(rawContent) - setFileState('idle') - onImport(parsed) - } catch (err) { - const message = getErrorMessage(err, 'Failed to process file') - setFileError(message) - setFileState('error') - } - }, - [onImport] - ) - - const handleFiles = useCallback( - (files: File[]) => { - const file = files[0] - if (file) processFile(file) - }, - [processFile] - ) - - return ( -
- -
- { - setGithubUrl(e.target.value) - if (githubError) setGithubError('') - }} - disabled={githubState === 'loading'} - className='min-w-0 flex-1' - /> - - {githubState === 'loading' ? : 'Fetch'} - -
-
- - - - -
- ) -} + } -function ImportDivider() { return ( -
-
- or -
-
+ void handleFiles(files)} + loading={importing} + label={importing ? 'Importing…' : undefined} + description='.md file with YAML frontmatter, or .zip containing a SKILL.md' + error={error || undefined} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts new file mode 100644 index 00000000000..18d6f7716c6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/index.ts @@ -0,0 +1,4 @@ +export { + type SkillEditorsController, + useSkillEditorsController, +} from './use-skill-editors-controller' diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts new file mode 100644 index 00000000000..bcc2e6483fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-members/use-skill-editors-controller.ts @@ -0,0 +1,76 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { createLogger } from '@sim/logger' +import type { AddPeopleTarget } from '@/components/permissions' +import type { SkillEditor } from '@/lib/api/contracts' +import { useRemoveSkillMember, useSkillMembers, useUpsertSkillMember } from '@/hooks/queries/skills' + +const logger = createLogger('SkillEditorsController') + +export interface SkillEditorsController { + editors: SkillEditor[] + editorsLoading: boolean + editorsError: boolean + /** Lowercased emails already on the roster (incl. workspace admins) — feeds the Add People modal. */ + existingEditorEmails: Set + addEditor: (target: AddPeopleTarget) => Promise + removeEditor: (userId: string) => Promise +} + +interface UseSkillEditorsControllerParams { + skillId: string + workspaceId: string + /** Gate the roster fetch off (e.g. built-in template skills have no editors). */ + enabled?: boolean +} + +/** + * Data + mutation controller behind the skill editor surfaces (the detail + * page's Skill Editors section and the Share modal): exposes the roster — + * explicit editors plus derived workspace admins — and the add/remove actions. + * Renderers own only chrome. + */ +export function useSkillEditorsController({ + skillId, + workspaceId, + enabled = true, +}: UseSkillEditorsControllerParams): SkillEditorsController { + const { + data: editors = [], + isPending: editorsLoading, + isError: editorsError, + } = useSkillMembers(skillId, { enabled }) + const { mutateAsync: upsertEditorAsync } = useUpsertSkillMember() + const { mutateAsync: removeEditorAsync } = useRemoveSkillMember() + + const existingEditorEmails = useMemo( + () => new Set(editors.map((editor) => (editor.userEmail ?? '').toLowerCase()).filter(Boolean)), + [editors] + ) + + const addEditor = useCallback( + (target: AddPeopleTarget) => upsertEditorAsync({ skillId, workspaceId, userId: target.userId }), + [upsertEditorAsync, skillId, workspaceId] + ) + + const removeEditor = useCallback( + async (userId: string) => { + try { + await removeEditorAsync({ skillId, workspaceId, userId }) + } catch (error) { + logger.error('Failed to remove skill editor', error) + } + }, + [removeEditorAsync, skillId, workspaceId] + ) + + return { + editors, + editorsLoading, + editorsError, + existingEditorEmails, + addEditor, + removeEditor, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index a8b6e2fb4dd..cd6d8d29f78 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -12,10 +12,15 @@ import { chipFieldSurfaceClass, cn, } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' import { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import' -import { parseSkillMarkdown } from '@/app/workspace/[workspaceId]/skills/components/utils' +import { + isSkillNameConflictError, + parseSkillMarkdown, + validateSkillName, +} from '@/app/workspace/[workspaceId]/skills/components/utils' import type { SkillDefinition } from '@/hooks/queries/skills' import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills' @@ -34,12 +39,9 @@ interface SkillModalProps { open: boolean onOpenChange: (open: boolean) => void onSave: () => void - onDelete?: (skillId: string) => void initialValues?: SkillDefinition } -const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ - interface FieldErrors { name?: string description?: string @@ -49,13 +51,12 @@ interface FieldErrors { type TabValue = 'create' | 'import' -export function SkillModal({ - open, - onOpenChange, - onSave, - onDelete, - initialValues, -}: SkillModalProps) { +const CREATE_TABS = [ + { value: 'create', label: 'Create' }, + { value: 'import', label: 'Import' }, +] as const + +export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillModalProps) { const params = useParams() const workspaceId = params.workspaceId as string @@ -98,13 +99,8 @@ export function SkillModal({ const handleSave = async () => { const newErrors: FieldErrors = {} - if (!name.trim()) { - newErrors.name = 'Name is required' - } else if (name.length > 64) { - newErrors.name = 'Name must be 64 characters or less' - } else if (!KEBAB_CASE_REGEX.test(name)) { - newErrors.name = 'Name must be kebab-case (e.g. my-skill)' - } + const nameError = validateSkillName(name) + if (nameError) newErrors.name = nameError if (!description.trim()) { newErrors.description = 'Description is required' @@ -136,11 +132,11 @@ export function SkillModal({ } onSave() } catch (error) { - const message = - error instanceof Error && error.message.includes('already exists') - ? error.message - : 'Failed to save skill. Please try again.' - setErrors({ general: message }) + if (isSkillNameConflictError(error)) { + setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') }) + } else { + setErrors({ general: 'Failed to save skill. Please try again.' }) + } } finally { setSaving(false) } @@ -172,8 +168,11 @@ export function SkillModal({ } const isEditing = !!initialValues - const readOnly = !!initialValues?.readOnly - const showFooter = activeTab === 'create' || isEditing + const isBuiltin = !!initialValues?.readOnly + /** New skills are created by the actor (who becomes an editor); existing ones require editor access. */ + const canEditSkill = !initialValues || initialValues.canEdit + const readOnly = isBuiltin || (isEditing && !canEditSkill) + const showFooter = activeTab === 'create' return ( {!isEditing && ( setActiveTab(value as TabValue)} /> @@ -213,6 +209,7 @@ export function SkillModal({ required error={errors.name} hint='Lowercase letters, numbers, and hyphens (e.g. my-skill)' + disabled={readOnly || saving} /> @@ -241,6 +239,7 @@ export function SkillModal({ }} placeholder='Skill instructions in markdown...' minHeight={200} + maxHeight={360} disabled={readOnly || saving} error={!!errors.content} workspaceId={workspaceId} @@ -258,19 +257,7 @@ export function SkillModal({ {showFooter && ( onOpenChange(false)} - cancelDisabled={readOnly} - secondaryActions={ - isEditing && onDelete - ? [ - { - label: 'Delete', - onClick: () => onDelete(initialValues.id), - variant: 'destructive', - disabled: readOnly, - }, - ] - : undefined - } + cancelDisabled={isBuiltin} primaryAction={{ label: saving ? 'Saving...' : isEditing ? 'Update' : 'Create', onClick: handleSave, diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts index debefcf690f..bdb862bf939 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts @@ -1,6 +1,7 @@ import JSZip from 'jszip' +import { isApiClientError } from '@/lib/api/client/errors' -interface ParsedSkill { +export interface ParsedSkill { name: string description: string content: string @@ -86,7 +87,6 @@ function inferNameFromHeading(markdown: string): string { /** * Extracts the SKILL.md content from a ZIP archive. * Searches for a file named SKILL.md at any depth within the archive. - * Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports). */ export async function extractSkillFromZip( data: File | Blob | ArrayBuffer | Uint8Array @@ -113,3 +113,56 @@ export async function extractSkillFromZip( const content = await zip.file(candidates[0])!.async('string') return content } +const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ + +/** + * Validates a skill name against the same rules the API enforces + * (`skillNameSchema`). Returns the field message, or null when valid. Shared so + * the rule and its copy live in one place across every skill editing surface. + */ +export function validateSkillName(name: string): string | null { + if (!name.trim()) return 'Name is required' + if (name.length > 64) return 'Name must be 64 characters or less' + if (!KEBAB_CASE_REGEX.test(name)) return 'Name must be kebab-case (e.g. my-skill)' + return null +} + +const SKILL_IMPORT_EXTENSIONS = ['.md', '.zip'] as const + +/** ZIPs are read fully in memory to find the SKILL.md, so cap what we'll accept. */ +const MAX_SKILL_ZIP_BYTES = 5 * 1024 * 1024 + +/** `accept` attribute for the skill file pickers. */ +export const SKILL_IMPORT_ACCEPT = SKILL_IMPORT_EXTENSIONS.join(',') + +/** + * Reads a user-picked `.md` or `.zip` into structured skill fields — the shared + * path behind every import entry point (the create page's Import action and the + * canvas modal's Import tab). Throws a user-facing message on an unsupported + * extension, an oversized ZIP, or a ZIP with no SKILL.md inside. + */ +export async function readSkillFile(file: File): Promise { + const name = file.name.toLowerCase() + + if (!SKILL_IMPORT_EXTENSIONS.some((ext) => name.endsWith(ext))) { + throw new Error('Unsupported file type. Use .md or .zip files.') + } + + if (name.endsWith('.zip')) { + if (file.size > MAX_SKILL_ZIP_BYTES) { + throw new Error('ZIP file is too large (max 5 MB)') + } + return parseSkillMarkdown(await extractSkillFromZip(file)) + } + + return parseSkillMarkdown(await file.text()) +} + +/** + * Whether a skill save failed on the per-workspace unique-name constraint + * (HTTP 409). Surfaces as an inline Name-field error at the callsites; the + * server message names the conflicting skill. + */ +export function isSkillNameConflictError(error: unknown): boolean { + return isApiClientError(error) && error.status === 409 +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx new file mode 100644 index 00000000000..e8d291584a7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/new/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next' +import { SkillCreate } from '@/app/workspace/[workspaceId]/skills/new/skill-create' + +export const metadata: Metadata = { + title: 'New Skill', +} + +export default async function SkillCreatePage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx new file mode 100644 index 00000000000..6c4a246f47b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx @@ -0,0 +1,237 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipInput, + ChipLink, + ChipTextarea, + chipFieldSurfaceClass, + cn, + toast, +} from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import dynamic from 'next/dynamic' +import { useRouter } from 'next/navigation' +import { SkillTile } from '@/app/workspace/[workspaceId]/components' +import { + CredentialDetailHeading, + CredentialDetailLayout, + DetailSection, + UnsavedChangesModal, + useUnsavedChangesGuard, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { SkillImportButton } from '@/app/workspace/[workspaceId]/skills/components/skill-import' +import { + isSkillNameConflictError, + type ParsedSkill, + parseSkillMarkdown, + validateSkillName, +} from '@/app/workspace/[workspaceId]/skills/components/utils' +import { useCreateSkill } from '@/hooks/queries/skills' + +const RichMarkdownField = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + ).then((m) => m.RichMarkdownField), + { + ssr: false, + loading: () =>
, + } +) + +const logger = createLogger('SkillCreate') + +interface FieldErrors { + name?: string + description?: string + content?: string +} + +interface SkillCreateProps { + workspaceId: string +} + +/** + * Full-page skill creation, mirroring the skill detail surface: a fixed action + * bar (Import / Create skill), a heading, and the editable Name / Description / + * Content sections. Importing a SKILL.md prefills all three fields in place. + */ +export function SkillCreate({ workspaceId }: SkillCreateProps) { + const router = useRouter() + const skillsHref = `/workspace/${workspaceId}/skills` + + const createSkill = useCreateSkill() + + const [nameDraft, setNameDraft] = useState('') + const [descriptionDraft, setDescriptionDraft] = useState('') + const [contentDraft, setContentDraft] = useState('') + /** Bumped to remount the seed-once rich Content editor on programmatic sets. */ + const [contentSeed, setContentSeed] = useState(0) + const [errors, setErrors] = useState({}) + + // Drops on success so the guard pops its history sentinel before we navigate — + // otherwise Back from the new skill lands on a stale, empty create form. + const isDirty = + !createSkill.isSuccess && + (!!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim()) + + const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref }) + + const handleCreate = async () => { + if (createSkill.isPending) return + + const newErrors: FieldErrors = {} + const nameError = validateSkillName(nameDraft) + if (nameError) newErrors.name = nameError + if (!descriptionDraft.trim()) newErrors.description = 'Description is required' + if (!contentDraft.trim()) newErrors.content = 'Content is required' + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + return + } + + try { + const created = await createSkill.mutateAsync({ + workspaceId, + skill: { name: nameDraft, description: descriptionDraft, content: contentDraft }, + }) + setErrors({}) + // The upsert responds with the caller's whole skill list (built-ins + // included), not just the new row — match by name, which is unique per + // workspace, rather than trusting the first element. + const createdId = created.find((skill) => skill.name === nameDraft)?.id + router.push(createdId ? `${skillsHref}/${createdId}` : skillsHref) + } catch (error) { + if (isSkillNameConflictError(error)) { + setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') }) + } else { + toast.error("Couldn't create skill", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + logger.error('Failed to create skill', error) + } + } + + const applyImportedSkill = (data: ParsedSkill) => { + setNameDraft(data.name) + setDescriptionDraft(data.description) + setContentDraft(data.content) + setErrors({}) + setContentSeed((seed) => seed + 1) + } + + /** + * Pasting a full SKILL.md destructures it into the fields. Gated on a real + * YAML `name:` key so a stray `---` or heading-only snippet pastes as + * ordinary content instead of silently overwriting all three fields. + */ + const handleContentPaste = (text: string): boolean => { + const parsed = parseSkillMarkdown(text) + if (!parsed.nameFromFrontmatter) return false + applyImportedSkill(parsed) + return true + } + + const back = ( + + Skills + + ) + + const actions = ( + <> + + + {createSkill.isPending ? 'Creating...' : 'Create'} + + + ) + + return ( + <> + + } + title='New skill' + subtitle='Write a skill, or import an existing SKILL.md' + /> + + + { + setNameDraft(event.target.value) + if (errors.name) setErrors((prev) => ({ ...prev, name: undefined })) + }} + placeholder='my-skill-name' + autoComplete='off' + data-lpignore='true' + disabled={createSkill.isPending} + error={!!errors.name} + /> +

+ {errors.name ?? 'Lowercase letters, numbers, and hyphens (e.g. my-skill)'} +

+
+ + + { + setDescriptionDraft(event.target.value) + if (errors.description) setErrors((prev) => ({ ...prev, description: undefined })) + }} + placeholder='What this skill does and when to use it...' + maxLength={1024} + autoComplete='off' + data-lpignore='true' + disabled={createSkill.isPending} + error={!!errors.description} + /> + {errors.description && ( +

{errors.description}

+ )} +
+ + + { + setContentDraft(value) + if (errors.content) setErrors((prev) => ({ ...prev, content: undefined })) + }} + placeholder='Skill instructions in markdown...' + minHeight={260} + disabled={createSkill.isPending} + error={!!errors.content} + workspaceId={workspaceId} + onPasteText={handleContentPaste} + /> + {errors.content && ( +

{errors.content}

+ )} +
+
+ + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts index 554005fc76d..e78b8167fc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts @@ -3,20 +3,18 @@ import { parseAsString } from 'nuqs/server' /** * Co-located, typed URL query-param definition for the Skills gallery. * - * `skillId` deep-links the skill edit modal to a specific skill. The modal - * opens when a valid id (one that resolves to a loaded skill) is present; - * closing it clears the param. Opening a skill is a destination, so it lands in - * the browser history (`history: 'push'`). The "create new skill" flow has no id - * and stays in local component state. + * `skillId` is a LEGACY deep-link param from when skills edited in a modal on + * this page. Skills now open at `/workspace/[workspaceId]/skills/[skillId]`; + * the gallery reads this param once and redirects there (read-then-strip). */ export const skillIdParam = { key: 'skillId', parser: parseAsString, } as const -/** Opening a skill is a destination → push to history; clear on close. */ +/** Read-once redirect signal — replace history, never linger. */ export const skillIdUrlKeys = { - history: 'push', + history: 'replace', clearOnDefault: true, } as const diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index d045bf88b1b..7004a6c3ebf 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -1,27 +1,23 @@ 'use client' -import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipInput, Search } from '@sim/emcn' -import { createLogger } from '@sim/logger' +import { useEffect, useRef } from 'react' +import { Chip, ChipInput, Search } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { ArrowRight, Plus } from 'lucide-react' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' -import { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal' import { skillIdParam, skillIdUrlKeys, skillSearchParam, skillSearchUrlKeys, } from '@/app/workspace/[workspaceId]/skills/search-params' -import { useDeleteSkill, useSkills } from '@/hooks/queries/skills' +import { useSkills } from '@/hooks/queries/skills' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' -const logger = createLogger('SkillsSettings') - const SKILLS_LABEL = 'Skills' interface SkillItemProps { @@ -68,22 +64,31 @@ function SkillSection({ label, children }: SkillSectionProps) { export function Skills() { const params = useParams() + const router = useRouter() const workspaceId = (params?.workspaceId as string) || '' + const skillsHref = `/workspace/${workspaceId}/skills` const { data: skills = [], isLoading, error } = useSkills(workspaceId) - const deleteSkillMutation = useDeleteSkill() const [searchTerm, setSearchTermParam] = useQueryState(skillSearchParam.key, { ...skillSearchParam.parser, ...skillSearchUrlKeys, }) - const [editingSkillId, setEditingSkillId] = useQueryState(skillIdParam.key, { + const [legacySkillId, setLegacySkillId] = useQueryState(skillIdParam.key, { ...skillIdParam.parser, ...skillIdUrlKeys, }) - const [showAddForm, setShowAddForm] = useState(false) - const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) + /** + * Legacy deep links opened the edit modal via `?skillId=`; skills now have a + * dedicated detail page. Redirect once, stripping the param. + */ + const redirectedLegacyId = useRef(false) + useEffect(() => { + if (!legacySkillId || redirectedLegacyId.current) return + redirectedLegacyId.current = true + setLegacySkillId(null, { history: 'replace' }) + router.replace(`${skillsHref}/${legacySkillId}`) + }, [legacySkillId, setLegacySkillId, router, skillsHref]) /** * The input is controlled directly by the instant nuqs value; only the URL @@ -92,9 +97,6 @@ export function Skills() { */ const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam) - /** Derive the skill being edited from the loaded list — never store the object in the URL. */ - const editingSkill = editingSkillId ? (skills.find((s) => s.id === editingSkillId) ?? null) : null - const filteredSkills = skills.filter((s) => { if (!searchTerm.trim()) return true const searchLower = searchTerm.trim().toLowerCase() @@ -104,46 +106,15 @@ export function Skills() { ) }) - const handleDeleteClick = (skillId: string) => { - const s = skills.find((sk) => sk.id === skillId) - if (!s) return - - setSkillToDelete({ id: skillId, name: s.name }) - setShowDeleteDialog(true) - } - - const handleDeleteSkill = async () => { - if (!skillToDelete) return - - setShowDeleteDialog(false) - - try { - await deleteSkillMutation.mutateAsync({ - workspaceId, - skillId: skillToDelete.id, - }) - logger.info(`Deleted skill: ${skillToDelete.id}`) - } catch (error) { - logger.error('Error deleting skill:', error) - } finally { - setSkillToDelete(null) - } - } - - const handleSkillSaved = () => { - setShowAddForm(false) - setEditingSkillId(null) - } - const showNoResults = searchTerm.trim() && filteredSkills.length === 0 - const handleOpenAddForm = () => { - setEditingSkillId(null) - setShowAddForm(true) - } - const addButton = ( - + router.push(`${skillsHref}/new`)} + disabled={isLoading} + leftIcon={Plus} + > Add to Sim ) @@ -177,7 +148,7 @@ export function Skills() { key={s.id} name={s.name} description={s.description} - onClick={() => setEditingSkillId(s.id)} + onClick={() => router.push(`${skillsHref}/${s.id}`)} /> ))} @@ -189,38 +160,6 @@ export function Skills() {
- - { - if (!open) { - setShowAddForm(false) - setEditingSkillId(null) - } - }} - onSave={handleSkillSaved} - onDelete={(skillId) => { - setEditingSkillId(null) - handleDeleteClick(skillId) - }} - initialValues={editingSkill ?? undefined} - /> - -
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx index 80be87af9ea..ac2282c5b62 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/skill-input/skill-input.tsx @@ -10,8 +10,7 @@ import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' -import type { SkillDefinition } from '@/hooks/queries/skills' -import { useSkills } from '@/hooks/queries/skills' +import { type SkillDefinition, useSkills } from '@/hooks/queries/skills' import { usePermissionConfig } from '@/hooks/use-permission-config' interface StoredSkill { @@ -42,7 +41,16 @@ export function SkillInput({ const { data: workspaceSkills = [] } = useSkills(workspaceId) const [value, setValue] = useSubBlockValue(blockId, subBlockId) const [showCreateModal, setShowCreateModal] = useState(false) - const [editingSkill, setEditingSkill] = useState(null) + const [editingSkillId, setEditingSkillId] = useState(null) + const [editingSkillSnapshot, setEditingSkillSnapshot] = useState(null) + + // Prefer the live query cache so the modal reflects concurrent edits, but + // fall back to the click-time snapshot when a background refetch drops the + // skill — otherwise the modal would close mid-edit and silently discard the + // draft; saving surfaces the real server error instead. + const editingSkill = editingSkillId + ? (workspaceSkills.find((s) => s.id === editingSkillId) ?? editingSkillSnapshot) + : null const selectedSkills: StoredSkill[] = useMemo(() => { if (isPreview && previewValue) { @@ -105,7 +113,8 @@ export function SkillInput({ const handleSkillSaved = useCallback(() => { setShowCreateModal(false) - setEditingSkill(null) + setEditingSkillId(null) + setEditingSkillSnapshot(null) }, []) const resolveSkillName = useCallback( @@ -150,7 +159,8 @@ export function SkillInput({ className='flex cursor-pointer items-center justify-between gap-2 rounded-t-[4px] bg-[var(--surface-4)] px-2 py-[6.5px]' onClick={() => { if (fullSkill && !disabled && !isPreview) { - setEditingSkill(fullSkill) + setEditingSkillId(fullSkill.id) + setEditingSkillSnapshot(fullSkill) } }} > @@ -188,7 +198,8 @@ export function SkillInput({ onOpenChange={(isOpen) => { if (!isOpen) { setShowCreateModal(false) - setEditingSkill(null) + setEditingSkillId(null) + setEditingSkillSnapshot(null) } }} onSave={handleSkillSaved} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx index a6df56db169..61012a92c35 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx @@ -4,14 +4,14 @@ import { CODE_LINE_HEIGHT_PX, Code, calculateGutterWidth, + chipFieldSurfaceClass, cn, getCodeEditorProps, highlight, languages, } from '@sim/emcn' -import { Wand2 } from 'lucide-react' import Editor from 'react-simple-code-editor' -import { Button } from '@/components/ui/button' +import type { SchemaParameter } from '@/app/workspace/[workspaceId]/components/custom-tool-editor/custom-tool-schema' import { createEnvVarPattern, createWorkflowVariablePattern, @@ -22,16 +22,15 @@ interface CodeEditorProps { onChange: (value: string) => void language: 'javascript' | 'json' placeholder?: string + /** Layout/sizing only — the chip-field chrome is owned by this component. */ className?: string - gutterClassName?: string + /** Swaps the field border to the error token. */ + error?: boolean minHeight?: string highlightVariables?: boolean onKeyDown?: (e: React.KeyboardEvent) => void disabled?: boolean - schemaParameters?: Array<{ name: string; type: string; description: string; required: boolean }> - showWandButton?: boolean - onWandClick?: () => void - wandButtonDisabled?: boolean + schemaParameters?: SchemaParameter[] } const EMPTY_SCHEMA_PARAMETERS: NonNullable = [] @@ -42,15 +41,12 @@ export function CodeEditor({ language, placeholder = '', className = '', - gutterClassName = '', + error = false, minHeight, highlightVariables = true, onKeyDown, disabled = false, schemaParameters = EMPTY_SCHEMA_PARAMETERS, - showWandButton = false, - onWandClick, - wandButtonDisabled = false, }: CodeEditorProps) { const [visualLineHeights, setVisualLineHeights] = useState([]) @@ -182,21 +178,11 @@ export function CodeEditor({ } return ( - - {showWandButton && onWandClick && ( - - )} - - + + {renderLineNumbers()} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx index ab399baa0de..10dc434c4ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx @@ -1,37 +1,30 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +'use client' + +import { useEffect, useMemo, useState } from 'react' import { - Badge, - Button, ChipConfirmModal, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, ChipModalTabs, - cn, - Input, - Label, - Popover, - PopoverAnchor, - PopoverContent, - PopoverItem, - PopoverScrollArea, - PopoverSection, + toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { AlertCircle, ArrowUp } from 'lucide-react' import { useParams } from 'next/navigation' import { - checkEnvVarTrigger, - EnvVarDropdown, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' -import { - checkTagTrigger, - TagDropdown, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' -import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor' -import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' + CUSTOM_TOOL_DELETE_CONFIRM_TEXT, + CustomToolCodeField, + CustomToolSchemaField, + extractSchemaParameters, + FieldErrorText, + FUNCTION_NAME_LOCKED, + GeneratePromptControl, + useCodeGeneration, + useSchemaGeneration, + validateCustomToolSchema, +} from '@/app/workspace/[workspaceId]/components/custom-tool-editor' import { useCreateCustomTool, useCustomTools, @@ -68,6 +61,11 @@ export interface CustomTool { type ToolSection = 'schema' | 'code' +const TOOL_TABS = [ + { value: 'schema', label: 'Schema' }, + { value: 'code', label: 'Code' }, +] as const + export function CustomToolModal({ open, onOpenChange, @@ -89,216 +87,34 @@ export function CustomToolModal({ const [initialFunctionCode, setInitialFunctionCode] = useState('') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDiscardAlert, setShowDiscardAlert] = useState(false) - const [isSchemaPromptActive, setIsSchemaPromptActive] = useState(false) - const [schemaPromptInput, setSchemaPromptInput] = useState('') - const schemaPromptInputRef = useRef(null) - - const [isCodePromptActive, setIsCodePromptActive] = useState(false) - const [codePromptInput, setCodePromptInput] = useState('') - const codePromptInputRef = useRef(null) - - const schemaGeneration = useWand({ - wandConfig: { - enabled: true, - maintainHistory: true, - prompt: `You are an expert programmer specializing in creating OpenAI function calling format JSON schemas for custom tools. -Generate ONLY the JSON schema based on the user's request. -The output MUST be a single, valid JSON object, starting with { and ending with }. -The JSON schema MUST follow this specific format: -1. Top-level property "type" must be set to "function" -2. A "function" object containing: - - "name": A concise, camelCase name for the function - - "description": A clear description of what the function does - - "parameters": A JSON Schema object describing the function's parameters with: - - "type": "object" - - "properties": An object containing parameter definitions - - "required": An array of required parameter names -Current schema: {context} - -Do not include any explanations, markdown formatting, or other text outside the JSON object. - -Valid Schema Examples: - -Example 1: -{ - "type": "function", - "function": { - "name": "getWeather", - "description": "Fetches the current weather for a specific location.", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g., San Francisco, CA" - }, - "unit": { - "type": "string", - "description": "Temperature unit", - "enum": ["celsius", "fahrenheit"] - } - }, - "required": ["location"], - "additionalProperties": false - } - } -} + const schemaParameters = useMemo(() => extractSchemaParameters(jsonSchema), [jsonSchema]) -Example 2: -{ - "type": "function", - "function": { - "name": "addItemToOrder", - "description": "Add one quantity of a food item to the order.", - "parameters": { - "type": "object", - "properties": { - "itemName": { - "type": "string", - "description": "The name of the food item to add to order" - }, - "quantity": { - "type": "integer", - "description": "The quantity of the item to add", - "default": 1 - } - }, - "required": ["itemName"], - "additionalProperties": false - } - } -}`, - placeholder: 'Describe the function parameters and structure...', - generationType: 'custom-tool-schema', - }, - currentValue: jsonSchema, - onStreamStart: () => { - setJsonSchema('') - }, - onGeneratedContent: (content) => { - setJsonSchema(content) + const schemaGeneration = useSchemaGeneration({ + jsonSchema, + setJsonSchema: (updater) => { + setJsonSchema(updater) setSchemaError(null) }, - onStreamChunk: (chunk) => { - setJsonSchema((prev) => { - const newSchema = prev + chunk - if (schemaError) setSchemaError(null) - return newSchema - }) + replaceJsonSchema: (value) => { + setJsonSchema(value) + setSchemaError(null) }, }) - const schemaParameters = useMemo(() => { - try { - if (!jsonSchema) return [] - const parsed = JSON.parse(jsonSchema) - const properties = parsed?.function?.parameters?.properties - if (!properties) return [] - - return Object.keys(properties).map((key) => ({ - name: key, - type: properties[key].type || 'any', - description: properties[key].description || '', - required: parsed?.function?.parameters?.required?.includes(key) || false, - })) - } catch { - return [] - } - }, [jsonSchema]) - - const codeGenerationSchemaContext = useMemo(() => { - if (schemaParameters.length === 0) { - return 'Schema parameters: (none defined yet — the user has not added any parameters to the schema)' - } - const lines = schemaParameters.map((p) => { - const requiredLabel = p.required ? 'required' : 'optional' - const description = p.description ? `: ${p.description}` : '' - return `- ${p.name} (${p.type}, ${requiredLabel})${description}` - }) - return `Schema parameters (reference these directly by name in the generated code):\n${lines.join('\n')}` - }, [schemaParameters]) - - const codeGeneration = useWand({ - wandConfig: { - enabled: true, - maintainHistory: true, - prompt: `You are an expert JavaScript programmer. -Generate ONLY the raw body of a JavaScript function based on the user's request. -The code should be executable within an 'async function(params, environmentVariables) {...}' context. -- 'params' (object): Contains input parameters derived from the JSON schema. Reference these directly by name (e.g., 'userId', 'cityName'). Do NOT use 'params.paramName'. -- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use 'environmentVariables.VAR_NAME' or env. - -${codeGenerationSchemaContext} - -Current code: {context} - -IMPORTANT FORMATTING RULES: -1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes (e.g., use 'const apiKey = {{SERVICE_API_KEY}};' not 'const apiKey = "{{SERVICE_API_KEY}}";'). Our system replaces these placeholders before execution. -2. Reference Input Parameters/Workflow Variables: Reference them directly by name (e.g., 'const city = cityName;' or use directly in template strings like \`\${cityName}\`). Do NOT wrap in quotes or angle brackets. -3. Function Body ONLY: Do NOT include the function signature (e.g., 'async function myFunction() {' or the surrounding '}'). -4. Imports: Do NOT include import/require statements unless they are standard Node.js built-in modules (e.g., 'crypto', 'fs'). External libraries are not supported in this context. -5. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'. -6. Clarity: Write clean, readable code. -7. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw JavaScript code for the function body. - -Example Scenario: -User Prompt: "Fetch weather data from OpenWeather API. Use the city name passed in as 'cityName' and an API Key stored as the 'OPENWEATHER_API_KEY' environment variable." - -Generated Code: -const apiKey = {{OPENWEATHER_API_KEY}}; -const url = \`https://api.openweathermap.org/data/2.5/weather?q=\${cityName}&appid=\${apiKey}\`; - -try { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(\`API request failed with status \${response.status}: \${await response.text()}\`); - } - - const weatherData = await response.json(); - return weatherData; -} catch (error) { - console.error(\`Error fetching weather data: \${error.message}\`); - throw error; -}`, - placeholder: 'Describe the JavaScript function to generate...', - generationType: 'javascript-function-body', - }, - currentValue: functionCode, - onStreamStart: () => { - setFunctionCode('') - }, - onGeneratedContent: (content) => { - handleFunctionCodeChange(content) + const codeGeneration = useCodeGeneration({ + functionCode, + schemaParameters, + setFunctionCode: (updater) => { + setFunctionCode(updater) setCodeError(null) }, - onStreamChunk: (chunk) => { - setFunctionCode((prev) => { - const newCode = prev + chunk - handleFunctionCodeChange(newCode) - if (codeError) setCodeError(null) - return newCode - }) + replaceFunctionCode: (value) => { + setFunctionCode(value) + setCodeError(null) }, }) - const [showEnvVars, setShowEnvVars] = useState(false) - const [showTags, setShowTags] = useState(false) - const [showSchemaParams, setShowSchemaParams] = useState(false) - const [searchTerm, setSearchTerm] = useState('') - const [cursorPosition, setCursorPosition] = useState(0) - const codeEditorRef = useRef(null) - const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }) - const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0) - const schemaParamItemRefs = useRef>(new Map()) - const createToolMutation = useCreateCustomTool() const updateToolMutation = useUpdateCustomTool() const deleteToolMutation = useDeleteCustomTool() @@ -329,18 +145,6 @@ try { } }, [open]) - useEffect(() => { - if (!showSchemaParams || schemaParamSelectedIndex < 0) return - - const element = schemaParamItemRefs.current.get(schemaParamSelectedIndex) - if (element) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }) - } - }, [schemaParamSelectedIndex, showSchemaParams]) - const resetForm = () => { setJsonSchema('') setFunctionCode('') @@ -351,15 +155,7 @@ try { setActiveSection('schema') setIsEditing(false) setToolId(undefined) - setIsSchemaPromptActive(false) - setIsCodePromptActive(false) - setSchemaPromptInput('') - setCodePromptInput('') setShowDiscardAlert(false) - schemaGeneration.closePrompt() - schemaGeneration.hidePromptInline() - codeGeneration.closePrompt() - codeGeneration.hidePromptInline() } const handleClose = () => { @@ -369,53 +165,18 @@ try { onOpenChange(false) } - const validateSchema = (schema: string): { isValid: boolean; error: string | null } => { - if (!schema) return { isValid: false, error: null } - - try { - const parsed = JSON.parse(schema) - - if (!parsed.type || parsed.type !== 'function') { - return { isValid: false, error: 'Missing "type": "function"' } - } - if (!parsed.function || !parsed.function.name) { - return { isValid: false, error: 'Missing function.name field' } - } - if (!parsed.function.parameters) { - return { isValid: false, error: 'Missing function.parameters object' } - } - if (!parsed.function.parameters.type) { - return { isValid: false, error: 'Missing parameters.type field' } - } - if (parsed.function.parameters.properties === undefined) { - return { isValid: false, error: 'Missing parameters.properties field' } - } - if ( - typeof parsed.function.parameters.properties !== 'object' || - parsed.function.parameters.properties === null - ) { - return { isValid: false, error: 'parameters.properties must be an object' } - } - - return { isValid: true, error: null } - } catch { - return { isValid: false, error: 'Invalid JSON format' } - } - } - - const isSchemaValid = useMemo(() => validateSchema(jsonSchema).isValid, [jsonSchema]) + /** The Generate control and error live in the shared tab row, so both follow the visible editor. */ + const activeGeneration = activeSection === 'schema' ? schemaGeneration : codeGeneration + const activeError = + activeSection === 'schema' ? schemaError : codeGeneration.isStreaming ? null : codeError - const hasChanges = useMemo(() => { - if (!isEditing) return true - return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode - }, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode]) + const isSchemaValid = useMemo(() => validateCustomToolSchema(jsonSchema).isValid, [jsonSchema]) - const hasUnsavedChanges = useMemo(() => { - if (isEditing) { - return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode - } - return jsonSchema.trim().length > 0 || functionCode.trim().length > 0 - }, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode]) + const edited = jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode + const canSave = !isEditing || edited + const hasUnsavedChanges = isEditing + ? edited + : jsonSchema.trim().length > 0 || functionCode.trim().length > 0 const handleCloseAttempt = () => { if (hasUnsavedChanges && !schemaGeneration.isStreaming && !codeGeneration.isStreaming) { @@ -438,7 +199,7 @@ try { return } - const { isValid, error } = validateSchema(jsonSchema) + const { isValid, error } = validateCustomToolSchema(jsonSchema) if (!isValid) { setSchemaError(error) setActiveSection('schema') @@ -509,9 +270,7 @@ try { const errorMessage = getErrorMessage(error, 'Failed to save custom tool') if (errorMessage.includes('Cannot change function name')) { - setSchemaError( - 'Function name cannot be changed after creation. To use a different name, delete this tool and create a new one.' - ) + setSchemaError(FUNCTION_NAME_LOCKED) } else { setSchemaError(errorMessage) } @@ -520,11 +279,10 @@ try { } const handleJsonSchemaChange = (value: string) => { - if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return setJsonSchema(value) if (value.trim()) { - const { error } = validateSchema(value) + const { error } = validateCustomToolSchema(value) setSchemaError(error) } else { setSchemaError(null) @@ -532,265 +290,8 @@ try { } const handleFunctionCodeChange = (value: string) => { - if (codeGeneration.isLoading || codeGeneration.isStreaming) { - setFunctionCode(value) - if (codeError) { - setCodeError(null) - } - return - } - setFunctionCode(value) - if (codeError) { - setCodeError(null) - } - - const textarea = codeEditorRef.current?.querySelector('textarea') - if (textarea) { - const pos = textarea.selectionStart - setCursorPosition(pos) - - const textBeforeCursor = value.substring(0, pos) - const lines = textBeforeCursor.split('\n') - const currentLine = lines.length - const currentCol = lines[lines.length - 1].length - - try { - if (codeEditorRef.current) { - const editorRect = codeEditorRef.current.getBoundingClientRect() - const lineHeight = 21 - - const top = currentLine * lineHeight + 5 - const left = Math.min(currentCol * 8, editorRect.width - 260) - - setDropdownPosition({ top, left }) - } - } catch (error) { - logger.error('Error calculating cursor position:', { error }) - } - - const envVarTrigger = checkEnvVarTrigger(value, pos) - setShowEnvVars(envVarTrigger.show && !codeGeneration.isStreaming) - setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '') - - const tagTrigger = checkTagTrigger(value, pos) - setShowTags(tagTrigger.show && !codeGeneration.isStreaming) - if (!tagTrigger.show) { - setActiveSourceBlockId(null) - } - - if (!codeGeneration.isStreaming && schemaParameters.length > 0) { - const schemaParamTrigger = checkSchemaParamTrigger(value, pos, schemaParameters) - if (schemaParamTrigger.show && !showSchemaParams) { - setShowSchemaParams(true) - setSchemaParamSelectedIndex(0) - } else if (!schemaParamTrigger.show && showSchemaParams) { - setShowSchemaParams(false) - } - } - } - } - - const checkSchemaParamTrigger = (text: string, cursorPos: number, parameters: any[]) => { - if (parameters.length === 0) return { show: false, searchTerm: '' } - - const beforeCursor = text.substring(0, cursorPos) - const words = beforeCursor.split(/[\s=();,{}[\]]+/) - const currentWord = words[words.length - 1] || '' - - if (currentWord.length > 0 && /^[a-zA-Z_][\w]*$/.test(currentWord)) { - const matchingParams = parameters.filter((param) => - param.name.toLowerCase().startsWith(currentWord.toLowerCase()) - ) - return { show: matchingParams.length > 0, searchTerm: currentWord, matches: matchingParams } - } - - return { show: false, searchTerm: '' } - } - - const handleEnvVarSelect = (newValue: string) => { - setFunctionCode(newValue) - setShowEnvVars(false) - } - - const handleTagSelect = (newValue: string) => { - setFunctionCode(newValue) - setShowTags(false) - setActiveSourceBlockId(null) - } - - const handleSchemaParamSelect = (paramName: string) => { - const textarea = codeEditorRef.current?.querySelector('textarea') - if (textarea) { - const pos = textarea.selectionStart - const beforeCursor = functionCode.substring(0, pos) - const afterCursor = functionCode.substring(pos) - - const words = beforeCursor.split(/[\s=();,{}[\]]+/) - const currentWord = words[words.length - 1] || '' - const wordStart = beforeCursor.lastIndexOf(currentWord) - - const newValue = beforeCursor.substring(0, wordStart) + paramName + afterCursor - setFunctionCode(newValue) - setShowSchemaParams(false) - - setTimeout(() => { - textarea.focus() - textarea.setSelectionRange(wordStart + paramName.length, wordStart + paramName.length) - }, 0) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - const isSchemaPromptVisible = activeSection === 'schema' && schemaGeneration.isPromptVisible - const isCodePromptVisible = activeSection === 'code' && codeGeneration.isPromptVisible - - if (e.key === 'Escape') { - if (isSchemaPromptVisible) { - schemaGeneration.hidePromptInline() - e.preventDefault() - e.stopPropagation() - return - } - if (isCodePromptVisible) { - codeGeneration.hidePromptInline() - e.preventDefault() - e.stopPropagation() - return - } - if (showEnvVars || showTags || showSchemaParams) { - setShowEnvVars(false) - setShowTags(false) - setShowSchemaParams(false) - e.preventDefault() - e.stopPropagation() - return - } - } - - if (activeSection === 'schema' && schemaGeneration.isStreaming) { - e.preventDefault() - return - } - if (activeSection === 'code' && codeGeneration.isStreaming) { - e.preventDefault() - return - } - - if (showSchemaParams && schemaParameters.length > 0) { - switch (e.key) { - case 'ArrowDown': - e.preventDefault() - e.stopPropagation() - setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1)) - return - case 'ArrowUp': - e.preventDefault() - e.stopPropagation() - setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0)) - return - case 'Enter': - e.preventDefault() - e.stopPropagation() - if (schemaParamSelectedIndex >= 0 && schemaParamSelectedIndex < schemaParameters.length) { - const selectedParam = schemaParameters[schemaParamSelectedIndex] - handleSchemaParamSelect(selectedParam.name) - } - return - case 'Escape': - e.preventDefault() - e.stopPropagation() - setShowSchemaParams(false) - return - case ' ': - case 'Tab': - setShowSchemaParams(false) - return - } - } - - if (showEnvVars || showTags) { - if (['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) { - e.preventDefault() - e.stopPropagation() - } - } - } - - const handleSchemaWandClick = () => { - if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return - setIsSchemaPromptActive(true) - setSchemaPromptInput('') - setTimeout(() => { - schemaPromptInputRef.current?.focus() - }, 0) - } - - const handleSchemaPromptBlur = () => { - if (!schemaPromptInput.trim() && !schemaGeneration.isStreaming) { - setIsSchemaPromptActive(false) - } - } - - const handleSchemaPromptChange = (value: string) => { - setSchemaPromptInput(value) - } - - const handleSchemaPromptSubmit = () => { - const trimmedPrompt = schemaPromptInput.trim() - if (!trimmedPrompt || schemaGeneration.isLoading || schemaGeneration.isStreaming) return - schemaGeneration.generateStream({ prompt: trimmedPrompt }) - setSchemaPromptInput('') - setIsSchemaPromptActive(false) - } - - const handleSchemaPromptKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSchemaPromptSubmit() - } else if (e.key === 'Escape') { - e.preventDefault() - setSchemaPromptInput('') - setIsSchemaPromptActive(false) - } - } - - const handleCodeWandClick = () => { - if (codeGeneration.isLoading || codeGeneration.isStreaming) return - setIsCodePromptActive(true) - setCodePromptInput('') - setTimeout(() => { - codePromptInputRef.current?.focus() - }, 0) - } - - const handleCodePromptBlur = () => { - if (!codePromptInput.trim() && !codeGeneration.isStreaming) { - setIsCodePromptActive(false) - } - } - - const handleCodePromptChange = (value: string) => { - setCodePromptInput(value) - } - - const handleCodePromptSubmit = () => { - const trimmedPrompt = codePromptInput.trim() - if (!trimmedPrompt || codeGeneration.isLoading || codeGeneration.isStreaming) return - codeGeneration.generateStream({ prompt: trimmedPrompt }) - setCodePromptInput('') - setIsCodePromptActive(false) - } - - const handleCodePromptKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleCodePromptSubmit() - } else if (e.key === 'Escape') { - e.preventDefault() - setCodePromptInput('') - setIsCodePromptActive(false) - } + if (codeError) setCodeError(null) } const handleDelete = async () => { @@ -805,16 +306,15 @@ try { }) logger.info(`Deleted tool: ${toolId}`) - if (onDelete) { - onDelete(toolId) - } + onDelete?.(toolId) handleClose() } catch (error) { logger.error('Error deleting custom tool:', error) - const errorMessage = getErrorMessage(error, 'Failed to delete custom tool') - setSchemaError(`${errorMessage}. Please try again.`) - setActiveSection('schema') + // A delete failure is not a schema problem — keep it out of the Schema slot. + toast.error("Couldn't delete tool", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) setShowDeleteConfirm(false) } } @@ -840,307 +340,47 @@ try { the caret as the body scrolls. */} - setActiveSection(value as ToolSection)} - /> - - {activeSection === 'schema' && ( -
-
-
- - {schemaError && ( -
- - {schemaError} -
- )} -
-
- {!isSchemaPromptActive ? ( - - ) : ( -
- handleSchemaPromptChange(e.target.value)} - onBlur={handleSchemaPromptBlur} - onKeyDown={handleSchemaPromptKeyDown} - disabled={schemaGeneration.isStreaming} - className={cn( - 'h-5 max-w-[200px] flex-1 text-xs', - schemaGeneration.isStreaming && 'text-muted-foreground' - )} - placeholder='Generate...' - /> - -
- )} -
-
- +
+ setActiveSection(value as ToolSection)} /> + {activeError && {activeError}}
+ {/* + Keyed by section so switching tabs resets the inline prompt — one + control drives both wands, and a half-typed Schema prompt must not + carry over and generate Code instead. + */} + activeGeneration.generateStream({ prompt })} + /> +
+ + {activeSection === 'schema' && ( + )} {activeSection === 'code' && ( -
-
-
- - {codeError && !codeGeneration.isStreaming && ( -
- - {codeError} -
- )} -
-
- {!isCodePromptActive ? ( - - ) : ( -
- handleCodePromptChange(e.target.value)} - onBlur={handleCodePromptBlur} - onKeyDown={handleCodePromptKeyDown} - disabled={codeGeneration.isStreaming} - className={cn( - 'h-5 max-w-[200px] flex-1 text-xs', - codeGeneration.isStreaming && 'text-muted-foreground' - )} - placeholder='Generate...' - /> - -
- )} -
-
- {schemaParameters.length > 0 && ( -
-
- - Available parameters: - - {schemaParameters.map((param) => ( - - {param.name} - - ))} - - Start typing a parameter name for autocomplete. - -
-
- )} -
- 0 ? '380px' : '420px'} - className={cn( - 'bg-[var(--bg)]', - codeError && !codeGeneration.isStreaming && 'border-[var(--text-error)]', - (codeGeneration.isLoading || codeGeneration.isStreaming) && - 'cursor-not-allowed opacity-50' - )} - gutterClassName='bg-[var(--bg)]' - highlightVariables={true} - disabled={codeGeneration.isLoading || codeGeneration.isStreaming} - onKeyDown={handleKeyDown} - schemaParameters={schemaParameters} - /> - - {showEnvVars && ( - { - setShowEnvVars(false) - setSearchTerm('') - }} - className='w-64' - style={{ - position: 'absolute', - top: `${dropdownPosition.top}px`, - left: `${dropdownPosition.left}px`, - }} - /> - )} - - {showTags && ( - { - setShowTags(false) - setActiveSourceBlockId(null) - }} - className='w-64' - style={{ - position: 'absolute', - top: `${dropdownPosition.top}px`, - left: `${dropdownPosition.left}px`, - }} - /> - )} - - {showSchemaParams && schemaParameters.length > 0 && ( - { - if (!open) { - setShowSchemaParams(false) - } - }} - colorScheme='inverted' - > - -
- - e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - > - - Available Parameters - {schemaParameters.map((param, index) => ( - setSchemaParamSelectedIndex(index)} - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - handleSchemaParamSelect(param.name) - }} - ref={(el) => { - if (el) { - schemaParamItemRefs.current.set(index, el) - } - }} - > - {param.name} - {param.type && param.type !== 'any' && ( - - {param.type} - - )} - - ))} - - - - )} -
-
+ )} @@ -1181,7 +421,7 @@ try { primaryAction={{ label: isEditing ? 'Update Tool' : 'Save Tool', onClick: handleSave, - disabled: !isSchemaValid || !!schemaError || !hasChanges, + disabled: !isSchemaValid || !!schemaError || !canSave, }} /> )} @@ -1192,13 +432,7 @@ try { onOpenChange={setShowDeleteConfirm} srTitle='Delete Custom Tool' title='Delete Custom Tool' - text={[ - { - text: 'This will permanently delete the tool and remove it from any workflows that are using it.', - error: true, - }, - ' This action cannot be undone.', - ]} + text={CUSTOM_TOOL_DELETE_CONFIRM_TEXT} confirm={{ label: 'Delete', onClick: handleDelete, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index 1ac9e042573..dc6bd65b33f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -136,17 +136,6 @@ export function useWand({ setError(null) }, []) - const openPrompt = useCallback(() => { - setIsPromptVisible(true) - setPromptInputValue('') - }, []) - - const closePrompt = useCallback(() => { - if (isLoading) return - setIsPromptVisible(false) - setPromptInputValue('') - }, [isLoading]) - const generateStream = useCallback( async ({ prompt }: { prompt: string }) => { if (!prompt) { @@ -294,8 +283,6 @@ export function useWand({ generateStream, showPromptInline, hidePromptInline, - openPrompt, - closePrompt, updatePromptValue, cancelGeneration, } diff --git a/apps/sim/components/permissions/add-people-modal.tsx b/apps/sim/components/permissions/add-people-modal.tsx new file mode 100644 index 00000000000..dbfe6d129d6 --- /dev/null +++ b/apps/sim/components/permissions/add-people-modal.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { partitionSettledFailures, resolveAddEmail } from '@/lib/workspaces/sharing' +import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { MEMBER_ROLE_OPTIONS, type MemberRole } from './member-role-options' + +const logger = createLogger('AddPeopleModal') + +export interface AddPeopleTarget { + email: string + userId: string +} + +interface AddPeopleModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Lowercased emails that already have access — rejected as duplicates. */ + existingMemberEmails: Set + /** Grants one person the role; a rejection surfaces as a partial failure. */ + addMember: (target: AddPeopleTarget, role: MemberRole) => Promise + /** + * Hides the Role field for resources without per-member roles (skills): + * every add is a plain grant and `addMember` receives the default role. + */ + hideRole?: boolean +} + +/** + * Shared "Add people" modal for member-managed resources (credentials, skills): + * grants existing workspace members access, optionally with a chosen role. + * Emails are validated against the workspace roster and current membership; + * each add is an idempotent upsert and partial failures keep only the people + * that still need adding. + */ +export function AddPeopleModal({ + open, + onOpenChange, + existingMemberEmails, + addMember, + hideRole = false, +}: AddPeopleModalProps) { + const { workspacePermissions } = useWorkspacePermissionsContext() + + const [emailsToAdd, setEmailsToAdd] = useState([]) + const [roleToAdd, setRoleToAdd] = useState('member') + const [isAdding, setIsAdding] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const workspaceUserIdByEmail = useMemo( + () => + new Map( + (workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId]) + ), + [workspacePermissions?.users] + ) + + const validateAddEmail = useCallback( + (email: string): string | null => { + const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails }) + return 'error' in result ? result.error : null + }, + [workspaceUserIdByEmail, existingMemberEmails] + ) + + const handleClose = useCallback(() => { + setEmailsToAdd([]) + setRoleToAdd('member') + setSubmitError(null) + onOpenChange(false) + }, [onOpenChange]) + + const handleAddPeople = useCallback(async () => { + if (emailsToAdd.length === 0 || isAdding) return + setSubmitError(null) + const targets = emailsToAdd + .map((email) => { + const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails }) + return 'userId' in result ? { email, userId: result.userId } : null + }) + .filter((target): target is AddPeopleTarget => target !== null) + if (targets.length === 0) return + + setIsAdding(true) + try { + const results = await Promise.allSettled( + targets.map((target) => addMember(target, roleToAdd)) + ) + const failures = partitionSettledFailures(targets, results) + if (failures.length === 0) { + handleClose() + return + } + setEmailsToAdd(failures.map((target) => target.email)) + const firstError = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + logger.error('Failed to add some members', firstError?.reason) + const reason = getErrorMessage(firstError?.reason, 'Please try again in a moment.') + setSubmitError( + failures.length === targets.length + ? `Couldn't add people. ${reason}` + : `Couldn't add ${failures.length} of ${targets.length} people. ${reason}` + ) + } finally { + setIsAdding(false) + } + }, [ + emailsToAdd, + isAdding, + workspaceUserIdByEmail, + existingMemberEmails, + roleToAdd, + addMember, + handleClose, + ]) + + return ( + { + if (!next) handleClose() + }} + srTitle='Add people' + > + Add people + + + {!hideRole && ( + setRoleToAdd(role as MemberRole)} + disabled={isAdding} + /> + )} + {submitError} + + + + ) +} diff --git a/apps/sim/components/permissions/index.ts b/apps/sim/components/permissions/index.ts index 69aff65d947..6fd107d31c1 100644 --- a/apps/sim/components/permissions/index.ts +++ b/apps/sim/components/permissions/index.ts @@ -1,3 +1,12 @@ +export { AddPeopleModal, type AddPeopleTarget } from './add-people-modal' +export { + MEMBER_ROLE_OPTIONS, + type MemberRole, + type MemberRoleOption, + SKILL_EDITOR_ROLE_OPTIONS, + type SkillEditorRole, +} from './member-role-options' +export { MemberRow, type MemberRowMember } from './member-row' export { type OrgRole, OrgRoleSelector, @@ -8,6 +17,7 @@ export { type CredentialRoleSource, credentialRoleLockReason, RoleLockTooltip, + skillEditorLockReason, type WorkspaceRoleSource, workspaceRoleLockReason, } from './role-lock' diff --git a/apps/sim/components/permissions/member-role-options.ts b/apps/sim/components/permissions/member-role-options.ts new file mode 100644 index 00000000000..321f91149e3 --- /dev/null +++ b/apps/sim/components/permissions/member-role-options.ts @@ -0,0 +1,28 @@ +/** Role assignable to a member of a shared resource (credential, skill). */ +export type MemberRole = 'member' | 'admin' + +export interface MemberRoleOption { + value: MemberRole + label: string +} + +/** + * Roles assignable to a resource member. Shared by every member-management + * surface (credential detail, skill members) so role choices never drift. + */ +export const MEMBER_ROLE_OPTIONS: readonly MemberRoleOption[] = [ + { value: 'member', label: 'Member' }, + { value: 'admin', label: 'Admin' }, +] as const + +export type SkillEditorRole = 'editor' + +/** + * Skill membership is binary — a user either edits the skill or does not, and + * `skill_member` has no role column. The single option keeps the roster's role + * control identical in shape to the credential surface's while being honest + * that there is nothing to switch between; every skill row renders it disabled. + */ +export const SKILL_EDITOR_ROLE_OPTIONS: readonly { value: SkillEditorRole; label: string }[] = [ + { value: 'editor', label: 'Editor' }, +] as const diff --git a/apps/sim/components/permissions/member-row.tsx b/apps/sim/components/permissions/member-row.tsx new file mode 100644 index 00000000000..8a235584204 --- /dev/null +++ b/apps/sim/components/permissions/member-row.tsx @@ -0,0 +1,90 @@ +'use client' + +import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn' +import { getUserColor } from '@/lib/workspaces/colors' +import type { MemberRole } from './member-role-options' +import { RoleLockTooltip } from './role-lock' + +export interface MemberRowMember { + userId: string + userName: string | null + userEmail: string | null + role: TRole +} + +interface MemberRowProps { + member: MemberRowMember + /** Why the role is fixed (derived access); null when editable. */ + lockReason: string | null + /** Whether the viewer can act on rows (shows the Remove column). */ + canManage: boolean + roleDisabled: boolean + removeDisabled: boolean + /** + * Role choices for the dropdown. A surface whose membership is binary + * (skills) passes its own single option and always sets `roleDisabled`. + */ + roleOptions: readonly { value: TRole; label: string }[] + /** Omitted on surfaces with nothing to switch between (the role stays disabled). */ + onRoleChange?: (role: TRole) => void + onRemove: () => void +} + +/** + * One member row of a shared-resource member list: avatar + identity, a role + * dropdown (wrapped in a lock tooltip when the role is derived), and a remove + * action for managers. Consumers own the policy (who is locked/disabled); this + * row owns the chrome. + */ +export function MemberRow({ + member, + lockReason, + canManage, + roleDisabled, + removeDisabled, + roleOptions, + onRoleChange, + onRemove, +}: MemberRowProps) { + return ( +
+
+ + + {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()} + + +
+ + {member.userName || member.userEmail || member.userId} + + + {member.userEmail || member.userId} + +
+
+ + onRoleChange?.(role as TRole)} + /> + + {canManage && ( + + Remove + + )} +
+ ) +} diff --git a/apps/sim/components/permissions/role-lock.tsx b/apps/sim/components/permissions/role-lock.tsx index 6988b1200c4..db384516930 100644 --- a/apps/sim/components/permissions/role-lock.tsx +++ b/apps/sim/components/permissions/role-lock.tsx @@ -31,6 +31,15 @@ export function credentialRoleLockReason( return null } +/** + * Explanation shown when a skill editor's access is inherited from their + * workspace admin role rather than an explicit grant, and so cannot be removed. + * Returns null for explicitly added editors. + */ +export function skillEditorLockReason(isWorkspaceAdmin: boolean): string | null { + return isWorkspaceAdmin ? 'Workspace admins are automatically skill editors' : null +} + interface RoleLockTooltipProps { reason: string | null children: ReactNode diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index d3863d3fb6b..e7413a06236 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -545,14 +545,26 @@ describe('copyForkResourceContainers external MCP server copy', () => { }) describe('copyForkResourceContainers skill copy', () => { - function makeSkillTx(rows: Array>) { + /** Sequential tx mock: each select resolves the next queued row set (skill rows, then member rows). */ + function makeSkillTx(selects: Array>>) { + let call = 0 const inserted: Array> = [] const tx = { - select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }), + select: () => { + const result = Promise.resolve(selects[call++] ?? []) + const chain = { + from: () => chain, + innerJoin: () => chain, + where: () => result, + } + return chain + }, insert: () => ({ values: (values: Array>) => { inserted.push(...values) - return Promise.resolve() + return Object.assign(Promise.resolve(), { + onConflictDoNothing: () => Promise.resolve(), + }) }, }), } @@ -568,20 +580,20 @@ describe('copyForkResourceContainers skill copy', () => { knowledgeBases: [], } + const sourceSkillRow = { + id: 'sk-1', + name: 'My Skill', + description: 'desc', + workspaceId: 'src-ws', + userId: 'src-user', + createdAt: new Date(), + updatedAt: new Date(), + } + it('copies the skill body IN-DB and carries only the child id in the content plan', async () => { // The source projection deliberately omits `content` (it is copied server-side), so the row // fed to the tx mock has none - the body must never be materialized in app memory here. - const { tx, inserted } = makeSkillTx([ - { - id: 'sk-1', - name: 'My Skill', - description: 'desc', - workspaceId: 'src-ws', - userId: 'src-user', - createdAt: new Date(), - updatedAt: new Date(), - }, - ]) + const { tx, inserted } = makeSkillTx([[sourceSkillRow], []]) const result = await copyForkResourceContainers({ tx, @@ -604,6 +616,37 @@ describe('copyForkResourceContainers skill copy', () => { expect(result.contentPlan.skills).toEqual([{ childId }]) expect(result.names.skills).toEqual(['My Skill']) }) + + it('copies editor grants onto the child skill for users in the target roster', async () => { + // The editor query joins the child-workspace permissions in-DB, so the + // mock's second row set already represents source editors ∩ target roster. + const { tx, inserted } = makeSkillTx([ + [sourceSkillRow], + [ + { skillId: 'sk-1', userId: 'editor-1' }, + { skillId: 'sk-1', userId: 'editor-2' }, + ], + ]) + + await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: skillSelection, + workflowIdMap: new Map(), + }) + + const childSkill = inserted[0] + const firstGrant = inserted[1] + expect(firstGrant.skillId).toBe(childSkill.id) + expect(firstGrant.userId).toBe('editor-1') + + const secondGrant = inserted[2] + expect(secondGrant.skillId).toBe(childSkill.id) + expect(secondGrant.userId).toBe('editor-2') + }) }) describe('copyForkResourceContainers knowledge-base tag definitions', () => { diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index 2c7eda693b5..63c32d56549 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -6,7 +6,9 @@ import { knowledgeBase, knowledgeBaseTagDefinitions, mcpServers, + permissions, skill, + skillMember, userTableDefinitions, userTableRows, workflowMcpServer, @@ -335,6 +337,7 @@ export async function copyForkResourceContainers( .from(skill) .where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId))) const inserts: SkillSkeletonInsert[] = [] + const childSkillIdBySource = new Map() for (const row of rows) { const childId = generateId() inserts.push({ @@ -348,11 +351,52 @@ export async function copyForkResourceContainers( createdAt: now, updatedAt: now, }) + childSkillIdBySource.set(row.id, childId) record('skill', row.id, childId) contentPlan.skills.push({ childId }) names.skills.push(row.name) } - if (inserts.length > 0) await tx.insert(skill).values(inserts) + if (inserts.length > 0) { + await tx.insert(skill).values(inserts) + + // Copy editor grants for users who are members of the child workspace. + // Workspace admins need no rows — they are derived editors in the child + // too (mirrors credential member propagation otherwise). + const memberRows = await tx + .select({ + skillId: skillMember.skillId, + userId: skillMember.userId, + }) + .from(skillMember) + .innerJoin( + permissions, + and( + eq(permissions.userId, skillMember.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, childWorkspaceId) + ) + ) + .where(inArray(skillMember.skillId, Array.from(childSkillIdBySource.keys()))) + const memberInserts = memberRows.flatMap((member) => { + const childSkillId = childSkillIdBySource.get(member.skillId) + if (!childSkillId) return [] + return [ + { + id: generateId(), + skillId: childSkillId, + userId: member.userId, + createdAt: now, + updatedAt: now, + }, + ] + }) + if (memberInserts.length > 0) { + await tx + .insert(skillMember) + .values(memberInserts) + .onConflictDoNothing({ target: [skillMember.skillId, skillMember.userId] }) + } + } } if (selection.mcpServers.length > 0) { diff --git a/apps/sim/executor/handlers/agent/skills-resolver.test.ts b/apps/sim/executor/handlers/agent/skills-resolver.test.ts index fb9103da2bc..bfc68245b28 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.test.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.test.ts @@ -3,20 +3,25 @@ */ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { resolveSkillContent } from './skills-resolver' +import { + resolveSkillContent, + resolveSkillContentById, + resolveSkillMetadata, +} from './skills-resolver' -// resolveSkillContent is the shared resolver invoked when a workflow agent block -// calls load_skill. -describe('resolveSkillContent', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) +// resolveSkillContent is the shared resolver invoked when a workflow agent +// block calls load_skill. Skill editors gate editing only — resolution never +// blocks on the acting user. +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() +}) - afterAll(() => { - resetDbChainMock() - }) +afterAll(() => { + resetDbChainMock() +}) +describe('resolveSkillContent', () => { it('returns null without a skill name or workspace', async () => { expect(await resolveSkillContent('', 'ws-1')).toBeNull() expect(await resolveSkillContent('x', '')).toBeNull() @@ -29,7 +34,7 @@ describe('resolveSkillContent', () => { }) it('resolves a workspace user skill by name', async () => { - queueTableRows(schemaMock.skill, [{ content: '# Playbook', name: 'posthog-playbook' }]) + queueTableRows(schemaMock.skill, [{ content: '# Playbook' }]) expect(await resolveSkillContent('posthog-playbook', 'ws-1')).toBe('# Playbook') }) @@ -37,3 +42,30 @@ describe('resolveSkillContent', () => { expect(await resolveSkillContent('missing', 'ws-1')).toBeNull() }) }) + +describe('resolveSkillContentById', () => { + it('resolves a workspace skill by id', async () => { + queueTableRows(schemaMock.skill, [{ content: '# Body', name: 'my-skill' }]) + expect(await resolveSkillContentById('sk-1', 'ws-1')).toEqual({ + name: 'my-skill', + content: '# Body', + }) + }) + + it('returns null when the skill does not exist in the workspace', async () => { + expect(await resolveSkillContentById('missing', 'ws-1')).toBeNull() + }) +}) + +describe('resolveSkillMetadata', () => { + it('returns every attached skill in the workspace', async () => { + queueTableRows(schemaMock.skill, [ + { id: 'sk-1', name: 'a', description: 'A' }, + { id: 'sk-2', name: 'b', description: 'B' }, + ]) + + const metadata = await resolveSkillMetadata([{ skillId: 'sk-1' }, { skillId: 'sk-2' }], 'ws-1') + + expect(metadata.map((m) => m.name)).toEqual(['a', 'b']) + }) +}) diff --git a/apps/sim/executor/handlers/agent/skills-resolver.ts b/apps/sim/executor/handlers/agent/skills-resolver.ts index b5a145562ee..b14024ec570 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.ts @@ -45,11 +45,11 @@ export async function resolveSkillMetadata( try { const rows = await db - .select({ name: skill.name, description: skill.description }) + .select({ id: skill.id, name: skill.name, description: skill.description }) .from(skill) .where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, dbSkillIds))) - return [...metadata, ...rows] + return [...metadata, ...rows.map((row) => ({ name: row.name, description: row.description }))] } catch (error) { logger.error('Failed to resolve skill metadata', { error, dbSkillIds, workspaceId }) return metadata @@ -71,7 +71,7 @@ export async function resolveSkillContent( try { const rows = await db - .select({ content: skill.content, name: skill.name }) + .select({ content: skill.content }) .from(skill) .where(and(eq(skill.workspaceId, workspaceId), eq(skill.name, skillName))) .limit(1) @@ -109,7 +109,7 @@ export async function resolveSkillContentById( return null } - return rows[0] + return { name: rows[0].name, content: rows[0].content } } catch (error) { logger.error('Failed to resolve skill content', { error, skillId, workspaceId }) return null diff --git a/apps/sim/executor/handlers/pi/context.ts b/apps/sim/executor/handlers/pi/context.ts index dfabfd67a5c..b6e41e74e50 100644 --- a/apps/sim/executor/handlers/pi/context.ts +++ b/apps/sim/executor/handlers/pi/context.ts @@ -26,7 +26,9 @@ function isMemoryEnabled(config: PiMemoryConfig): boolean { return !!config.memoryType && config.memoryType !== 'none' } -/** Resolves selected skill inputs to full `{ name, content }` entries for Pi. */ +/** + * Resolves selected skill inputs to full `{ name, content }` entries for Pi. + */ export async function resolvePiSkills( skillInputs: unknown, workspaceId: string | undefined @@ -36,15 +38,8 @@ export async function resolvePiSkills( const skills: PiSkill[] = [] for (const input of skillInputs as SkillInput[]) { if (!input?.skillId) continue - try { - const resolved = await resolveSkillContentById(input.skillId, workspaceId) - if (resolved) skills.push({ name: resolved.name, content: resolved.content }) - } catch (error) { - logger.warn('Failed to resolve skill for Pi', { - skillId: input.skillId, - error: getErrorMessage(error), - }) - } + const resolved = await resolveSkillContentById(input.skillId, workspaceId) + if (resolved) skills.push({ name: resolved.name, content: resolved.content }) } return skills } diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index a46637c70bd..ebbaa2660d7 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -135,7 +135,6 @@ export class PiBlockHandler implements BlockHandler { } return this.runPi(ctx, block, runCloudReviewPi, params) } - const memoryConfig: PiMemoryConfig = { memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'], conversationId: asOptString(inputs.conversationId), diff --git a/apps/sim/hooks/queries/skills.ts b/apps/sim/hooks/queries/skills.ts index e2bd5a1265f..6817f6b6249 100644 --- a/apps/sim/hooks/queries/skills.ts +++ b/apps/sim/hooks/queries/skills.ts @@ -3,14 +3,19 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import { requestJson } from '@/lib/api/client/request' import { deleteSkillContract, + listSkillMembersContract, listSkillsContract, + removeSkillMemberContract, type Skill, + type SkillEditor, + upsertSkillMemberContract, upsertSkillsContract, } from '@/lib/api/contracts' const logger = createLogger('SkillsQueries') export const SKILL_LIST_STALE_TIME = 60 * 1000 +export const SKILL_MEMBER_LIST_STALE_TIME = 30 * 1000 export type SkillDefinition = Skill @@ -21,6 +26,7 @@ export const skillsKeys = { all: ['skills'] as const, lists: () => [...skillsKeys.all, 'list'] as const, list: (workspaceId: string) => [...skillsKeys.lists(), workspaceId] as const, + members: (skillId?: string) => [...skillsKeys.all, 'members', skillId ?? ''] as const, } /** @@ -70,7 +76,13 @@ export function useCreateSkill() { const { data } = await requestJson(upsertSkillsContract, { body: { - skills: [{ name: s.name, description: s.description, content: s.content }], + skills: [ + { + name: s.name, + description: s.description, + content: s.content, + }, + ], workspaceId, }, }) @@ -114,25 +126,11 @@ export function useUpdateSkill() { mutationFn: async ({ workspaceId, skillId, updates }: UpdateSkillParams) => { logger.info(`Updating skill: ${skillId} in workspace ${workspaceId}`) - const currentSkills = queryClient.getQueryData( - skillsKeys.list(workspaceId) - ) - const currentSkill = currentSkills?.find((s) => s.id === skillId) - - if (!currentSkill) { - throw new Error('Skill not found') - } - + // Updates are partial on the wire — omitted fields are preserved + // server-side, so nothing is re-sent from a possibly-stale cache. const { data } = await requestJson(upsertSkillsContract, { body: { - skills: [ - { - id: skillId, - name: updates.name ?? currentSkill.name, - description: updates.description ?? currentSkill.description, - content: updates.content ?? currentSkill.content, - }, - ], + skills: [{ id: skillId, ...updates }], workspaceId, }, }) @@ -172,6 +170,7 @@ export function useUpdateSkill() { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: skillsKeys.members(variables.skillId) }) }, }) } @@ -224,3 +223,88 @@ export function useDeleteSkill() { }, }) } + +/** + * Fetch the editor roster for a skill (explicit editors plus derived workspace + * admins). Built-in skills have no editors — callers should not enable this + * for readOnly skills. + */ +export function useSkillMembers(skillId?: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: skillsKeys.members(skillId), + queryFn: async ({ signal }) => { + if (!skillId) return [] + const data = await requestJson(listSkillMembersContract, { + params: { id: skillId }, + signal, + }) + return data.editors + }, + enabled: Boolean(skillId) && (options?.enabled ?? true), + staleTime: SKILL_MEMBER_LIST_STALE_TIME, + }) +} + +interface UpsertSkillMemberParams { + skillId: string + workspaceId: string + userId: string +} + +export function useUpsertSkillMember() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ skillId, userId }: UpsertSkillMemberParams) => { + return requestJson(upsertSkillMemberContract, { + params: { id: skillId }, + body: { userId }, + }) + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: skillsKeys.members(variables.skillId) }) + queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) + }, + }) +} + +interface RemoveSkillMemberParams { + skillId: string + workspaceId: string + userId: string +} + +export function useRemoveSkillMember() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ skillId, userId }: RemoveSkillMemberParams) => { + return requestJson(removeSkillMemberContract, { + params: { id: skillId }, + query: { userId }, + }) + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey: skillsKeys.members(variables.skillId) }) + const previousEditors = queryClient.getQueryData( + skillsKeys.members(variables.skillId) + ) + if (previousEditors) { + queryClient.setQueryData( + skillsKeys.members(variables.skillId), + previousEditors.filter((editor) => editor.userId !== variables.userId) + ) + } + return { previousEditors } + }, + onError: (_err, variables, context) => { + if (context?.previousEditors) { + queryClient.setQueryData(skillsKeys.members(variables.skillId), context.previousEditors) + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: skillsKeys.members(variables.skillId) }) + queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) + }, + }) +} diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 950acf782e1..31af51cd489 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -8,6 +8,11 @@ export const skillSchema = z.object({ name: z.string(), description: z.string(), content: z.string(), + /** + * Whether the caller can edit, delete, and share the skill (explicit editor + * or derived workspace admin). Always false for built-in template skills. + */ + canEdit: z.boolean(), createdAt: z.string(), updatedAt: z.string(), /** True for built-in template skills, which are read-only and not stored in the DB. */ @@ -16,17 +21,57 @@ export const skillSchema = z.object({ export type Skill = z.output -export const skillUpsertItemSchema = z.object({ - id: z.string().optional(), - name: z - .string() - .min(1, 'Skill name is required') - .max(64) - .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)'), - description: z.string().min(1, 'Description is required').max(1024), - content: z.string().min(1, 'Content is required').max(50_000, 'Content is too large'), +/** + * One entry of a skill's editor roster: a workspace admin (derived, always an + * editor) or an explicitly added editor. + */ +export const skillEditorSchema = z.object({ + id: z.string(), + userId: z.string(), + userName: z.string().nullable(), + userEmail: z.string().nullable(), + userImage: z.string().nullable().optional(), + isWorkspaceAdmin: z.boolean(), }) +export type SkillEditor = z.output + +const skillNameSchema = z + .string() + .min(1, 'Skill name is required') + .max(64) + .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Name must be kebab-case (e.g. my-skill)') +const skillDescriptionSchema = z.string().min(1, 'Description is required').max(1024) +const skillContentSchema = z + .string() + .min(1, 'Content is required') + .max(50_000, 'Content is too large') + +/** + * One skill in an upsert. Creates (no `id`) require name/description/content; + * updates (`id` set) are partial — omitted fields keep their current values + * server-side, so a partial edit can never clobber a concurrent content edit. + */ +export const skillUpsertItemSchema = z + .object({ + id: z.string().min(1).optional(), + name: skillNameSchema.optional(), + description: skillDescriptionSchema.optional(), + content: skillContentSchema.optional(), + }) + .superRefine((item, ctx) => { + if (item.id) return + if (item.name === undefined) { + ctx.addIssue({ code: 'custom', path: ['name'], message: 'Skill name is required' }) + } + if (item.description === undefined) { + ctx.addIssue({ code: 'custom', path: ['description'], message: 'Description is required' }) + } + if (item.content === undefined) { + ctx.addIssue({ code: 'custom', path: ['content'], message: 'Content is required' }) + } + }) + export const listSkillsQuerySchema = z.object({ workspaceId: z.string().min(1), }) @@ -43,10 +88,6 @@ export const deleteSkillQuerySchema = z.object({ source: z.enum(['settings', 'tool_input']).optional(), }) -export const importSkillBodySchema = z.object({ - url: z.string().url('A valid URL is required'), -}) - export const listSkillsContract = defineRouteContract({ method: 'GET', path: '/api/skills', @@ -84,14 +125,54 @@ export const deleteSkillContract = defineRouteContract({ }, }) -export const importSkillContract = defineRouteContract({ +export const skillIdParamsSchema = z.object({ + id: z.string().min(1, 'Skill id is required'), +}) + +export const upsertSkillMemberBodySchema = z.object({ + userId: z.string().min(1, 'User id is required'), +}) + +export type UpsertSkillMemberBody = z.input + +export const removeSkillMemberQuerySchema = z.object({ + userId: z.string().min(1, 'User id is required'), +}) + +export const listSkillMembersContract = defineRouteContract({ + method: 'GET', + path: '/api/skills/[id]/members', + params: skillIdParamsSchema, + response: { + mode: 'json', + schema: z.object({ + editors: z.array(skillEditorSchema), + }), + }, +}) + +export const upsertSkillMemberContract = defineRouteContract({ method: 'POST', - path: '/api/skills/import', - body: importSkillBodySchema, + path: '/api/skills/[id]/members', + params: skillIdParamsSchema, + body: upsertSkillMemberBodySchema, response: { mode: 'json', schema: z.object({ - content: z.string(), + success: z.literal(true), + }), + }, +}) + +export const removeSkillMemberContract = defineRouteContract({ + method: 'DELETE', + path: '/api/skills/[id]/members', + params: skillIdParamsSchema, + query: removeSkillMemberQuerySchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), }), }, }) diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 7d2955ef587..3cb64bedefc 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -47,6 +47,7 @@ import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import type { DbOrTx } from '@/lib/db/types' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, WorkspaceBillingAccountRemovalError, @@ -1470,6 +1471,7 @@ export async function transferUserBetweenOrganizations( workspaceIds, params.userId ) + await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, params.userId) } const [stats] = await tx @@ -1721,6 +1723,7 @@ export async function removeUserFromOrganization( workspaceIds, userId ) + await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, userId) const capturedUsage = await captureDepartedUsage() return { @@ -1935,6 +1938,7 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: { workspaceIds, userId ) + await removeWorkspaceSkillMembershipsTx(tx, workspaceIds, userId) return { workspaceAccessRevoked: deletedPermissions.length, diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 11ee6ac3702..5276e3257f6 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -26,11 +26,11 @@ import { 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 { listSkillsForUser } from '@/lib/workflows/skills/operations' import { assertActiveWorkspaceAccess, getUsersWithPermissions, - getWorkspaceWithOwner, + type WorkspaceAccess, } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceContext') @@ -334,11 +334,17 @@ export function buildWorkspaceContextMd(data: WorkspaceMdData): string { // workspace is unavailable or a fetch fails. async function buildWorkspaceMdData( workspaceId: string, - userId: string + userId: string, + options?: { workspaceAccess?: WorkspaceAccess } ): Promise { try { - await assertActiveWorkspaceAccess(workspaceId, userId) - const wsRow = await getWorkspaceWithOwner(workspaceId) + // Reuse the caller's already-asserted access when provided (hot chat path); + // the id match keeps a mismatched cache from authorizing this workspace. + const workspaceAccess = + options?.workspaceAccess && options.workspaceAccess.workspace?.id === workspaceId + ? options.workspaceAccess + : await assertActiveWorkspaceAccess(workspaceId, userId) + const wsRow = workspaceAccess.hasAccess ? workspaceAccess.workspace : null if (!wsRow) { return null } @@ -421,7 +427,7 @@ async function buildWorkspaceMdData( .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))), - listSkills({ workspaceId, includeBuiltins: false }), + listSkillsForUser({ workspaceId, userId, includeBuiltins: false, workspaceAccess }), db .select({ @@ -555,9 +561,10 @@ const WORKSPACE_CONTEXT_UNAVAILABLE_MD = */ export async function generateWorkspaceContext( workspaceId: string, - userId: string + userId: string, + options?: { workspaceAccess?: WorkspaceAccess } ): Promise { - const data = await buildWorkspaceMdData(workspaceId, userId) + const data = await buildWorkspaceMdData(workspaceId, userId, options) return data ? buildWorkspaceMd(data) : WORKSPACE_CONTEXT_UNAVAILABLE_MD } diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts index dc551e382e3..e3c1c7bfebc 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -3,7 +3,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { captureServerEvent } from '@/lib/posthog/server' -import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' +import { getSkillActorContext } from '@/lib/skills/access' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' +import { deleteSkill, listSkillsForUser, upsertSkills } from '@/lib/workflows/skills/operations' const logger = createLogger('CopilotToolExecutor') @@ -33,22 +35,23 @@ export async function executeManageSkill( return { success: false, error: 'workspaceId is required' } } - const writeOps: string[] = ['add', 'edit', 'delete'] + // Workspace write gates only creation; edits and deletes are gated per skill + // below (skill editor — explicit editor row or derived workspace admin). if ( - writeOps.includes(operation) && + operation === 'add' && context.userPermission && context.userPermission !== 'write' && context.userPermission !== 'admin' ) { return { success: false, - error: `Permission denied: '${operation}' on manage_skill requires write access. You have '${context.userPermission}' permission.`, + error: `Permission denied: 'add' on manage_skill requires write access. You have '${context.userPermission}' permission.`, } } try { if (operation === 'list') { - const skills = await listSkills({ workspaceId }) + const skills = await listSkillsForUser({ workspaceId, userId: context.userId }) return { success: true, @@ -128,26 +131,36 @@ export async function executeManageSkill( } } - const existing = await listSkills({ workspaceId }) - const found = existing.find((s) => s.id === params.skillId) - if (!found) { + if (isBuiltinSkillId(params.skillId)) { + return { success: false, error: 'Built-in skills are read-only and cannot be modified' } + } + + const actor = await getSkillActorContext(params.skillId, context.userId) + if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { return { success: false, error: `Skill not found: ${params.skillId}` } } + if (!actor.canEdit) { + return { + success: false, + error: `Permission denied: editing skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, + } + } + // Partial update: omitted fields keep their current values server-side. await upsertSkills({ skills: [ { id: params.skillId, - name: params.name || found.name, - description: params.description || found.description, - content: params.content || found.content, + ...(params.name ? { name: params.name } : {}), + ...(params.description ? { description: params.description } : {}), + ...(params.content ? { content: params.content } : {}), }, ], workspaceId, userId: context.userId, }) - const updatedName = params.name || found.name + const updatedName = params.name || actor.skill.name recordAudit({ workspaceId, actorId: context.userId, @@ -176,8 +189,8 @@ export async function executeManageSkill( success: true, operation, skillId: params.skillId, - name: params.name || found.name, - message: `Updated skill "${params.name || found.name}"`, + name: updatedName, + message: `Updated skill "${updatedName}"`, }, } } @@ -187,6 +200,19 @@ export async function executeManageSkill( return { success: false, error: "'skillId' is required for 'delete'" } } + if (!isBuiltinSkillId(params.skillId)) { + const actor = await getSkillActorContext(params.skillId, context.userId) + if (!actor.skill || actor.skill.workspaceId !== workspaceId || !actor.hasWorkspaceAccess) { + return { success: false, error: `Skill not found: ${params.skillId}` } + } + if (!actor.canEdit) { + return { + success: false, + error: `Permission denied: deleting skill "${actor.skill.name}" requires skill editor access. Ask a skill editor to add you.`, + } + } + } + const deleted = await deleteSkill({ skillId: params.skillId, workspaceId }) if (!deleted) { return { success: false, error: `Skill not found: ${params.skillId}` } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index cf3d48816e8..eeb6516f9f5 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -2154,9 +2154,11 @@ export class WorkspaceVFS { } /** - * Advertise workspace skills in the VFS without eagerly loading their bodies. - * Paths are registered as lazy so glob/WORKSPACE.md see them, but full content - * is fetched only when read (or a grep whose scope touches the path) resolves them. + * Advertise the workspace skills in the VFS without eagerly loading their + * bodies. Paths are registered as lazy so glob/WORKSPACE.md see them, but + * full content is fetched only when read (or a grep whose scope touches the + * path) resolves them. Skills are workspace-visible — everyone with + * workspace access sees and uses every skill. */ private async materializeSkills( workspaceId: string diff --git a/apps/sim/lib/credentials/access.test.ts b/apps/sim/lib/credentials/access.test.ts index 0fc2c8dc2f8..b5ae09c1418 100644 --- a/apps/sim/lib/credentials/access.test.ts +++ b/apps/sim/lib/credentials/access.test.ts @@ -8,6 +8,11 @@ const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({ vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, + resolveWorkspaceAccess: vi.fn(async (workspaceId: string, userId: string, provided?: any) => + provided && provided.workspace?.id === workspaceId + ? provided + : mockCheckWorkspaceAccess(workspaceId, userId) + ), })) import { getCredentialActorContext } from '@/lib/credentials/access' diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 1fd5728982a..005a47fe65d 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { credential, credentialMember, credentialTypeEnum } from '@sim/db/schema' import { and, eq, inArray } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { resolveWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect @@ -74,11 +74,11 @@ export async function getCredentialActorContext( } } - const providedAccess = options?.workspaceAccess - const workspaceAccess = - providedAccess && providedAccess.workspace?.id === credentialRow.workspaceId - ? providedAccess - : await checkWorkspaceAccess(credentialRow.workspaceId, userId) + const workspaceAccess = await resolveWorkspaceAccess( + credentialRow.workspaceId, + userId, + options?.workspaceAccess + ) const [memberRow] = await db .select() .from(credentialMember) @@ -111,6 +111,13 @@ export async function getCredentialActorContext( * Workspace owners and admins are derived credential admins, so no per-credential * owner promotion is needed to avoid orphaning a credential. Returns the number * of memberships revoked. + * + * Deliberately DIVERGES from the skill sibling (`removeWorkspaceSkillMembershipsTx` + * deletes active rows): credential access is explicit-rows-only, so a revoked + * row here is an inert tombstone the env-credential join sync uses to avoid + * resurrecting access. Skills have an implicit workspace-shared grant, where a + * revoked row is a live per-skill DENY marker and a kept-active row would + * re-grant on rejoin — do not "harmonize" either helper toward the other. */ export async function revokeWorkspaceCredentialMembershipsTx( tx: DbOrTx, diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index b04b5655fe4..21fcc48c286 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -25,7 +25,7 @@ import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' const logger = createLogger('InboxExecutor') @@ -210,21 +210,16 @@ export async function executeInboxTask(taskId: string): Promise { return { attachments, ...downloaded } } - const [ - attachmentResult, - workspaceContext, - integrationTools, - userPermission, - billingAttribution, - entitlements, - ] = await Promise.all([ - fetchAttachments(), - generateWorkspaceContext(ws.id, userId), - buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), - resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), - computeWorkspaceEntitlements(ws.id, userId), - ]) + const workspaceAccess = await checkWorkspaceAccess(ws.id, userId) + const userPermission = workspaceAccess.permission + const [attachmentResult, workspaceContext, integrationTools, billingAttribution, entitlements] = + await Promise.all([ + fetchAttachments(), + generateWorkspaceContext(ws.id, userId, { workspaceAccess }), + buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), + resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), + computeWorkspaceEntitlements(ws.id, userId), + ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult const truncatedTask = { diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 61534aeb1a3..723717d1d36 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -213,6 +213,16 @@ export interface PostHogEventMap { source?: 'settings' | 'tool_input' } + skill_shared: { + skill_id: string + workspace_id: string + } + + skill_unshared: { + skill_id: string + workspace_id: string + } + workspace_deleted: { workspace_id: string workflow_count: number diff --git a/apps/sim/lib/skills/access.test.ts b/apps/sim/lib/skills/access.test.ts new file mode 100644 index 00000000000..d993edec97f --- /dev/null +++ b/apps/sim/lib/skills/access.test.ts @@ -0,0 +1,282 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkspaceAccess, mockGetUsersWithPermissions, dbState, makeChain, dbMock } = + vi.hoisted(() => { + const state = { results: [] as unknown[][] } + const chainFactory = () => { + const resolve = () => Promise.resolve(state.results.shift() ?? []) + const chain: any = {} + chain.from = vi.fn(() => chain) + chain.innerJoin = vi.fn(() => chain) + chain.where = vi.fn(() => chain) + chain.set = vi.fn(() => chain) + chain.limit = vi.fn(() => resolve()) + chain.returning = vi.fn(() => resolve()) + chain.then = (onFulfilled: any, onRejected: any) => resolve().then(onFulfilled, onRejected) + return chain + } + return { + mockCheckWorkspaceAccess: vi.fn(), + mockGetUsersWithPermissions: vi.fn(), + dbState: state, + makeChain: chainFactory, + dbMock: { + select: vi.fn(() => chainFactory()), + update: vi.fn(() => chainFactory()), + }, + } + }) + +vi.mock('@sim/db', () => ({ + db: dbMock, +})) + +vi.mock('@sim/db/schema', () => ({ + skill: { + id: 'skill.id', + workspaceId: 'skill.workspaceId', + name: 'skill.name', + }, + skillMember: { + id: 'skillMember.id', + skillId: 'skillMember.skillId', + userId: 'skillMember.userId', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => ({ and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), + inArray: vi.fn((a: unknown, b: unknown) => ({ inArray: [a, b] })), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, + getUsersWithPermissions: mockGetUsersWithPermissions, + resolveWorkspaceAccess: vi.fn(async (workspaceId: string, userId: string, provided?: any) => + provided && provided.workspace?.id === workspaceId + ? provided + : mockCheckWorkspaceAccess(workspaceId, userId) + ), +})) + +import { + checkSkillsUpdateAccess, + getEditableSkillIds, + getSkillActorContext, + listSkillEditors, + removeWorkspaceSkillMembershipsTx, +} from '@/lib/skills/access' + +const wsAdmin = { hasAccess: true, canWrite: true, canAdmin: true, workspace: { id: 'ws' } } +const wsWrite = { hasAccess: true, canWrite: true, canAdmin: false, workspace: { id: 'ws' } } +const wsRead = { hasAccess: true, canWrite: false, canAdmin: false, workspace: { id: 'ws' } } +const wsNone = { hasAccess: false, canWrite: false, canAdmin: false, workspace: null } + +beforeEach(() => { + vi.clearAllMocks() + dbState.results = [] + mockGetUsersWithPermissions.mockResolvedValue([]) +}) + +describe('getSkillActorContext', () => { + it('grants edit access from an explicit editor row', async () => { + dbState.results = [[{ id: 's1', workspaceId: 'ws' }], [{ id: 'row-1' }]] + mockCheckWorkspaceAccess.mockResolvedValue(wsRead) + + const ctx = await getSkillActorContext('s1', 'user1') + + expect(ctx.hasWorkspaceAccess).toBe(true) + expect(ctx.canEdit).toBe(true) + }) + + it('derives edit access from workspace admin without any rows', async () => { + dbState.results = [[{ id: 's1', workspaceId: 'ws' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(wsAdmin) + + const ctx = await getSkillActorContext('s1', 'admin-user') + + expect(ctx.canEdit).toBe(true) + }) + + it('lets rowless workspace members see but not edit', async () => { + dbState.results = [[{ id: 's1', workspaceId: 'ws' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(wsWrite) + + const ctx = await getSkillActorContext('s1', 'writer') + + expect(ctx.hasWorkspaceAccess).toBe(true) + expect(ctx.canEdit).toBe(false) + }) + + it('denies everything without workspace access, even with an editor row', async () => { + dbState.results = [[{ id: 's1', workspaceId: 'ws' }], [{ id: 'row-1' }]] + mockCheckWorkspaceAccess.mockResolvedValue(wsNone) + + const ctx = await getSkillActorContext('s1', 'outsider') + + expect(ctx.hasWorkspaceAccess).toBe(false) + expect(ctx.canEdit).toBe(false) + }) + + it('returns an empty context when the skill does not exist', async () => { + dbState.results = [[]] + + const ctx = await getSkillActorContext('missing', 'user1') + + expect(ctx.skill).toBeNull() + expect(ctx.hasWorkspaceAccess).toBe(false) + expect(ctx.canEdit).toBe(false) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) +}) + +describe('getEditableSkillIds', () => { + it('collects the editor rows from one workspace-scoped scan', async () => { + dbState.results = [[{ skillId: 's-mine' }, { skillId: 's-also-mine' }]] + mockCheckWorkspaceAccess.mockResolvedValue(wsRead) + + const access = await getEditableSkillIds('ws', 'user1') + + expect(access.canAdminWorkspace).toBe(false) + expect(access.editorSkillIds).toEqual(new Set(['s-mine', 's-also-mine'])) + }) + + it('flags workspace admins as editors of everything', async () => { + dbState.results = [[]] + mockCheckWorkspaceAccess.mockResolvedValue(wsAdmin) + + const access = await getEditableSkillIds('ws', 'admin-user') + + expect(access.canAdminWorkspace).toBe(true) + }) + + it('grants nothing without workspace access, even with editor rows', async () => { + dbState.results = [[{ skillId: 's-mine' }]] + mockCheckWorkspaceAccess.mockResolvedValue(wsNone) + + const access = await getEditableSkillIds('ws', 'outsider') + + expect(access.canAdminWorkspace).toBe(false) + expect(access.editorSkillIds.size).toBe(0) + }) +}) + +describe('listSkillEditors', () => { + const roster = (users: Array<{ userId: string; permissionType: string }>) => + users.map((u) => ({ + userId: u.userId, + permissionType: u.permissionType, + name: `${u.userId}-name`, + email: `${u.userId}@x.com`, + image: null, + })) + + it('lists derived workspace admins plus explicit editors still in the roster', async () => { + dbState.results = [ + [ + { id: 'row-1', userId: 'writer' }, + { id: 'row-2', userId: 'ghost' }, + ], + ] + mockGetUsersWithPermissions.mockResolvedValue( + roster([ + { userId: 'boss', permissionType: 'admin' }, + { userId: 'writer', permissionType: 'write' }, + { userId: 'reader', permissionType: 'read' }, + ]) + ) + + const editors = await listSkillEditors({ id: 's1', workspaceId: 'ws' }) + const byUser = new Map(editors.map((e) => [e.userId, e])) + + expect(byUser.get('boss')).toMatchObject({ + id: 'workspace-admin-boss', + isWorkspaceAdmin: true, + userEmail: 'boss@x.com', + }) + expect(byUser.get('writer')).toMatchObject({ id: 'row-1', isWorkspaceAdmin: false }) + // Workspace members without a row are not editors. + expect(byUser.has('reader')).toBe(false) + // Rows for users no longer in the workspace never render. + expect(byUser.has('ghost')).toBe(false) + }) + + it('keeps a workspace admin flagged as derived even when they hold an explicit row', async () => { + dbState.results = [[{ id: 'row-1', userId: 'boss' }]] + mockGetUsersWithPermissions.mockResolvedValue( + roster([{ userId: 'boss', permissionType: 'admin' }]) + ) + + const editors = await listSkillEditors({ id: 's1', workspaceId: 'ws' }) + + expect(editors).toHaveLength(1) + expect(editors[0]).toMatchObject({ id: 'row-1', userId: 'boss', isWorkspaceAdmin: true }) + }) +}) + +describe('checkSkillsUpdateAccess', () => { + it('returns nothing for an empty id list without querying', async () => { + const result = await checkSkillsUpdateAccess({ workspaceId: 'ws', userId: 'u', skillIds: [] }) + expect(result.existingIds.size).toBe(0) + expect(result.denied).toEqual([]) + expect(dbMock.select).not.toHaveBeenCalled() + }) + + it('partitions resolvable ids and denies skills without an editor row', async () => { + dbState.results = [ + [ + { id: 's-mine', name: 'mine' }, + { id: 's-other', name: 'other' }, + ], + [{ skillId: 's-mine' }], + ] + mockCheckWorkspaceAccess.mockResolvedValue(wsWrite) + + const result = await checkSkillsUpdateAccess({ + workspaceId: 'ws', + userId: 'u', + skillIds: ['s-mine', 's-other', 's-create'], + }) + + expect(result.existingIds).toEqual(new Set(['s-mine', 's-other'])) + expect(result.denied).toEqual([{ id: 's-other', name: 'other' }]) + }) + + it('denies nothing for workspace admins', async () => { + dbState.results = [[{ id: 's-any', name: 'any' }], []] + mockCheckWorkspaceAccess.mockResolvedValue(wsAdmin) + + const result = await checkSkillsUpdateAccess({ + workspaceId: 'ws', + userId: 'admin-user', + skillIds: ['s-any'], + }) + + expect(result.denied).toEqual([]) + }) +}) + +describe('removeWorkspaceSkillMembershipsTx', () => { + it('returns 0 for an empty workspace list without querying', async () => { + const tx = { select: vi.fn(() => makeChain()), delete: vi.fn(() => makeChain()) } + expect(await removeWorkspaceSkillMembershipsTx(tx as never, [], 'u')).toBe(0) + expect(tx.delete).not.toHaveBeenCalled() + }) + + it('deletes every editor grant for the user in the workspaces and counts them', async () => { + const { eq } = await import('drizzle-orm') + dbState.results = [[{ id: 'm1' }, { id: 'm2' }]] + const tx = { select: vi.fn(() => makeChain()), delete: vi.fn(() => makeChain()) } + + expect(await removeWorkspaceSkillMembershipsTx(tx as never, ['ws'], 'u')).toBe(2) + expect(tx.delete).toHaveBeenCalledTimes(1) + // Rows are plain editor grants — the delete has no status filter, so a + // re-invited user starts with no edit rights until re-added. + expect(vi.mocked(eq).mock.calls).toContainEqual(['skillMember.userId', 'u']) + expect(vi.mocked(eq).mock.calls.some(([field]) => field === 'skillMember.status')).toBe(false) + }) +}) diff --git a/apps/sim/lib/skills/access.ts b/apps/sim/lib/skills/access.ts new file mode 100644 index 00000000000..ca835a47414 --- /dev/null +++ b/apps/sim/lib/skills/access.ts @@ -0,0 +1,212 @@ +import { db } from '@sim/db' +import { skill, skillMember } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { + getUsersWithPermissions, + resolveWorkspaceAccess, + type WorkspaceAccess, +} from '@/lib/workspaces/permissions/utils' + +type SkillRecord = typeof skill.$inferSelect + +export interface SkillActorContext { + skill: SkillRecord | null + /** Whether the actor can see and use the skill — plain workspace access. */ + hasWorkspaceAccess: boolean + /** + * Whether the actor can edit, delete, and share the skill: an explicit + * `skill_member` editor row, or derived workspace admin (always, undemotable). + */ + canEdit: boolean +} + +/** + * Resolves the acting user's context for a single skill. Everyone with + * workspace access sees and uses every skill; editing is gated by the editors + * list. Builtin skills are code-only and have no editors; callers guard with + * `isBuiltinSkillId` before reaching this. + */ +export async function getSkillActorContext( + skillId: string, + userId: string +): Promise { + const [skillRow] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1) + + if (!skillRow?.workspaceId) { + return { skill: skillRow ?? null, hasWorkspaceAccess: false, canEdit: false } + } + + const [workspaceAccess, [editorRow]] = await Promise.all([ + resolveWorkspaceAccess(skillRow.workspaceId, userId), + db + .select({ id: skillMember.id }) + .from(skillMember) + .where(and(eq(skillMember.skillId, skillId), eq(skillMember.userId, userId))) + .limit(1), + ]) + + return { + skill: skillRow, + hasWorkspaceAccess: workspaceAccess.hasAccess, + canEdit: workspaceAccess.hasAccess && (workspaceAccess.canAdmin || !!editorRow), + } +} + +export interface EditableSkillIds { + /** Workspace admins are derived editors of every skill in the workspace. */ + canAdminWorkspace: boolean + /** Skills where the user holds an explicit editor row. */ + editorSkillIds: Set +} + +/** + * Batch edit-access surface for tagging many skills at once (list routes, + * upsert authorization): one workspace-access lookup plus one editor-row scan + * scoped to the workspace. A skill is editable when `canAdminWorkspace` or its + * id is in `editorSkillIds`. + * + * Pass `workspaceAccess` when the caller already resolved it to skip a + * redundant lookup. + */ +export async function getEditableSkillIds( + workspaceId: string, + userId: string, + options?: { workspaceAccess?: WorkspaceAccess } +): Promise { + const [workspaceAccess, editorRows] = await Promise.all([ + resolveWorkspaceAccess(workspaceId, userId, options?.workspaceAccess), + db + .select({ skillId: skillMember.skillId }) + .from(skillMember) + .innerJoin(skill, eq(skillMember.skillId, skill.id)) + .where(and(eq(skill.workspaceId, workspaceId), eq(skillMember.userId, userId))), + ]) + + if (!workspaceAccess.hasAccess) { + return { canAdminWorkspace: false, editorSkillIds: new Set() } + } + + return { + canAdminWorkspace: workspaceAccess.canAdmin, + editorSkillIds: new Set(editorRows.map((row) => row.skillId)), + } +} + +export interface SkillEditor { + /** Explicit row id, or a synthetic `workspace-admin-` id for derived admins without rows. */ + id: string + userId: string + userName: string | null + userEmail: string | null + userImage: string | null + /** Derived editors — always present, cannot be removed from the list. */ + isWorkspaceAdmin: boolean +} + +/** + * The editor roster for a skill: every workspace admin (derived, undemotable) + * plus every explicit-row user still in the workspace roster. Rows for users + * who left the workspace are ignored, exactly as edit enforcement ignores them. + */ +export async function listSkillEditors(skillRow: { + id: string + workspaceId: string +}): Promise { + const [explicitRows, workspaceMembers] = await Promise.all([ + db + .select({ id: skillMember.id, userId: skillMember.userId }) + .from(skillMember) + .where(eq(skillMember.skillId, skillRow.id)), + getUsersWithPermissions(skillRow.workspaceId), + ]) + + const rowByUser = new Map(explicitRows.map((row) => [row.userId, row])) + + const editors: SkillEditor[] = [] + for (const wsMember of workspaceMembers) { + const row = rowByUser.get(wsMember.userId) + const isWorkspaceAdmin = wsMember.permissionType === 'admin' + if (!row && !isWorkspaceAdmin) continue + + editors.push({ + id: row?.id ?? `workspace-admin-${wsMember.userId}`, + userId: wsMember.userId, + userName: wsMember.name, + userEmail: wsMember.email, + userImage: wsMember.image ?? null, + isWorkspaceAdmin, + }) + } + return editors +} + +export interface SkillsUpdateAccess { + /** Ids from the request that resolve to existing skills in the workspace. */ + existingIds: Set + /** Existing skills the user may not update (not an editor, not a workspace admin). */ + denied: Array<{ id: string; name: string }> +} + +/** + * Partitions an upsert request's skill ids for authorization: ids that resolve + * to existing workspace skills require skill editor access; unresolved ids are + * creates, gated by workspace write permission instead. + */ +export async function checkSkillsUpdateAccess(params: { + workspaceId: string + userId: string + skillIds: string[] + workspaceAccess?: WorkspaceAccess +}): Promise { + if (params.skillIds.length === 0) return { existingIds: new Set(), denied: [] } + + const rows = await db + .select({ id: skill.id, name: skill.name }) + .from(skill) + .where(and(eq(skill.workspaceId, params.workspaceId), inArray(skill.id, params.skillIds))) + + const existingIds = new Set(rows.map((row) => row.id)) + if (rows.length === 0) return { existingIds, denied: [] } + + const access = await getEditableSkillIds(params.workspaceId, params.userId, { + workspaceAccess: params.workspaceAccess, + }) + const denied = access.canAdminWorkspace + ? [] + : rows.filter((row) => !access.editorSkillIds.has(row.id)) + + return { existingIds, denied } +} + +/** + * Removes a user's skill editor grants across one or more workspaces when they + * leave (workspace removal, org removal/transfer). Rows are editor grants + * only — everyone in the workspace already sees and uses every skill — so a + * later re-invite lands them with no edit rights until re-added. Workspace + * admins are derived editors, so no promotion is needed to avoid orphaning a + * skill. Returns the number of grants removed. + */ +export async function removeWorkspaceSkillMembershipsTx( + tx: DbOrTx, + workspaceId: string | string[], + userId: string +): Promise { + const workspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId] + if (workspaceIds.length === 0) return 0 + + const removed = await tx + .delete(skillMember) + .where( + and( + eq(skillMember.userId, userId), + inArray( + skillMember.skillId, + tx.select({ id: skill.id }).from(skill).where(inArray(skill.workspaceId, workspaceIds)) + ) + ) + ) + .returning({ id: skillMember.id }) + + return removed.length +} diff --git a/apps/sim/lib/workflows/skills/operations.test.ts b/apps/sim/lib/workflows/skills/operations.test.ts index 9c5ebc167e1..62f49c01b57 100644 --- a/apps/sim/lib/workflows/skills/operations.test.ts +++ b/apps/sim/lib/workflows/skills/operations.test.ts @@ -4,10 +4,17 @@ import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +const { getEditableSkillIdsMock } = vi.hoisted(() => ({ + getEditableSkillIdsMock: vi.fn(), +})) + vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) -vi.mock('@sim/utils/id', () => ({ generateShortId: () => 'gen-id' })) +vi.mock('@sim/utils/id', () => ({ generateId: () => 'gen-uuid', generateShortId: () => 'gen-id' })) +vi.mock('@/lib/skills/access', () => ({ + getEditableSkillIds: getEditableSkillIdsMock, +})) -import { listSkills } from '@/lib/workflows/skills/operations' +import { listSkills, listSkillsForUser } from '@/lib/workflows/skills/operations' describe('listSkills includeBuiltins', () => { beforeEach(() => { @@ -39,3 +46,76 @@ describe('listSkills includeBuiltins', () => { expect(result.some((s) => s.id.startsWith('builtin-'))).toBe(false) }) }) + +describe('listSkillsForUser', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + getEditableSkillIdsMock.mockResolvedValue({ + canAdminWorkspace: false, + editorSkillIds: new Set(), + }) + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('returns every workspace skill tagged with edit access from editor rows', async () => { + queueTableRows(schemaMock.skill, [ + { id: 'sk-mine', name: 'mine', description: 'd', content: 'c', workspaceId: 'ws-1' }, + { id: 'sk-other', name: 'other', description: 'd', content: 'c', workspaceId: 'ws-1' }, + ]) + getEditableSkillIdsMock.mockResolvedValue({ + canAdminWorkspace: false, + editorSkillIds: new Set(['sk-mine']), + }) + + const result = await listSkillsForUser({ + workspaceId: 'ws-1', + userId: 'user-1', + includeBuiltins: false, + }) + + expect(result).toHaveLength(2) + expect(result.find((s) => s.id === 'sk-mine')).toMatchObject({ canEdit: true }) + expect(result.find((s) => s.id === 'sk-other')).toMatchObject({ canEdit: false }) + }) + + it('tags every skill editable for workspace admins', async () => { + queueTableRows(schemaMock.skill, [ + { id: 'sk-1', name: 'one', description: 'd', content: 'c', workspaceId: 'ws-1' }, + { id: 'sk-2', name: 'two', description: 'd', content: 'c', workspaceId: 'ws-1' }, + ]) + getEditableSkillIdsMock.mockResolvedValue({ + canAdminWorkspace: true, + editorSkillIds: new Set(), + }) + + const result = await listSkillsForUser({ + workspaceId: 'ws-1', + userId: 'admin-1', + includeBuiltins: false, + }) + + expect(result.every((s) => s.canEdit)).toBe(true) + }) + + it('always passes builtin skills through as non-editable', async () => { + const result = await listSkillsForUser({ workspaceId: 'ws-1', userId: 'user-1' }) + + expect(result.length).toBeGreaterThan(0) + expect(result.every((s) => s.id.startsWith('builtin-') && s.canEdit === false)).toBe(true) + }) + + it('lets a workspace skill sharing a builtin name override it for everyone', async () => { + queueTableRows(schemaMock.skill, [ + { id: 'sk-research', name: 'research', description: 'd', content: 'c', workspaceId: 'ws-1' }, + ]) + + const result = await listSkillsForUser({ workspaceId: 'ws-1', userId: 'user-1' }) + + expect(result.some((s) => s.id === 'sk-research')).toBe(true) + expect(result.some((s) => s.id === 'builtin-research')).toBe(false) + }) +}) diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 7e0fcfb4c2f..4746cc0d5f7 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -1,15 +1,17 @@ import { db } from '@sim/db' -import { skill } from '@sim/db/schema' +import { skill, skillMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' +import { generateId, generateShortId } from '@sim/utils/id' import { and, desc, eq, ne } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' +import { getEditableSkillIds } from '@/lib/skills/access' import { BUILTIN_SKILLS, type BuiltinSkill, getBuiltinSkillById, isBuiltinSkillId, } from '@/lib/workflows/skills/builtin-skills' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('SkillsOperations') @@ -57,6 +59,46 @@ export async function listSkills(params: { workspaceId: string; includeBuiltins? return [...builtins, ...dbRows] } +/** A skill row tagged with whether the caller can edit it (always false on builtins). */ +export type SkillWithAccess = typeof skill.$inferSelect & { canEdit: boolean } + +/** + * List every skill in the workspace, each tagged with whether the caller can + * edit it: workspace admins can edit all; others can edit skills where they + * hold an explicit editor row. Everyone in the workspace sees and uses every + * skill. Built-in template skills are code-only and never editable. + * + * Pass `workspaceAccess` when the caller already resolved it to skip a + * redundant lookup. + */ +export async function listSkillsForUser(params: { + workspaceId: string + userId: string + includeBuiltins?: boolean + workspaceAccess?: WorkspaceAccess +}): Promise { + const [dbRows, access] = await Promise.all([ + listSkills({ workspaceId: params.workspaceId, includeBuiltins: false }), + getEditableSkillIds(params.workspaceId, params.userId, { + workspaceAccess: params.workspaceAccess, + }), + ]) + + const tagged: SkillWithAccess[] = dbRows.map((row) => ({ + ...row, + canEdit: access.canAdminWorkspace || access.editorSkillIds.has(row.id), + })) + + if (params.includeBuiltins === false) return tagged + + // A workspace skill that shares a built-in's name overrides it for everyone. + const dbNames = new Set(tagged.map((r) => r.name.toLowerCase())) + const builtins: SkillWithAccess[] = BUILTIN_SKILLS.filter( + (b) => !dbNames.has(b.name.toLowerCase()) + ).map((b) => ({ ...builtinSkillRow(params.workspaceId, b), canEdit: false })) + return [...builtins, ...tagged] +} + /** * Fetch a single skill by id, scoped to a workspace. Built-in template skills * resolve from code; otherwise returns the DB row, or null when the skill does @@ -112,7 +154,11 @@ export interface TouchedSkill { } export interface UpsertSkillsResult { - /** Every skill in the workspace after the upsert, ordered by createdAt desc. */ + /** + * Every skill in the workspace after the upsert, ordered by createdAt desc. + * Empty when `returnSkills: false` — callers that re-fetch a filtered list + * themselves opt out so the transaction never re-reads full content bodies. + */ skills: Awaited> /** Only the skills this upsert created or updated, tagged with the operation. */ touched: TouchedSkill[] @@ -125,13 +171,14 @@ export interface UpsertSkillsResult { export async function upsertSkills(params: { skills: Array<{ id?: string - name: string - description: string - content: string + name?: string + description?: string + content?: string }> workspaceId: string userId: string requestId?: string + returnSkills?: boolean }): Promise { const { skills, workspaceId, userId, requestId = generateRequestId() } = params @@ -147,41 +194,53 @@ export async function upsertSkills(params: { const nowTime = new Date() if (s.id) { - const existingSkill = await tx + // Id-carrying items are updates and never fall through to a create: the + // caller's authorization partitioned on resolvability, so a vanished id + // must surface as not-found rather than an ungated (re-)create. + const [current] = await tx .select() .from(skill) .where(and(eq(skill.id, s.id), eq(skill.workspaceId, workspaceId))) .limit(1) - if (existingSkill.length > 0) { - if (s.name !== existingSkill[0].name) { - const nameConflict = await tx - .select({ id: skill.id }) - .from(skill) - .where( - and(eq(skill.workspaceId, workspaceId), eq(skill.name, s.name), ne(skill.id, s.id)) - ) - .limit(1) - - if (nameConflict.length > 0) { - throw new Error(`A skill with the name "${s.name}" already exists in this workspace`) - } - } + if (!current) { + throw new Error(`Skill not found: ${s.id}`) + } + + // Partial update: omitted fields keep their current values, so a + // sharing-only toggle can never clobber a concurrent content edit. + const nextName = s.name ?? current.name + if (nextName !== current.name) { + const nameConflict = await tx + .select({ id: skill.id }) + .from(skill) + .where( + and(eq(skill.workspaceId, workspaceId), eq(skill.name, nextName), ne(skill.id, s.id)) + ) + .limit(1) - await tx - .update(skill) - .set({ - name: s.name, - description: s.description, - content: s.content, - updatedAt: nowTime, - }) - .where(and(eq(skill.id, s.id), eq(skill.workspaceId, workspaceId))) - - touched.push({ id: s.id, name: s.name, operation: 'updated' }) - logger.info(`[${requestId}] Updated skill ${s.id}`) - continue + if (nameConflict.length > 0) { + throw new Error(`The skill name "${nextName}" is unavailable in this workspace`) + } } + + await tx + .update(skill) + .set({ + name: nextName, + description: s.description ?? current.description, + content: s.content ?? current.content, + updatedAt: nowTime, + }) + .where(and(eq(skill.id, s.id), eq(skill.workspaceId, workspaceId))) + + touched.push({ id: s.id, name: nextName, operation: 'updated' }) + logger.info(`[${requestId}] Updated skill ${s.id}`) + continue + } + + if (!s.name || !s.description || !s.content) { + throw new Error('Skill name, description, and content are required to create a skill') } const duplicateName = await tx @@ -191,7 +250,7 @@ export async function upsertSkills(params: { .limit(1) if (duplicateName.length > 0) { - throw new Error(`A skill with the name "${s.name}" already exists in this workspace`) + throw new Error(`The skill name "${s.name}" is unavailable in this workspace`) } const newId = generateShortId() @@ -206,10 +265,25 @@ export async function upsertSkills(params: { updatedAt: nowTime, }) + // The creator becomes an editor; workspace admins are derived editors + // with no rows, and everyone in the workspace can already use the skill. + await tx.insert(skillMember).values({ + id: generateId(), + skillId: newId, + userId, + invitedBy: userId, + createdAt: nowTime, + updatedAt: nowTime, + }) + touched.push({ id: newId, name: s.name, operation: 'created' }) logger.info(`[${requestId}] Created skill "${s.name}"`) } + if (params.returnSkills === false) { + return { skills: [], touched } + } + const resultSkills = await tx .select() .from(skill) diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index 81c12739b40..6afc4f66554 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -164,6 +164,21 @@ export async function checkWorkspaceAccess( return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws, permission } } +/** + * Returns `provided` when it was resolved for this exact workspace, otherwise + * resolves fresh. The id match is what keeps a caller from authorizing against + * another workspace's cached access - every access-reuse path must go through + * this rather than hand-rolling the comparison. + */ +export async function resolveWorkspaceAccess( + workspaceId: string, + userId: string, + provided?: WorkspaceAccess +): Promise { + if (provided && provided.workspace?.id === workspaceId) return provided + return checkWorkspaceAccess(workspaceId, userId) +} + /** * Thrown when a user attempts to access a workspace they don't have access to, * or that doesn't exist / has been archived. Carries `statusCode = 403` so the diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts b/apps/sim/lib/workspaces/sharing.test.ts similarity index 96% rename from apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts rename to apps/sim/lib/workspaces/sharing.test.ts index c762ac9f0fc..1c202684093 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts +++ b/apps/sim/lib/workspaces/sharing.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { partitionSettledFailures, resolveAddEmail } from './sharing' +import { partitionSettledFailures, resolveAddEmail } from '@/lib/workspaces/sharing' describe('resolveAddEmail', () => { const ctx = { diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts b/apps/sim/lib/workspaces/sharing.ts similarity index 88% rename from apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts rename to apps/sim/lib/workspaces/sharing.ts index f315ba0eedf..a42eed3bd21 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts +++ b/apps/sim/lib/workspaces/sharing.ts @@ -1,14 +1,15 @@ export interface ResolveAddEmailContext { /** Lowercased email -> workspace userId for every workspace member. */ workspaceUserIdByEmail: Map - /** Lowercased emails that already have access to the credential. */ + /** Lowercased emails that already have access to the resource. */ existingMemberEmails: Set } export type ResolveAddEmailResult = { userId: string } | { error: string } /** - * Decide whether a format-valid email can be added to a credential: it must + * Decide whether a format-valid email can be added to a shared resource + * (credential, skill): it must * belong to a workspace member and not already have access. Matching is * case-insensitive (the context map/set are keyed by lowercased email) while * error messages echo the email as the user typed it. Returns the resolved diff --git a/bun.lock b/bun.lock index 69f9acb71ac..849269e0794 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -601,6 +602,7 @@ "postgres": "^3.4.5", "react": "19.2.4", "react-dom": "19.2.4", + "zod": "4.3.6", }, "packages": { "@1password/sdk": ["@1password/sdk@0.3.1", "", { "dependencies": { "@1password/sdk-core": "0.3.1" } }, "sha512-20zbQfqsjcECT0gvnAw4zONJDt3XQgNH946pZR0NV1Qxukyaz/DKB0cBnBNCCEWZg93Bah8poaR6gJCyuNX14w=="], @@ -3095,7 +3097,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4211,16 +4213,10 @@ "@better-auth/core/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@better-auth/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@better-auth/sso/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "@better-auth/sso/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], - "@better-auth/sso/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "@better-auth/stripe/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@browserbasehq/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@browserbasehq/sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4535,10 +4531,6 @@ "@sim/db/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], - "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - - "@sim/workflow-renderer/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4595,8 +4587,6 @@ "@trigger.dev/core/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "@trigger.dev/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@trigger.dev/sdk/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@trigger.dev/sdk/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.36.0", "", {}, "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ=="], @@ -4635,8 +4625,6 @@ "better-auth/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "better-auth/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -4733,18 +4721,20 @@ "fumadocs-mdx/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], - "fumadocs-mdx/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "fumadocs-openapi/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + "fumadocs-ui/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], + "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4903,8 +4893,6 @@ "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], - "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], @@ -4969,10 +4957,6 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "zod-error/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "zod-validation-error/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], diff --git a/package.json b/package.json index 374ffb1f23c..95b4486ff50 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", "minimatch": "^10.2.5", - "mermaid": "11.15.0" + "mermaid": "11.15.0", + "zod": "4.3.6" }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 90e02afe2af..a7bfb980408 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -159,6 +159,8 @@ export const AuditAction = { SKILL_CREATED: 'skill.created', SKILL_UPDATED: 'skill.updated', SKILL_DELETED: 'skill.deleted', + SKILL_MEMBER_ADDED: 'skill_member.added', + SKILL_MEMBER_REMOVED: 'skill_member.removed', // Schedules SCHEDULE_CREATED: 'schedule.created', diff --git a/packages/db/migrations/0267_dark_romulus.sql b/packages/db/migrations/0267_dark_romulus.sql new file mode 100644 index 00000000000..b75860a6ee7 --- /dev/null +++ b/packages/db/migrations/0267_dark_romulus.sql @@ -0,0 +1,29 @@ +CREATE TABLE "skill_member" ( + "id" text PRIMARY KEY NOT NULL, + "skill_id" text NOT NULL, + "user_id" text NOT NULL, + "invited_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "skill_member" ADD CONSTRAINT "skill_member_skill_id_skill_id_fk" FOREIGN KEY ("skill_id") REFERENCES "public"."skill"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "skill_member" ADD CONSTRAINT "skill_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "skill_member" ADD CONSTRAINT "skill_member_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "skill_member_user_id_idx" ON "skill_member" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "skill_member_unique" ON "skill_member" USING btree ("skill_id","user_id");--> statement-breakpoint +-- Backfill: existing skills predate the editors list, where any workspace `write` user +-- could edit any skill. Grant those users an explicit editor row so their edit rights +-- survive the cutover. Workspace admins get no rows (they are derived editors at +-- runtime), which the write-only permission join guarantees — the permissions table is +-- unique on (user_id, entity_type, entity_id). Everyone with workspace access can see +-- and use every skill regardless of rows, so no other grants are needed. +-- Idempotent and deploy-window re-runnable: deterministic ids + ON CONFLICT DO NOTHING, +-- so re-running this INSERT once after full cutover heals any skill created by a +-- still-running old pod during the deploy window. +INSERT INTO "skill_member" ("id", "skill_id", "user_id", "invited_by", "created_at", "updated_at") +SELECT 'skillm_' || md5(s."id" || ':' || p."user_id"), s."id", p."user_id", s."user_id", now(), now() +FROM "skill" s +INNER JOIN "permissions" p ON p."entity_type" = 'workspace' AND p."entity_id" = s."workspace_id" AND p."permission_type" = 'write' +WHERE s."workspace_id" IS NOT NULL +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/packages/db/migrations/meta/0267_snapshot.json b/packages/db/migrations/meta/0267_snapshot.json new file mode 100644 index 00000000000..4be63a659fd --- /dev/null +++ b/packages/db/migrations/meta/0267_snapshot.json @@ -0,0 +1,17529 @@ +{ + "id": "25435972-1fe1-4766-a728-d8e5c8818168", + "prevId": "dd2e88e1-ce36-426e-9857-a9f8ab6141fb", + "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": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": 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": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "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 + }, + "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_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": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "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": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS 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.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 + }, + "inputs": { + "name": "inputs", + "type": "json", + "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 + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_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 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_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.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "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 + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "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": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "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": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "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": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "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 + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "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_operation": { + "name": "workflow_deployment_operation", + "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": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_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": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "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": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "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 + }, + "deployment_operation_id": { + "name": "deployment_operation_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_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "organization_assigned_at": { + "name": "organization_assigned_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": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "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_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", + "workflow_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 39b5fac75ac..7ec307a7e73 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1863,6 +1863,13 @@ "when": 1784838122475, "tag": "0266_spotty_alex_power", "breakpoints": true + }, + { + "idx": 267, + "version": "7", + "when": 1784861110986, + "tag": "0267_dark_romulus", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 146284517bd..2badf305690 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1050,6 +1050,31 @@ export const skill = pgTable( }) ) +/** + * Editor grants for a skill. A row makes the user an editor (edit, delete, + * share); workspace admins are derived editors and need no rows. Everyone with + * workspace access can see and use every skill regardless of rows. + */ +export const skillMember = pgTable( + 'skill_member', + { + id: text('id').primaryKey(), + skillId: text('skill_id') + .notNull() + .references(() => skill.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + userIdIdx: index('skill_member_user_id_idx').on(table.userId), + uniqueMembership: uniqueIndex('skill_member_unique').on(table.skillId, table.userId), + }) +) + export const mothershipSettings = pgTable( 'mothership_settings', { diff --git a/packages/emcn/src/components/chip-switch/chip-switch.tsx b/packages/emcn/src/components/chip-switch/chip-switch.tsx index 717af940ba5..cf0cf535de2 100644 --- a/packages/emcn/src/components/chip-switch/chip-switch.tsx +++ b/packages/emcn/src/components/chip-switch/chip-switch.tsx @@ -22,7 +22,7 @@ export interface ChipSwitchOption { */ export interface ChipSwitchProps { /** Ordered list of options to render as segments. */ - options: ChipSwitchOption[] + options: readonly ChipSwitchOption[] /** Currently selected value. */ value: T /** Invoked with the next selection when a segment is clicked. */ diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index e18095f5d36..356ebc4174d 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -131,6 +131,8 @@ export const auditMock = { SKILL_CREATED: 'skill.created', SKILL_UPDATED: 'skill.updated', SKILL_DELETED: 'skill.deleted', + SKILL_MEMBER_ADDED: 'skill_member.added', + SKILL_MEMBER_REMOVED: 'skill_member.removed', TABLE_CREATED: 'table.created', TABLE_UPDATED: 'table.updated', TABLE_DELETED: 'table.deleted', diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 9f892b1c2ec..f7f4084c798 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -406,6 +406,14 @@ export const schemaMock = { createdAt: 'createdAt', updatedAt: 'updatedAt', }, + skillMember: { + id: 'id', + skillId: 'skillId', + userId: 'userId', + invitedBy: 'invitedBy', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, subscription: { id: 'id', plan: 'plan', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 8289bba83d8..b6f582a143f 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: 969, - zodRoutes: 969, + totalRoutes: 970, + zodRoutes: 970, nonZodRoutes: 0, } as const diff --git a/scripts/check-migrations-safety.ts b/scripts/check-migrations-safety.ts index 5c4e299af23..5008b48e367 100644 --- a/scripts/check-migrations-safety.ts +++ b/scripts/check-migrations-safety.ts @@ -25,6 +25,7 @@ * bun run scripts/check-migrations-safety.ts --dir # a directory */ import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' @@ -414,7 +415,9 @@ function changedMigrationFiles(baseRef: string): string[] { if (inDir(p)) files.add(p) } } - return [...files] + // A migration deleted in the working tree (e.g. regenerated before commit) has + // no SQL left to lint — skip it rather than crash on the read. + return [...files].filter((f) => existsSync(path.join(ROOT, f))) } async function listSqlFiles(dir: string): Promise { From bee45621ecb2c4465be29d4e54249e96bfad07e2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 23 Jul 2026 20:14:37 -0700 Subject: [PATCH 13/32] refactor(skills): one definition of the skill fields across every surface (#5916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(skills): one definition of the skill fields across every surface The Name / Description / Content trio was implemented three times — once in the canvas modal and once each in the create and detail pages — and had already drifted: the name hint appeared on create, was missing entirely on detail, and sat in a different slot in the modal; the content editor was 260px on the pages and 200px in the modal; only the modal marked the fields required. Extract SkillFields, the DetailSection form both full-page surfaces render, and move the shared copy into skill-copy.ts. The modal keeps ChipModalField — that is required inside a ChipModalBody, so it cannot share the pages' JSX — but now reads the same placeholder, hint, and max-length constants, so the wording can no longer diverge. The detail page's local FieldLockTooltip moves into SkillFields and is now available to both pages, and the dynamic rich-editor import drops from three copies to two. No behavioral change beyond the drift being resolved: the name hint now shows on the detail page as it already did on create. * refactor(skills): derive modal saving state, collapse the field message line From the cleanup passes over this branch: - SkillModal mirrored `mutation.isPending` into a local `saving` useState, which .claude/rules/sim-hooks.md forbids. It also carried a latent bug: the `finally { setSaving(false) }` ran after `onSave()` had closed the modal, so it set state on an unmounting component, and a throw from `onSave()` would leave the modal stuck in a saving state. Derived from the two mutations instead. - The hint/error line under a field was written three times in SkillFields, in three slightly different shapes. One FieldMessage helper now renders all three. - The name hint is an authoring instruction, so it no longer shows under a field that cannot be edited (a built-in skill, or a viewer who is not an editor). --- .../skills/[skillId]/skill-detail.tsx | 151 ++++------------ .../skills/components/skill-copy.ts | 14 ++ .../skills/components/skill-fields/index.ts | 4 + .../components/skill-fields/skill-fields.tsx | 166 ++++++++++++++++++ .../components/skill-modal/skill-modal.tsx | 24 +-- .../[workspaceId]/skills/new/skill-create.tsx | 123 +++---------- 6 files changed, 258 insertions(+), 224 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx index 9029bbda79e..c3189d4d50a 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -1,33 +1,24 @@ 'use client' -import { type ReactNode, useState } from 'react' -import { - Chip, - ChipConfirmModal, - ChipInput, - ChipLink, - ChipTextarea, - chipFieldSurfaceClass, - cn, - Send, - Tooltip, - toast, -} from '@sim/emcn' +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipLink, Send, toast } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import dynamic from 'next/dynamic' import { useRouter } from 'next/navigation' import { AddPeopleModal } from '@/components/permissions' import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { CredentialDetailHeading, CredentialDetailLayout, - DetailSection, UnsavedChangesModal, useUnsavedChangesGuard, } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SkillEditorsCard } from '@/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card' +import { + type SkillFieldErrors, + SkillFields, +} from '@/app/workspace/[workspaceId]/skills/components/skill-fields' import { useSkillEditorsController } from '@/app/workspace/[workspaceId]/skills/components/skill-members' import { isSkillNameConflictError, @@ -36,47 +27,8 @@ import { } from '@/app/workspace/[workspaceId]/skills/components/utils' import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills' -const RichMarkdownField = dynamic( - () => - import( - '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' - ).then((m) => m.RichMarkdownField), - { - ssr: false, - loading: () =>
, - } -) - const logger = createLogger('SkillDetail') -interface FieldErrors { - name?: string - description?: string - content?: string -} - -interface FieldLockTooltipProps { - reason: string | null - children: ReactNode -} - -/** - * Wraps a read-only field so hovering it explains why editing is locked. - * Renders children unchanged when the field is editable. The wrapper div - * receives the hover events a disabled control swallows. - */ -function FieldLockTooltip({ reason, children }: FieldLockTooltipProps) { - if (!reason) return <>{children} - return ( - - -
{children}
-
- {reason} -
- ) -} - interface SkillDetailProps { workspaceId: string skillId: string @@ -110,7 +62,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { const [contentDraft, setContentDraft] = useState('') /** Bumped to remount the seed-once rich Content editor on programmatic sets. */ const [contentSeed, setContentSeed] = useState(0) - const [errors, setErrors] = useState({}) + const [errors, setErrors] = useState({}) const [shareOpen, setShareOpen] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [prevSkillId, setPrevSkillId] = useState(null) @@ -138,7 +90,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { const handleSave = async () => { if (!skill || !canEdit || !isDirty || updateSkill.isPending) return - const newErrors: FieldErrors = {} + const newErrors: SkillFieldErrors = {} const nameError = validateSkillName(nameDraft) if (nameError) newErrors.name = nameError if (!descriptionDraft.trim()) newErrors.description = 'Description is required' @@ -252,70 +204,29 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { subtitle={isBuiltin ? 'Built-in skill' : skill.description} /> - - - { - setNameDraft(event.target.value) - if (errors.name) setErrors((prev) => ({ ...prev, name: undefined })) - }} - placeholder='my-skill-name' - autoComplete='off' - data-lpignore='true' - disabled={readOnly} - error={!!errors.name} - /> - - {errors.name && ( -

{errors.name}

- )} -
- - - - { - setDescriptionDraft(event.target.value) - if (errors.description) setErrors((prev) => ({ ...prev, description: undefined })) - }} - placeholder='What this skill does and when to use it...' - maxLength={1024} - autoComplete='off' - data-lpignore='true' - disabled={readOnly} - /> - - {errors.description && ( -

{errors.description}

- )} -
- - - - { - setContentDraft(value) - if (errors.content) setErrors((prev) => ({ ...prev, content: undefined })) - }} - placeholder='Skill instructions in markdown...' - minHeight={260} - disabled={readOnly} - error={!!errors.content} - workspaceId={workspaceId} - onPasteText={handleContentPaste} - /> - - {errors.content && ( -

{errors.content}

- )} -
+ { + setNameDraft(value) + if (errors.name) setErrors((prev) => ({ ...prev, name: undefined })) + }} + onDescriptionChange={(value) => { + setDescriptionDraft(value) + if (errors.description) setErrors((prev) => ({ ...prev, description: undefined })) + }} + onContentChange={(value) => { + setContentDraft(value) + if (errors.content) setErrors((prev) => ({ ...prev, content: undefined })) + }} + errors={errors} + contentKey={`${skill.id}:${contentSeed}`} + workspaceId={workspaceId} + disabled={readOnly} + lockReason={lockReason} + onPasteText={handleContentPaste} + /> {!isBuiltin && } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts new file mode 100644 index 00000000000..6c3ae2f63fb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-copy.ts @@ -0,0 +1,14 @@ +/** + * Field copy shared by every skill editing surface. The two pages share JSX via + * `SkillFields`, but the canvas modal must frame its fields with + * `ChipModalField` — required inside a `ChipModalBody` — so it cannot. These + * strings are what keep the modal in step with the pages. + */ + +export const SKILL_NAME_PLACEHOLDER = 'my-skill-name' +export const SKILL_NAME_HINT = 'Lowercase letters, numbers, and hyphens (e.g. my-skill)' +export const SKILL_DESCRIPTION_PLACEHOLDER = 'What this skill does and when to use it...' +export const SKILL_CONTENT_PLACEHOLDER = 'Skill instructions in markdown...' + +/** Mirrors `skillDescriptionSchema` in the contract. */ +export const SKILL_DESCRIPTION_MAX_LENGTH = 1024 diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts new file mode 100644 index 00000000000..257dcb31ce0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts @@ -0,0 +1,4 @@ +export { + type SkillFieldErrors, + SkillFields, +} from '@/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields' diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx new file mode 100644 index 00000000000..c865528ad2a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/skill-fields.tsx @@ -0,0 +1,166 @@ +'use client' + +import type { ReactNode } from 'react' +import { ChipInput, ChipTextarea, chipFieldSurfaceClass, cn, Tooltip } from '@sim/emcn' +import dynamic from 'next/dynamic' +import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + SKILL_CONTENT_PLACEHOLDER, + SKILL_DESCRIPTION_MAX_LENGTH, + SKILL_DESCRIPTION_PLACEHOLDER, + SKILL_NAME_HINT, + SKILL_NAME_PLACEHOLDER, +} from '@/app/workspace/[workspaceId]/skills/components/skill-copy' + +const RichMarkdownField = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' + ).then((m) => m.RichMarkdownField), + { + ssr: false, + loading: () =>
, + } +) + +export interface SkillFieldErrors { + name?: string + description?: string + content?: string +} + +interface SkillFieldsProps { + name: string + description: string + content: string + onNameChange: (value: string) => void + onDescriptionChange: (value: string) => void + onContentChange: (value: string) => void + errors: SkillFieldErrors + /** Remounts the seed-once rich editor when content is set programmatically. */ + contentKey: string | number + workspaceId: string + disabled?: boolean + /** + * Why the fields are locked (built-in skill, or viewer is not an editor). + * Renders a tooltip explaining it — a disabled control swallows hover, so each + * field is wrapped rather than the control itself. + */ + lockReason?: string | null + /** Intercepts a full SKILL.md paste into Content. */ + onPasteText?: (text: string) => boolean +} + +interface FieldLockTooltipProps { + reason: string | null | undefined + children: ReactNode +} + +function FieldLockTooltip({ reason, children }: FieldLockTooltipProps) { + if (!reason) return <>{children} + return ( + + +
{children}
+
+ {reason} +
+ ) +} + +/** + * The hint-or-error line under a field. Renders nothing when there is neither, + * so a field without a message reserves no space. + */ +function FieldMessage({ error, hint }: { error?: string; hint?: string }) { + const message = error ?? hint + if (!message) return null + return ( +

+ {message} +

+ ) +} + +/** + * The Name / Description / Content trio as the full-page skill surfaces render + * it — `DetailSection` rows with the error line beneath each field. Shared by + * the create and detail pages so the copy, sizing, and error treatment cannot + * drift between them. The canvas modal frames the same fields with + * `ChipModalField` (required inside a `ChipModalBody`) and shares the copy + * constants instead. + */ +export function SkillFields({ + name, + description, + content, + onNameChange, + onDescriptionChange, + onContentChange, + errors, + contentKey, + workspaceId, + disabled = false, + lockReason, + onPasteText, +}: SkillFieldsProps) { + return ( + <> + + + onNameChange(event.target.value)} + placeholder={SKILL_NAME_PLACEHOLDER} + autoComplete='off' + data-lpignore='true' + disabled={disabled} + error={!!errors.name} + /> + + + + + + + onDescriptionChange(event.target.value)} + placeholder={SKILL_DESCRIPTION_PLACEHOLDER} + maxLength={SKILL_DESCRIPTION_MAX_LENGTH} + autoComplete='off' + data-lpignore='true' + disabled={disabled} + error={!!errors.description} + /> + + + + + + + + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index cd6d8d29f78..12e97e2d71c 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -15,6 +15,13 @@ import { import { getErrorMessage } from '@sim/utils/errors' import dynamic from 'next/dynamic' import { useParams } from 'next/navigation' +import { + SKILL_CONTENT_PLACEHOLDER, + SKILL_DESCRIPTION_MAX_LENGTH, + SKILL_DESCRIPTION_PLACEHOLDER, + SKILL_NAME_HINT, + SKILL_NAME_PLACEHOLDER, +} from '@/app/workspace/[workspaceId]/skills/components/skill-copy' import { SkillImport } from '@/app/workspace/[workspaceId]/skills/components/skill-import' import { isSkillNameConflictError, @@ -73,7 +80,6 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM */ const [contentSeed, setContentSeed] = useState(0) const [errors, setErrors] = useState({}) - const [saving, setSaving] = useState(false) const [activeTab, setActiveTab] = useState('create') const [prevOpen, setPrevOpen] = useState(false) const [prevInitialValues, setPrevInitialValues] = useState(initialValues) @@ -96,6 +102,8 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM description !== initialValues.description || content !== initialValues.content + const saving = createSkill.isPending || updateSkill.isPending + const handleSave = async () => { const newErrors: FieldErrors = {} @@ -115,8 +123,6 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM return } - setSaving(true) - try { if (initialValues) { await updateSkill.mutateAsync({ @@ -137,8 +143,6 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM } else { setErrors({ general: 'Failed to save skill. Please try again.' }) } - } finally { - setSaving(false) } } @@ -205,10 +209,10 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM if (errors.name || errors.general) setErrors((prev) => ({ ...prev, name: undefined, general: undefined })) }} - placeholder='my-skill-name' + placeholder={SKILL_NAME_PLACEHOLDER} required error={errors.name} - hint='Lowercase letters, numbers, and hyphens (e.g. my-skill)' + hint={SKILL_NAME_HINT} disabled={readOnly || saving} /> @@ -221,8 +225,8 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM if (errors.description || errors.general) setErrors((prev) => ({ ...prev, description: undefined, general: undefined })) }} - placeholder='What this skill does and when to use it...' - maxLength={1024} + placeholder={SKILL_DESCRIPTION_PLACEHOLDER} + maxLength={SKILL_DESCRIPTION_MAX_LENGTH} required error={errors.description} disabled={readOnly || saving} @@ -237,7 +241,7 @@ export function SkillModal({ open, onOpenChange, onSave, initialValues }: SkillM if (errors.content || errors.general) setErrors((prev) => ({ ...prev, content: undefined, general: undefined })) }} - placeholder='Skill instructions in markdown...' + placeholder={SKILL_CONTENT_PLACEHOLDER} minHeight={200} maxHeight={360} disabled={readOnly || saving} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx index 6c4a246f47b..928862b063b 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx @@ -1,28 +1,22 @@ 'use client' import { useState } from 'react' -import { - Chip, - ChipInput, - ChipLink, - ChipTextarea, - chipFieldSurfaceClass, - cn, - toast, -} from '@sim/emcn' +import { Chip, ChipLink, toast } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import dynamic from 'next/dynamic' import { useRouter } from 'next/navigation' import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { CredentialDetailHeading, CredentialDetailLayout, - DetailSection, UnsavedChangesModal, useUnsavedChangesGuard, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + type SkillFieldErrors, + SkillFields, +} from '@/app/workspace/[workspaceId]/skills/components/skill-fields' import { SkillImportButton } from '@/app/workspace/[workspaceId]/skills/components/skill-import' import { isSkillNameConflictError, @@ -32,25 +26,8 @@ import { } from '@/app/workspace/[workspaceId]/skills/components/utils' import { useCreateSkill } from '@/hooks/queries/skills' -const RichMarkdownField = dynamic( - () => - import( - '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' - ).then((m) => m.RichMarkdownField), - { - ssr: false, - loading: () =>
, - } -) - const logger = createLogger('SkillCreate') -interface FieldErrors { - name?: string - description?: string - content?: string -} - interface SkillCreateProps { workspaceId: string } @@ -71,7 +48,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) { const [contentDraft, setContentDraft] = useState('') /** Bumped to remount the seed-once rich Content editor on programmatic sets. */ const [contentSeed, setContentSeed] = useState(0) - const [errors, setErrors] = useState({}) + const [errors, setErrors] = useState({}) // Drops on success so the guard pops its history sentinel before we navigate — // otherwise Back from the new skill lands on a stale, empty create form. @@ -84,7 +61,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) { const handleCreate = async () => { if (createSkill.isPending) return - const newErrors: FieldErrors = {} + const newErrors: SkillFieldErrors = {} const nameError = validateSkillName(nameDraft) if (nameError) newErrors.name = nameError if (!descriptionDraft.trim()) newErrors.description = 'Description is required' @@ -161,70 +138,28 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) { subtitle='Write a skill, or import an existing SKILL.md' /> - - { - setNameDraft(event.target.value) - if (errors.name) setErrors((prev) => ({ ...prev, name: undefined })) - }} - placeholder='my-skill-name' - autoComplete='off' - data-lpignore='true' - disabled={createSkill.isPending} - error={!!errors.name} - /> -

- {errors.name ?? 'Lowercase letters, numbers, and hyphens (e.g. my-skill)'} -

-
- - - { - setDescriptionDraft(event.target.value) - if (errors.description) setErrors((prev) => ({ ...prev, description: undefined })) - }} - placeholder='What this skill does and when to use it...' - maxLength={1024} - autoComplete='off' - data-lpignore='true' - disabled={createSkill.isPending} - error={!!errors.description} - /> - {errors.description && ( -

{errors.description}

- )} -
- - - { - setContentDraft(value) - if (errors.content) setErrors((prev) => ({ ...prev, content: undefined })) - }} - placeholder='Skill instructions in markdown...' - minHeight={260} - disabled={createSkill.isPending} - error={!!errors.content} - workspaceId={workspaceId} - onPasteText={handleContentPaste} - /> - {errors.content && ( -

{errors.content}

- )} -
+ { + setNameDraft(value) + if (errors.name) setErrors((prev) => ({ ...prev, name: undefined })) + }} + onDescriptionChange={(value) => { + setDescriptionDraft(value) + if (errors.description) setErrors((prev) => ({ ...prev, description: undefined })) + }} + onContentChange={(value) => { + setContentDraft(value) + if (errors.content) setErrors((prev) => ({ ...prev, content: undefined })) + }} + errors={errors} + contentKey={contentSeed} + workspaceId={workspaceId} + disabled={createSkill.isPending} + onPasteText={handleContentPaste} + /> Date: Thu, 23 Jul 2026 20:20:25 -0700 Subject: [PATCH 14/32] fix(providers): final regenerated stream must not re-call tools (empty chat answers) (#5915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): stop the final regenerated stream from re-calling tools and clobbering the answer After the silent tool loop settles, OpenAI Responses and Gemini re-issue a streaming request purely to stream the answer as prose — but with tools still attached and auto tool choice, a reasoning model can re-decide to call a tool there. Streamed calls are never executed on this path, so the run ends with a dead function call, an empty streamed answer, and the stream callback clobbering the tool loop's settled text with '' (deployed chat rendered {"content": ""}). Force tool_choice 'none' / functionCallingConfig NONE on the regeneration and keep the tool loop's settled answer whenever the stream ends without text. * feat(openai): settled tool chips on the regenerated answer stream The silent Responses tool loop has no live stream while tools run, so opted-in consumers saw no tool chips at all for OpenAI. The loop now records each executed call and prepends settled tool_call_start/end pairs (name + status only) to the agent-events stream ahead of the regenerated answer. Runs without a sink never see these events, so legacy output is unchanged. * fix(providers): apply the regeneration guard fleet-wide Audit of every silent tool loop for the same race fixed for OpenAI/Gemini (final regenerated stream re-calls a tool that is never executed, ending with an empty answer that clobbers the settled text): - anthropic (both implementations): tool_choice {type:'none'} on the regeneration (tools must stay — history carries tool_use blocks) + keep the settled answer when the stream ends without text - groq: was re-applying the ORIGINAL tool_choice, so forced-tool runs re-forced the tool on the regeneration — guaranteed dead call; now 'none' + fallback - deepseek, mistral, cerebras, azure-openai (legacy chat path), openrouter, xai: 'auto' -> 'none' + fallback - bedrock: fallback only — Bedrock's ToolChoice has no 'none' and toolConfig is required when history carries toolUse blocks Already guarded (no change): meta, sakana, nvidia, vllm, litellm, baseten, together, fireworks, kimi, zai, ollama. --- .../en/workflows/deployment/agent-events.mdx | 2 +- apps/sim/providers/anthropic/core.ts | 25 +++- apps/sim/providers/azure-openai/index.ts | 13 +- apps/sim/providers/bedrock/index.ts | 11 +- apps/sim/providers/cerebras/index.ts | 13 +- apps/sim/providers/deepseek/index.ts | 14 +- apps/sim/providers/gemini/core.ts | 18 ++- apps/sim/providers/groq/index.ts | 14 +- apps/sim/providers/mistral/index.ts | 13 +- .../providers/openai/core.reasoning.test.ts | 75 ++++++++++- apps/sim/providers/openai/core.ts | 123 ++++++++++++++---- apps/sim/providers/openrouter/index.ts | 13 +- apps/sim/providers/xai/index.ts | 13 +- 13 files changed, 291 insertions(+), 56 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx index f3110dc995d..a3c9d72a627 100644 --- a/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx @@ -80,7 +80,7 @@ Per-model support is generated from the model registry on the [Agent block page] |--------|----------|------------| | Anthropic / Azure Anthropic | Yes (incl. redacted blocks in traces). The newest Claude generations omit full thinking; Sim requests summarized thinking for them on streaming runs | Yes | | Gemini / Vertex | Yes when a thinking level is set (thought summaries requested on agent-events runs) | Yes | -| OpenAI Responses | Reasoning **summaries** when streamed (requires OpenAI organization verification; unverified orgs fall back to no summaries) | Silent tool loop today (answer streams; chips may be absent) | +| OpenAI Responses | Reasoning **summaries** when streamed (requires OpenAI organization verification; unverified orgs fall back to no summaries) | Silent tool loop; tool chips arrive settled (start + end together) once tools finish, ahead of the streamed answer — no live in-progress chips yet | | OpenAI-compat (Groq, DeepSeek, …) | Only if vendor streams `reasoning` / `reasoning_content` | Live loop where wired (e.g. Groq, DeepSeek) | | Bedrock | Not invented | Yes when streaming tool loop is used | diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 8dbe657ef50..787e8eb4882 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -852,11 +852,17 @@ export async function executeAnthropicProviderRequest( const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + /** + * The regeneration exists purely to stream the settled answer as prose — + * streamed tool_use is never executed on this path. `tools` must stay + * (history contains tool_use blocks) but tool choice is pinned to none; + * with tools present and no choice, `auto` would let the model re-call. + */ const streamingPayload = { ...payload, messages: currentMessages, stream: true, - tool_choice: undefined, + tool_choice: payload.tools?.length ? ({ type: 'none' } as const) : undefined, } const streamResponse = await anthropic.messages.create( @@ -890,7 +896,12 @@ export async function executeAnthropicProviderRequest( createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, ({ content: streamContent, usage, thinking }) => { - output.content = streamContent + if (!streamContent && content) { + logger.warn( + `${providerLabel} final stream produced no text; keeping tool-loop answer` + ) + } + output.content = streamContent || content output.tokens = { input: tokens.input + usage.input_tokens, output: tokens.output + usage.output_tokens, @@ -1286,11 +1297,12 @@ export async function executeAnthropicProviderRequest( if (request.stream) { logger.info(`Using streaming for final ${providerLabel} response after tool processing`) + /** Same regeneration guard as the primary path: prose only, no re-calls. */ const streamingPayload = { ...payload, messages: currentMessages, stream: true, - tool_choice: undefined, + tool_choice: payload.tools?.length ? ({ type: 'none' } as const) : undefined, } const streamResponse = await anthropic.messages.create( @@ -1324,7 +1336,12 @@ export async function executeAnthropicProviderRequest( createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, ({ content: streamContent, usage, thinking }) => { - output.content = streamContent + if (!streamContent && content) { + logger.warn( + `${providerLabel} final stream produced no text; keeping tool-loop answer` + ) + } + output.content = streamContent || content output.tokens = { input: tokens.input + usage.input_tokens, output: tokens.output + usage.output_tokens, diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 053d375a9c2..9280f8d0ee4 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -477,10 +477,14 @@ async function executeChatCompletionsRequest( const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + /** + * The regeneration exists purely to stream the settled answer as prose — + * streamed tool_calls are never executed on this path. + */ const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, messages: currentMessages, - tool_choice: 'auto', + tool_choice: 'none', stream: true, stream_options: { include_usage: true }, } @@ -520,8 +524,11 @@ async function executeChatCompletionsRequest( : undefined, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { - output.content = content + createReadableStreamFromAzureOpenAIStream(streamResponse, (streamedContent, usage) => { + if (!streamedContent && content) { + logger.warn('Azure OpenAI final stream produced no text; keeping tool-loop answer') + } + output.content = streamedContent || content output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 15ae2bdaa70..1609080585d 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -942,7 +942,16 @@ export const bedrockProvider: ProviderConfig = { streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromBedrockStream(bedrockStream, (streamContent, usage) => { - output.content = streamContent + /** + * Bedrock's ToolChoice has no `none`, and toolConfig is required + * when history carries toolUse blocks — the regeneration can + * re-call a tool that is never executed on this path. Keep the + * tool loop's settled answer when the stream ends without text. + */ + if (!streamContent && content) { + logger.warn('Bedrock final stream produced no text; keeping tool-loop answer') + } + output.content = streamContent || content output.tokens = { input: tokens.input + usage.inputTokens, output: tokens.output + usage.outputTokens, diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index 992d4ffdb5a..ca9c35a0e80 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -448,10 +448,14 @@ export const cerebrasProvider: ProviderConfig = { if (request.stream) { logger.info('Using streaming for final Cerebras response after tool processing') + /** + * The regeneration exists purely to stream the settled answer as prose — + * streamed tool_calls are never executed on this path. + */ const streamingPayload = { ...payload, messages: currentMessages, - tool_choice: 'auto', + tool_choice: 'none', stream: true, } @@ -495,8 +499,11 @@ export const cerebrasProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { - output.content = content + createReadableStreamFromCerebrasStream(streamResponse, (streamedContent, usage) => { + if (!streamedContent && content) { + logger.warn('Cerebras final stream produced no text; keeping tool-loop answer') + } + output.content = streamedContent || content output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 5d942699409..198fbf2d2c5 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -544,10 +544,15 @@ export const deepseekProvider: ProviderConfig = { if (request.stream) { logger.info('Using streaming for final DeepSeek response after tool processing') + /** + * The regeneration exists purely to stream the settled answer as prose — + * streamed tool_calls are never executed on this path; with `auto` a + * model can re-call and end the stream with no text. + */ const streamingPayload = { ...payload, messages: currentMessages, - tool_choice: 'auto', + tool_choice: 'none', stream: true, } @@ -594,8 +599,11 @@ export const deepseekProvider: ProviderConfig = { createReadableStreamFromDeepseekStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, - (content, usage, thinking) => { - output.content = content + (streamedContent, usage, thinking) => { + if (!streamedContent && content) { + logger.warn('DeepSeek final stream produced no text; keeping tool-loop answer') + } + output.content = streamedContent || content output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 00732bac295..e634b6d0768 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -1239,8 +1239,21 @@ export async function executeGeminiRequest( request.responseFormat.schema ) as Schema } + } else if (nextConfig.tools) { + /** + * The regeneration exists purely to stream the settled answer as + * prose — streamed function calls are never executed. With AUTO the + * model can re-decide to call a tool here, ending the stream with a + * dead functionCall and an empty answer. + */ + nextConfig.toolConfig = { + functionCallingConfig: { mode: FunctionCallingConfigMode.NONE }, + } } + /** Settled answer from the check response — kept if the stream ends without text. */ + const checkAnswer = extractTextContent(checkResponse.candidates?.[0]) + // Capture accumulated cost before streaming const accumulatedCost = { input: state.cost.input, @@ -1267,7 +1280,10 @@ export async function executeGeminiRequest( const stream = createReadableStreamFromGeminiStream( streamGenerator, (streamContent: string, usage: GeminiUsage, thinking?: string) => { - streamingResult.execution.output.content = streamContent + if (!streamContent && checkAnswer) { + logger.warn('Gemini final stream produced no text; keeping tool-loop answer') + } + streamingResult.execution.output.content = streamContent || checkAnswer streamingResult.execution.output.tokens = { input: accumulatedTokens.input + usage.promptTokenCount, output: accumulatedTokens.output + usage.candidatesTokenCount, diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 539f8eede87..78d3d3b0e14 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -499,10 +499,15 @@ export const groqProvider: ProviderConfig = { if (request.stream) { logger.info('Using streaming for final Groq response after tool processing') + /** + * The regeneration exists purely to stream the settled answer as prose — + * streamed tool_calls are never executed on this path (re-applying the + * original forced tool_choice here would even guarantee a dead call). + */ const streamingPayload = { ...payload, messages: currentMessages, - tool_choice: originalToolChoice || 'auto', + tool_choice: 'none' as const, stream: true, } @@ -549,8 +554,11 @@ export const groqProvider: ProviderConfig = { createReadableStreamFromGroqStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes streamResponse as unknown as AsyncIterable, - (content, usage, thinking) => { - output.content = content + (streamedContent, usage, thinking) => { + if (!streamedContent && content) { + logger.warn('Groq final stream produced no text; keeping tool-loop answer') + } + output.content = streamedContent || content output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 4d95b1e96ad..605967bb546 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -441,10 +441,14 @@ export const mistralProvider: ProviderConfig = { const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + /** + * The regeneration exists purely to stream the settled answer as prose — + * streamed tool_calls are never executed on this path. + */ const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, messages: currentMessages, - tool_choice: 'auto', + tool_choice: 'none', stream: true, } const streamResponse = await mistral.chat.completions.create( @@ -483,8 +487,11 @@ export const mistralProvider: ProviderConfig = { : undefined, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromMistralStream(streamResponse, (content, usage) => { - output.content = content + createReadableStreamFromMistralStream(streamResponse, (streamedContent, usage) => { + if (!streamedContent && content) { + logger.warn('Mistral final stream produced no text; keeping tool-loop answer') + } + output.content = streamedContent || content output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts index 887dcfbf766..06f5e926331 100644 --- a/apps/sim/providers/openai/core.reasoning.test.ts +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -6,9 +6,10 @@ * without explicit effort keep a reasoning-free payload, and the * unverified-organization 400 falls back to a summary-free retry. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { executeResponsesProviderRequest } from '@/providers/openai/core' import type { ProviderRequest } from '@/providers/types' +import { executeTool } from '@/tools' vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) @@ -162,4 +163,76 @@ describe('executeResponsesProviderRequest reasoning payload', () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) expect(body.reasoning).toBeUndefined() }) + + describe('final regenerated stream after the tool loop', () => { + const TOOL_CALL_RESPONSE = { + id: 'resp_tool', + status: 'completed', + output: [{ type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: '{}' }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + } + + const SETTLED_ANSWER_RESPONSE = { + id: 'resp_answer', + status: 'completed', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Settled answer from loop' }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + } + + /** An SSE stream that ends without any output_text (dead function_call turn). */ + function emptySseResponse() { + return new Response('data: {"type":"response.completed"}\n\ndata: [DONE]\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + } + + async function collect(stream: ReadableStream) { + const events: unknown[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events + } + + it('forces tool_choice none and keeps the tool-loop answer when the stream has no text', async () => { + ;(executeTool as Mock).mockResolvedValue({ success: true, output: { results: [] } }) + fetchMock + .mockResolvedValueOnce(jsonResponse(TOOL_CALL_RESPONSE)) + .mockResolvedValueOnce(jsonResponse(SETTLED_ANSWER_RESPONSE)) + .mockResolvedValueOnce(emptySseResponse()) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream; execution: { output: { content: string } } } + + expect(fetchMock).toHaveBeenCalledTimes(3) + const streamBody = JSON.parse(fetchMock.mock.calls[2][1].body as string) + expect(streamBody.stream).toBe(true) + // The regeneration exists to stream prose; streamed calls are never executed. + expect(streamBody.tool_choice).toBe('none') + + const events = await collect(result.stream) + // Settled chips for the silent loop's executed calls ride ahead of the answer. + expect(events[0]).toEqual({ type: 'tool_call_start', id: 'call_1', name: 'exa_search' }) + expect(events[1]).toEqual({ + type: 'tool_call_end', + id: 'call_1', + name: 'exa_search', + status: 'success', + }) + expect(result.execution.output.content).toBe('Settled answer from loop') + }) + }) }) diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index ce8b1c2284b..d7cbce000a8 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -3,6 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import type OpenAI from 'openai' import type { IterationToolCall, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' @@ -385,6 +386,12 @@ export async function executeResponsesProviderRequest( const toolCalls = [] const toolResults: Record[] = [] + /** + * Executed calls in completion order, for settled tool chips on the + * regenerated answer stream (the silent loop has no live stream to emit + * lifecycle events on while tools actually run). + */ + const toolLifecycle: Array<{ id: string; name: string; status: ToolCallEndStatus }> = [] let iterationCount = 0 let modelTime = firstResponseTime let toolsTime = 0 @@ -524,6 +531,12 @@ export async function executeResponsesProviderRequest( success: result.success, }) + toolLifecycle.push({ + id: toolCall.id, + name: toolName, + status: result.success ? 'success' : 'error', + }) + currentInput.push({ type: 'function_call_output', call_id: toolCall.id, @@ -701,8 +714,13 @@ export async function executeResponsesProviderRequest( const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - // For Azure with deferred format in streaming mode, include the format in the streaming call - const streamOverrides: Record = { stream: true, tool_choice: 'auto' } + /** + * The regeneration exists purely to stream the settled answer as prose — + * streamed function calls are never executed. With `tool_choice: 'auto'` + * a reasoning model can re-decide to call a tool here, ending the stream + * with a dead function_call and an empty answer. + */ + const streamOverrides: Record = { stream: true, tool_choice: 'none' } if (deferredTextFormat) { streamOverrides.text = { ...((basePayload.text as Record) ?? {}), @@ -734,35 +752,86 @@ export async function executeResponsesProviderRequest( }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromResponses(streamResponse, (content, usage, thinking) => { - output.content = content - output.tokens = { - input: tokens.input + (usage?.promptTokens || 0), - output: tokens.output + (usage?.completionTokens || 0), - total: tokens.total + (usage?.totalTokens || 0), - } + createStream: ({ output }) => { + const answerStream = createReadableStreamFromResponses( + streamResponse, + (streamedContent, usage, thinking) => { + /** + * Belt-and-braces for the regeneration ending without text: keep + * the tool loop's settled answer instead of clobbering it with an + * empty string (clients then render it from the final envelope). + */ + if (!streamedContent && content) { + logger.warn( + `${config.providerLabel} final stream produced no text; keeping tool-loop answer` + ) + } + output.content = streamedContent || content + output.tokens = { + input: tokens.input + (usage?.promptTokens || 0), + output: tokens.output + (usage?.completionTokens || 0), + total: tokens.total + (usage?.totalTokens || 0), + } - const streamCost = calculateCost( - request.model, - usage?.promptTokens || 0, - usage?.completionTokens || 0 - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } + const streamCost = calculateCost( + request.model, + usage?.promptTokens || 0, + usage?.completionTokens || 0 + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } - if (thinking) { - const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') - if (lastModel) { - lastModel.thinkingContent = thinking + if (thinking) { + const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') + if (lastModel) { + lastModel.thinkingContent = thinking + } } } - }), + ) + + if (toolLifecycle.length === 0) { + return answerStream + } + + /** + * Settled tool chips ride ahead of the answer: the silent loop's + * calls already completed, so opted-in consumers get start+end pairs + * (name + status only) before the regenerated text streams. Runs + * without a sink never see these events (the byte projection ignores + * non-text), so legacy output is unchanged. + */ + const answerReader = answerStream.getReader() + return new ReadableStream({ + start(controller) { + for (const call of toolLifecycle) { + controller.enqueue({ type: 'tool_call_start', id: call.id, name: call.name }) + controller.enqueue({ + type: 'tool_call_end', + id: call.id, + name: call.name, + status: call.status, + }) + } + }, + async pull(controller) { + const { done, value } = await answerReader.read() + if (done) { + controller.close() + return + } + controller.enqueue(value) + }, + cancel(reason) { + return answerReader.cancel(reason) + }, + }) + }, }) return streamingResult diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 0508b28c2ce..72780553942 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -430,10 +430,14 @@ export const openRouterProvider: ProviderConfig = { if (request.stream) { const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + /** + * The regeneration exists purely to stream the settled answer as prose — + * streamed tool_calls are never executed on this path. + */ const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = { ...payload, messages: [...currentMessages], - tool_choice: 'auto', + tool_choice: 'none', stream: true, stream_options: { include_usage: true }, } @@ -474,8 +478,11 @@ export const openRouterProvider: ProviderConfig = { toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - output.content = content + createReadableStreamFromOpenAIStream(streamResponse, (streamedContent, usage) => { + if (!streamedContent && content) { + logger.warn('OpenRouter final stream produced no text; keeping tool-loop answer') + } + output.content = streamedContent || content output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 6d83e03b37d..974bbe87565 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -480,10 +480,14 @@ export const xAIProvider: ProviderConfig = { stream: true, } } else { + /** + * The regeneration exists purely to stream the settled answer as + * prose — streamed tool_calls are never executed on this path. + */ finalStreamingPayload = { ...basePayload, messages: currentMessages, - tool_choice: 'auto', + tool_choice: 'none', tools: preparedTools?.tools, stream: true, } @@ -532,8 +536,11 @@ export const xAIProvider: ProviderConfig = { createReadableStreamFromXAIStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content + (streamedContent, usage) => { + if (!streamedContent && content) { + logger.warn('xAI final stream produced no text; keeping tool-loop answer') + } + output.content = streamedContent || content output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, From e6eef4a4bfb737c60864fdb76c55760af2072857 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 23 Jul 2026 21:23:26 -0700 Subject: [PATCH 15/32] feat(library): What Is an MCP Server? (#5919) Co-authored-by: Sim Pi Agent --- .../library/what-is-an-mcp-server/index.mdx | 124 ++++++++++++++++++ .../library/what-is-an-mcp-server/cover.jpg | Bin 0 -> 30745 bytes 2 files changed, 124 insertions(+) create mode 100644 apps/sim/content/library/what-is-an-mcp-server/index.mdx create mode 100644 apps/sim/public/library/what-is-an-mcp-server/cover.jpg diff --git a/apps/sim/content/library/what-is-an-mcp-server/index.mdx b/apps/sim/content/library/what-is-an-mcp-server/index.mdx new file mode 100644 index 00000000000..37102908960 --- /dev/null +++ b/apps/sim/content/library/what-is-an-mcp-server/index.mdx @@ -0,0 +1,124 @@ +--- +slug: what-is-an-mcp-server +title: 'What Is an MCP Server?' +description: 'Learn what an MCP server is, how Model Context Protocol discovery, tools, and transports work, and how Sim consumes and publishes MCP tools for AI agents.' +date: 2026-07-24 +updated: 2026-07-24 +authors: + - andrew +readingTime: 10 +tags: [MCP, AI Agents, Model Context Protocol, Sim] +ogImage: /library/what-is-an-mcp-server/cover.jpg +canonical: https://www.sim.ai/library/what-is-an-mcp-server +draft: false +faq: + - q: "What does an MCP server do?" + a: "An MCP server exposes tools, resources, and prompts through the Model Context Protocol so a compatible AI client can discover and use those capabilities through a standard interface." + - q: "How is an MCP server different from a REST API?" + a: "A REST integration usually requires client-specific endpoint, schema, and authentication code. MCP adds a standard discovery and invocation layer, allowing compatible clients to list a server's capabilities and call its tools through the same protocol." + - q: "Which MCP transport should I use?" + a: "Use stdio when the client and server run on the same machine. Use Streamable HTTP for remote or multi-client deployments. HTTP+SSE is the deprecated legacy transport and is mainly relevant for backwards compatibility." + - q: "Can Sim use and publish MCP tools?" + a: "Yes. Sim can connect to external MCP servers and execute their tools inside workflows. It can also expose deployed workflows as tools on a workflow MCP server for compatible clients to discover and call." +--- + +## TL;DR + +An MCP server is a program that exposes tools, data, and prompts to AI models through the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-06-18/architecture), a standard interface any MCP-compatible client can call. + +- An MCP server lets an LLM or agent [discover and call external capabilities](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) without a developer hardcoding each one. +- [Anthropic introduced and open-sourced MCP in November 2024](https://www.anthropic.com/news/model-context-protocol). It uses [JSON-RPC 2.0 messages](https://modelcontextprotocol.io/specification/2025-06-18/basic) and is supported by clients including [Claude Desktop](https://modelcontextprotocol.io/quickstart/user), [Cursor](https://docs.cursor.com/context/model-context-protocol), and [VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). +- MCP replaces dozens of bespoke API integrations with a [single client-server architecture](https://modelcontextprotocol.io/specification/2025-06-18/architecture), so compatible clients can use the same server. +- Sim works in both directions. It calls external MCP servers as tools, and it publishes deployed Sim workflows as tools that other AI systems can call through MCP. +- The [spec defines two current transports](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports), stdio for local processes and Streamable HTTP for remote calls. The older HTTP+SSE transport is deprecated and should not be used for new builds. +- The comparison table below shows how MCP servers, traditional API integrations, and Sim workflows-as-MCP-tools differ on setup effort, discovery, and reusability. + +## What is an MCP server? + +An MCP server is a program that exposes tools, data sources, and prompts to an AI model through the [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-06-18/architecture), a shared standard for connecting language models to external capabilities. + +The point of an MCP server is to give an LLM or agent a consistent way to reach the outside world. Instead of writing custom glue for every API a model needs, you run an MCP server that advertises its capabilities in a format the model understands. An [MCP-compatible client](https://modelcontextprotocol.io/specification/2025-06-18/architecture) can then connect, list what the server offers, and call it. If you are new to the software on the other side of that connection, this guide to [AI agents and chatbots](https://www.sim.ai/library/ai-agent-vs-chatbot) explains why tool use matters. + +[Model Context Protocol](https://modelcontextprotocol.io) was [introduced and open-sourced by Anthropic in November 2024](https://www.anthropic.com/news/model-context-protocol) to solve a coordination problem. Each new tool, database, or service used to require its own integration written against a specific model or framework, and none of that work carried over. MCP defines one interface for all of them, built on [JSON-RPC 2.0](https://modelcontextprotocol.io/specification/2025-06-18/basic), so a server built once works with clients that speak the protocol. Adoption moved fast: [Claude Desktop](https://modelcontextprotocol.io/quickstart/user), [Cursor](https://docs.cursor.com/context/model-context-protocol), and [VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) all document MCP client support. + +A single MCP server can expose three kinds of capability. [**Tools**](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) are actions the model can invoke, like sending an email or querying a database. [**Resources**](https://modelcontextprotocol.io/specification/2025-06-18/server/resources) are data the model can read, like files or records. [**Prompts**](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts) are reusable templates the server offers to guide a task. The client asks the server what it supports, and the model or user decides what to call. + +Because the interface is standardized, the same MCP server can work across [compatible clients](https://modelcontextprotocol.io/specification/2025-06-18/architecture) without modification. A server you build for one agent can answer calls from another when it connects and negotiates a supported protocol version and capability set. + +## How does an MCP server differ from a traditional API integration? + +A traditional API integration forces a developer to hardcode each endpoint by hand, while an MCP server lets a client [discover the available tools at runtime](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). When you connect to a REST API, you read the docs, map the request and response shapes, and write code specific to that one service. Connect to a second service, and you repeat the process with a different auth scheme and schema. An MCP server flips this. Through the protocol's [tool discovery flow](https://modelcontextprotocol.io/specification/2025-06-18/server/tools), a client queries the server and receives tool names, descriptions, and input schemas. It can then call those tools without the developer wiring each underlying endpoint into that client ahead of time. The server owns its tool definitions rather than forcing every client to maintain a field-by-field mapping. + +Authentication becomes a per-server concern from the client's perspective. Every conventional API may use a different scheme, which can mean new token handling, headers, and refresh logic for each integration. For HTTP transports, MCP defines an [authorization framework based on OAuth 2.1](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization), so a client authenticates to the MCP server rather than directly implementing auth for every downstream tool it exposes. The server still has to manage its downstream credentials. Adding another tool behind an existing server does not necessarily require new auth code in the client. REST APIs can publish machine-readable descriptions through formats such as the [OpenAPI Specification](https://spec.openapis.org/oas/latest.html), but that description is separate from the invocation layer and is not shared automatically by every client. MCP makes capability discovery part of the protocol, which is what lets exposed tools travel across compatible clients. + +## How is an MCP server built? + +Three things determine how a server behaves once an agent connects to it. Discovery answers "what can you do?", tool wrapping defines the individual actions, and the transport layer decides how bytes move between client and server. Build all three well and any compatible client can use your server without a custom adapter. + +### Discovery + +[Discovery](https://modelcontextprotocol.io/specification/2025-06-18/architecture) lets a client query an MCP server for its available tools, resources, and prompts at connection time. The protocol provides [list operations and capability negotiation](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle), and a [tool list](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools) includes each tool's name, description, and input schema. An agent reads that response and decides which tool to call, so endpoint shapes do not have to be hardcoded into the client. [Servers can also notify clients that a tool list changed](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#list-changed-notification) when both sides declare support for that capability. + +### Tool wrapping + +Tool wrapping turns an existing function, database query, or REST API into a callable MCP tool. You write a thin adapter that declares the tool's schema and maps the model's arguments onto whatever the underlying code expects. A weather API becomes a `get_forecast` tool, a SQL query becomes a `search_orders` tool, and the agent calls both through the same [MCP tool invocation interface](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). Wrapping is where most of your build effort goes, since it defines the contract the model reasons about. + +### Transport options + +The [MCP transport specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) defines two current transports. A third, the original HTTP+SSE design, is deprecated and should not be used for new builds. + +- [**stdio**](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio) runs the server as a local subprocess and pipes JSON-RPC messages over standard input and output. Use it for local tools, desktop clients, and processes on the same machine where you want no network setup. Messages are newline-delimited UTF-8, and the server can write logs to stderr. +- [**Streamable HTTP**](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) exposes a single MCP endpoint that handles both directions. The client POSTs JSON-RPC requests to it, and the server returns either a standard HTTP response or an SSE stream. This is the transport for remote, multi-client, or horizontally scaled deployments. +- [**HTTP+SSE (legacy, deprecated)**](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#backwards-compatibility) was the original network design. It used separate SSE and POST endpoints. Streamable HTTP replaced it, and the specification retains compatibility guidance for clients and servers that still need to interoperate with older implementations. + +A point worth clearing up, because it trips up a lot of teams: "SSE" and "HTTP" are not two peer transports sitting alongside stdio. [SSE is a response mechanism used by Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http), while "the SSE transport" commonly refers to the deprecated two-endpoint HTTP+SSE design. If you are building something new, use Streamable HTTP. If you maintain a legacy server, plan migration around the clients you need to support. + +You can wire the same set of wrapped tools to either current transport without rewriting the tools themselves, since the [protocol architecture separates server capabilities from transports](https://modelcontextprotocol.io/specification/2025-06-18/architecture). A common pattern is stdio for local development and Streamable HTTP for production. + +The [Sim MCP docs](https://docs.sim.ai/mcp) show how discovery, wrapping, and transport selection appear when you connect a server in practice, including which transport to pick for local versus deployed setups. + +## Can Sim act as both an MCP client and an MCP server? + +Yes. Sim works in both directions, which means you can consume external tools and expose your own from the same platform. + +As an MCP client, Sim calls external MCP servers as tools inside your workflows. You configure a server in a Sim workspace, select its tools for an agent or MCP block, and Sim can invoke those published tools. If you run an MCP server for a database, search index, or internal service, Sim discovers its tools and calls them alongside native integrations. You get access to the MCP ecosystem without writing a custom Sim integration for each server. + +As an MCP server, Sim can publish deployed workflows as tools on a workflow MCP server that other AI systems call. Build and deploy a workflow in Sim, add it to a workflow MCP server, and a compatible client can discover and invoke it. Whatever logic you assembled inside Sim, whether it chains models, calls APIs, or runs branching decisions, appears to the caller as a tool with a defined schema. For the broader build-and-deploy flow, see [how to create an AI agent with Sim](https://www.sim.ai/library/how-to-create-an-ai-agent). + +That two-way capability changes how you compose systems. You can connect Sim to a third-party MCP server, build a workflow on top of it, and then publish that workflow as an MCP tool for something else to consume. One Sim deployment can sit in the middle of a chain, acting as a client to the servers below it and a server to the clients above it. + +The practical payoff is reuse. You build a workflow once, deploy it, and compatible clients can call it without knowing how it works internally. You skip the usual step of writing and maintaining a separate API wrapper for each consumer. + +Setup for both roles lives in the [Sim MCP docs](https://docs.sim.ai/mcp), which walk through connecting external servers as tools and publishing deployed workflows through a workflow MCP server. + +## Why teams run MCP servers on Sim + +Standing up an MCP server yourself means writing the wrapper, hosting it, handling auth, and maintaining it as the specification evolves. Sim provides a workflow layer around that work, and three qualities matter for teams that need to control their deployment. + +**It is Apache 2.0 licensed.** Sim's repository ships under Apache 2.0, a permissive open-source license that allows use, modification, commercial distribution, and sublicensing and includes an express patent grant. The practical implications are covered in [Apache 2.0 vs fair-code](https://www.sim.ai/library/apache-2-0-vs-fair-code). + +**You can run it on your own infrastructure, including isolated environments.** A self-hosted Sim deployment keeps the workflow and MCP execution layer within infrastructure you control. That matters when exposed tools touch regulated or private data. Teams comparing deployment and governance models can also review these [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). + +**Admins control access to MCP capabilities.** Sim's workspace permissions govern MCP server management, and permission-group settings can hide MCP deployment or disable MCP tools for selected users. Workflow execution traces record activity block by block, giving operators a detailed view of what the workflow did when a tool was called. + +You can build the workflow behind the server in natural language with Mothership, assemble it on the visual canvas, or define it through the API. The exposed MCP tool presents the same deployed workflow logic either way. + +## MCP server vs traditional API integration vs Sim workflow-as-MCP-tool + +Three approaches let an AI system reach an external tool, and they differ in how much work you do up front and how far the result travels. The table below compares them on setup effort, discovery support, reusability across clients, and who can call the result. + +| | Setup effort | Discovery support | Reusability across clients | Who can call it | +| --- | --- | --- | --- | --- | +| **MCP server** | Moderate. You wrap tools once behind the protocol. | [Built in](https://modelcontextprotocol.io/specification/2025-06-18/server/tools). Clients query the server for its available tools. | High. [Compatible clients](https://modelcontextprotocol.io/specification/2025-06-18/architecture) connect without a bespoke integration. | Any MCP client that supports the server's transport and protocol version. | +| **Traditional API integration** | High. You implement endpoints, auth flows, and schemas for each client integration. | Not required by REST itself. Discovery depends on documentation or an additional description format. | Low by default. Each new client needs integration code. | Applications with code written for that API. | +| **Sim workflow-as-MCP-tool** | Low. You build and deploy a workflow, then add it to a workflow MCP server. | Built in. Sim exposes the deployed workflow as a discoverable tool. | High. Compatible AI systems can connect to its MCP server. | MCP clients that can reach and authenticate to the Sim endpoint. | + +A traditional API integration can lock a tool to one application and force the next team to repeat the client work. An MCP server breaks that pattern by exposing tools through a discovery layer compatible clients can read, so one server serves many callers. A Sim workflow-as-MCP-tool pushes the effort down further: you build and deploy the logic in Sim, then publish it through a workflow MCP server. See [docs.sim.ai/mcp](https://docs.sim.ai/mcp) for setup details. + +## Conclusion + +MCP servers solve a real problem for anyone building agentic systems. Instead of writing a custom integration for every tool an agent needs, you expose those tools through one standard interface that compatible clients can discover and call. That standardization is why the protocol spread quickly after Anthropic released it. + +Sim gives you both directions in one place. You can wire external MCP servers into a workflow as tools, and you can expose deployed Sim workflows through an MCP server for other AI systems to call, on infrastructure you control under the Apache 2.0 license. + +Start by reading [docs.sim.ai/mcp](https://docs.sim.ai/mcp) and deploy your first MCP server today. diff --git a/apps/sim/public/library/what-is-an-mcp-server/cover.jpg b/apps/sim/public/library/what-is-an-mcp-server/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e87140b7528d403bfcd16c55f5c1f6c79c3a092c GIT binary patch literal 30745 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

bPy7)6hj}DmH?Eq z0H_-mywX|`+F*KH+?kv^Lz_TI`KmZm5jR(H#P$dX959isgj-+_p29!nBJRhF6&OUE zHA$ulX|TljX_^8L3%p%a(>MDs?37r2R$NwG&KtoDbL` z)N(ET+!5@j?tiJ;_Q`2Lxe)0X_^q_%H%aQ621nrHT_QW+;ms5Vh%RFxZiS5X&^%1n zHVcMNSHIiHaaQ-xl@tDGg0<4GSSOFvbHZSD6v9I*x}98Pa(+gt=*ti62pVQ7mlzy&RaDQ=IKOqz z5LSFEQ#?3XBXy6GpZ4NRRV{@!RASBQ_(eIp#SV7293${6 TH{g2BQ+5=$S@i`nk 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 literal 0 HcmV?d00001 From 444c415a0baa192ef71a8d2cf2e6dddf2a98fa7e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 23 Jul 2026 23:08:12 -0700 Subject: [PATCH 16/32] improvement(data-retention): docs for overrides + PII redaction, fix wedged saves (#5905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(data-retention): clamp sub-day retention values so saves aren't wedged A stored value under 12 hours rounded to '0' days on load and was re-sent as 0, which the contract rejects (min 24) — blocking every save on the page, including unrelated fields. Clamp hours->days into the contract's range on read, and throw instead of emitting 0/NaN on write. * improvement(docs): rewrite data retention for workspace overrides + PII redaction - Document PII redaction (Logs / Workflow input / Block outputs stages, entity types, languages, custom regex patterns) - Replace the stale 'no per-workspace overrides' section with the retention-policies list and override inheritance - Correct log retention (also covers background job logs) and soft deletion (adds Chat conversations, KB documents) - Add PII + override screenshots, refresh the main one --- .../en/platform/enterprise/data-retention.mdx | 126 +++++++++++++++--- .../enterprise/data-retention-override.png | Bin 0 -> 292061 bytes .../static/enterprise/data-retention-pii.png | Bin 0 -> 324865 bytes .../static/enterprise/data-retention.png | Bin 107407 -> 196552 bytes .../components/data-retention-settings.tsx | 30 ++++- 5 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 apps/docs/public/static/enterprise/data-retention-override.png create mode 100644 apps/docs/public/static/enterprise/data-retention-pii.png diff --git a/apps/docs/content/docs/en/platform/enterprise/data-retention.mdx b/apps/docs/content/docs/en/platform/enterprise/data-retention.mdx index ecd4c4321d4..c54118f790d 100644 --- a/apps/docs/content/docs/en/platform/enterprise/data-retention.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/data-retention.mdx @@ -1,12 +1,18 @@ --- title: Data Retention -description: Control how long execution logs, deleted resources, and copilot data are kept before permanent deletion +description: Control how long execution logs, deleted resources, and Chat data are kept before permanent deletion — and redact PII from workflow data --- +import { Callout } from 'fumadocs-ui/components/callout' import { FAQ } from '@/components/ui/faq' import { Image } from '@/components/ui/image' -Data Retention lets organization owners and admins on Enterprise plans configure how long three categories of data are kept before they are permanently deleted. The configuration applies to every workspace in the organization. +Data Retention lets organization owners and admins on Enterprise plans control two things: + +1. **Retention periods** — how long execution logs, soft-deleted resources, and Chat data are kept before they are permanently deleted. +2. **PII redaction** — masking personally identifiable information out of your workflow data at up to three points: workflow input, block outputs, and logs. + +Both are configured once at the **organization level** and apply to every workspace, with optional **per-workspace overrides** for workspaces that need different rules. --- @@ -14,21 +20,31 @@ Data Retention lets organization owners and admins on Enterprise plans configure Go to **Settings → Enterprise → Data Retention** in your workspace. -Data Retention settings showing three dropdowns — Log retention, Soft deletion cleanup, and Task cleanup — each set to Forever +Data Retention settings showing the Retention policies list with the Organization default row and its summary of retention periods and PII stages + +The page shows your **retention policies** as a list: -You will see three independent settings, each with the same set of options: **1 day, 3 days, 7 days, 14 days, 30 days, 60 days, 90 days, 180 days, 1 year, 5 years,** or **Forever**. +- The **Organization** row (tagged *Default*) holds the settings that apply to every workspace without its own override. +- Each **workspace override** row below it applies to one or more specific workspaces. -Setting a period to **Forever** means that category of data is never automatically deleted. +Open a row to edit it, or click **Add override** to create a workspace override. Each policy has a **Retention** section, and — when PII redaction is enabled — a **PII redaction** section. --- -## Settings +## Retention periods + +Each policy has three independent retention settings, each with the same set of options: **1 day, 3 days, 7 days, 14 days, 30 days, 60 days, 90 days, 180 days, 1 year, 5 years,** or **Forever**. + +Setting a period to **Forever** means that category of data is never automatically deleted. On a workspace override, each field can also be set to **Inherit from organization** to fall back to the organization default for just that field. ### Log retention -Controls how long **workflow execution logs** are kept. +Controls how long **execution logs** are kept. -When the retention period expires, execution log records are permanently deleted, along with any files associated with those executions stored in cloud storage. +When the retention period expires, log records are permanently deleted, along with any files associated with those executions in cloud storage. This covers: + +- Workflow execution logs +- Background job logs (deployed APIs, schedules, and webhooks) ### Soft deletion cleanup @@ -40,33 +56,83 @@ Resources covered: - Workflows - Workflow folders -- Knowledge bases +- Knowledge bases (and their documents) - Tables - Files - MCP server configurations - Agent memory +- Chat conversations ### Task cleanup -Controls how long **Mothership data** is kept, including: +Controls how long **Chat data** is kept, including: -- Copilot chats and run history +- Chat conversations and run history - Run checkpoints and async tool calls -- Inbox tasks (Sim Mailer) +- Inbox tasks Each setting is independent. You can configure a short log retention period alongside a long soft deletion cleanup period, or any combination that fits your compliance requirements. --- -## Organization-wide configuration +## PII redaction + +When PII redaction is enabled for your organization, each policy gains a **PII redaction** section that masks personally identifiable information — names, emails, phone numbers, credit-card numbers, national IDs, and more — from your workflow data. Sim detects and masks PII with [Microsoft Presidio](https://microsoft.github.io/presidio/); each match is replaced with a placeholder token such as ``. + +Redaction is configured per **stage** — the point in a run where masking is applied. Select a stage, then choose which entity types and custom patterns to redact for it: + +| Stage | What it does | +|---|---| +| **Logs** | Redacts workflow logs when they are persisted. Observability-only — the workflow still runs on the original data. | +| **Workflow input** | Redacts the workflow input **before execution**. The workflow runs on the masked data, which may change its output. | +| **Block outputs** | Masks every block output **before the next block reads it**. Runs in-flight and may change output and execution performance. | + +PII redaction section with the Block outputs stage selected, showing the entity type grid grouped by Common, United States, United Kingdom, and Other regions + + +The **Workflow input** and **Block outputs** stages alter what the workflow computes on, not just what is stored. Redacted data is masked during the run and may affect workflow output. Enable them only where that trade-off is acceptable. + + +### Entity types and language + +For each stage, choose the **entity types** to redact from the searchable grid. They are grouped as: + +- **Common** — person name, email, phone, credit card, IP address, URL, IBAN, crypto wallet, medical license, VIN +- **United States** — SSN, passport, driver's license, bank account, ITIN +- **United Kingdom** — NHS number, National Insurance number +- **Other regions** — Singapore, Australian, and Indian identifiers + +The **Block outputs** stage is restricted to regex- and checksum-based recognizers, so it can run in-flight over large payloads without a performance penalty. Types that need name-model detection — person name, location, date or time — are not offered for that stage. + +Detection is language-aware: pick the **language** whose recognizers should apply. English, Spanish, Italian, Polish, and Finnish are supported, and the grid filters to the identifiers available for the selected language. + + +Some recognizers match loosely and over-redact — US Social Security Number, US bank account number, and Date or time have no checksum and match aggressively. Enable these only where false positives are acceptable. + + +### Custom patterns -Retention is configured at the **organization level**. A single configuration applies to every workspace in the organization — there are no per-workspace overrides. +Beyond the built-in entity types, each stage can redact anything a **regular expression** matches — employee IDs, internal URLs, ticket numbers. Give each pattern a name, a regex, and a replacement token; every match is replaced with the replacement text wrapped in angle brackets (e.g. `EMPLOYEE_ID` → ``). + +--- + +## Per-workspace overrides + +Retention and PII redaction are configured at the **organization level** and apply to every workspace by default. When a workspace needs different rules, add a **workspace override**. + +Add workspace override panel showing the workspace picker, retention fields set to Inherit from organization, and the PII redaction Inherit or Override switch + +- An override targets one or more workspaces (a workspace can belong to only one override). +- Each **retention** field either sets its own period or **inherits** the organization value. +- **PII redaction** on an override is either **Inherit** (use the organization's redaction) or **Override** (workspace-specific). Choosing Override replaces the organization's redaction rules entirely for that workspace — it is not merged stage-by-stage. + +Removing an override returns its workspaces to the organization defaults. --- ## Defaults -By default, all three settings are unconfigured — no data is automatically deleted in any category until you configure it. Setting a period to **Forever** has the same effect as leaving it unconfigured, but makes the intent explicit and allows you to change it later without saving from scratch. +By default, retention settings are unconfigured — no data is automatically deleted in any category, and no PII is redacted, until you configure it. Setting a retention period to **Forever** has the same effect as leaving it unconfigured, but makes the intent explicit and lets you change it later without configuring from scratch. --- @@ -84,13 +150,17 @@ By default, all three settings are unconfigured — no data is automatically del answer: "No. Once the soft deletion cleanup period expires and the cleanup job runs, resources are permanently deleted and cannot be recovered." }, { - question: "Does the retention period apply to all workspaces in my organization?", - answer: "Yes. Retention is configured once per organization and applies to every workspace in the organization." + question: "Does a policy apply to all workspaces?", + answer: "The organization policy applies to every workspace that does not have its own override. Add a per-workspace override to give specific workspaces different retention periods or PII redaction rules." }, { question: "What happens if I shorten the retention period?", answer: "The next cleanup job will delete any data that is older than the new, shorter period — including data that would have been kept under the previous setting. Shortening the period is irreversible for data that falls outside the new window." }, + { + question: "Does PII redaction change how my workflows run?", + answer: "The Logs stage does not — it only masks data as it is persisted, so the workflow runs on the original data. The Workflow input and Block outputs stages do: they mask data in-flight, so the workflow computes on the redacted values, which can change its output." + }, { question: "What is the minimum retention period?", answer: "1 day (24 hours)." @@ -105,10 +175,28 @@ By default, all three settings are unconfigured — no data is automatically del ## Self-hosted setup -### Environment variables +### Retention periods ```bash NEXT_PUBLIC_DATA_RETENTION_ENABLED=true +DATA_RETENTION_ENABLED=true +``` + +Once enabled, retention settings are configurable through **Settings → Enterprise → Data Retention** the same way as Sim Cloud. + +### PII redaction + +PII redaction runs against a standalone [Presidio](https://microsoft.github.io/presidio/) service. Deploy it (see `apps/pii`) and point Sim at it, then enable the redaction surfaces: + +```bash +# The Presidio service exposing /analyze and /anonymize +PII_URL=http://localhost:5001 + +# Expose the log-redaction stage and the Data Retention PII section +PII_REDACTION=true + +# Additionally expose the execution-altering stages (Workflow input, Block outputs) +PII_GRANULAR_REDACTION=true ``` -Once enabled, data retention settings are configurable through **Settings → Enterprise → Data Retention** the same way as Sim Cloud. +`PII_GRANULAR_REDACTION` layers on top of `PII_REDACTION` — with only `PII_REDACTION` enabled, just the **Logs** stage is configurable. diff --git a/apps/docs/public/static/enterprise/data-retention-override.png b/apps/docs/public/static/enterprise/data-retention-override.png new file mode 100644 index 0000000000000000000000000000000000000000..eb5771c67e5ba3dcd89676a86a8c150d2749bee5 GIT binary patch literal 292061 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

oSm7|pFtvrwM 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� zdX;YT4Zr|XWgQY!yhA9l(zDhDezWWBlG9yB&`+o`Q(J7fSHFz4|1W{fHPtg|?rgqs z;Lm!QGUYMFYIPeLcy3x^D)Md2`#&YKo+xaJ3#qYSw2;|4j}N}(Y}bPCvwUj_*!Mtl=wDTNMF*bQ2qB3~>HbHjp$zcRHkV{Km{hYYzYr`&ZHGE)G%T0e~wR zUyaiO{ka`t=JLz7^Y7l`|M5xtzM5@3{LRiM1OCdFM7pK_SIHW&&$B>eeS(A(A&0&KN+>Wjma7@aY>7> zekeJqoX8lGH?6DiBOBdUAj&4gwnsnOy0`j0LyW4sLFoo)z#v>-hYrx%^>!CU z@74Qc+4!B#%P8EYo7=#jF$W59?K(O<$xJxN6yg=oHhA!4J}Xq7(bpac83Z&xg-l^k~eBP!C&_ z${inD20z|dy1}`=m>)^(Wi0RSG6H%v|=ca|kjv~W1ggZ_4;>~fBV+qpZY|6Vo7Oz3AU@AwmkF4hCf{m?d z$n9(T9b;Rd1zm=!gXW;?RtEsr5=%anzSG}_|DW&gzkel-mx&&n=3MxwtAY2{+Yk99 z0{zkvwZSSh-=}_Eg;@K|;_e1Lj-$I-&m+g?$Fm|x);sA471gd~0B9D|hSwxueO&-XX%p6SD{)WAD+tUy&p$H&pk&c|&lA zNfAo$5nkUkT?Jh6IQOQ7lCBvo$pAy|`LVs^^|6$$#ckJn3pS-_kTaQr{ zGTCsR0?aMVKrG{q?hnCbj!(>u;m$C}T;H!+lPthZ1UCn!_CB=|>h`TH#n4Q&*(8q0j70r3D{Ds&G~ZFz^g zQ*pJifE%DXW#TA5u~k4lQCkLCFOQ=mA8La-)F;>sXaJ4MGmkWC%rkF%Q09?5ho=Wg zcGNP%H|ZcK%ZDN8&silf6I|NH|9xTp{;sVvJ&J7oNBAsYL9JpgLKZO>LHc6`5~W)Z z??IW&EsVy!^~w$0hP$_q0RH{DOgY`Ri+;M{=QOQ=U;5xXAU+>(9T|jMZ?MsDnVF30 z-CAQ6tF}&W+rd$uokzZD`SFaU87S|n5SjEI!X!O1%efYP_>nDi7}Cl-IVLUp@eQNL zLNUf9Q+5hIw;B%8TLQSFl_q1N4HI7(UG_&rnxL>EmJJW$4zOiEye{@J1fVX#VC}FC zz>?Vv?BJC#+n#~nGBbqQ>gnlM;&|9eXpS7G0631}r)f5U4$k1hc^bb&16 zP=?0{t&Pw{W<9G(z+9WbdHrzZ{ik!5Wz6krb)d?aM&la^_&Z9=2D+O-1}3=$s5p`3 z>{BXw@bj({fj^Ngf_V1G1&}>7J#4p=*J5`Q*f0wbI8cVEnO%hc3O%eR5&(#$FrL*u z4&^pRXFS~`KM5Q+M!+0J&mW4`wlW{7u#1x(dq`~WuXX{lLX-3@mV9QQ{F;8(0U%ya ztG#V8vHLnYBe!m~(wL372PY}T}#WG-RsBS2LaFzY)3yGtLW|?V23Y_ z9s3UGTj^)khSsu93vj~Y_d_r~2!JEnfKm@L{msnzzh7vU6Gty_a2t=14PxFqUh~Ls zdz9sCsO^%F3Z$hkXNL$S7DqdoIey=H(`HWc(;`dRzdht$@x@&r9(wqbF&=R&fO zU$Q%mXrXSE-5E%*x=-!!AU2?@DN|wv6?HbwAr<>WpVXHz20f)nVQpp1kH%&qAhJN* zUBo;WifMI3jMQK1xe0+$({-)Wk-`nr=PkXOx2f*l>>Y%VVsEpRF zmS(w~hcWS$bs|-?}es^Z#^@vD^UOdf}Wl&@ojG z^t6@`^+`51`I!Q=Al_pv@-4f3*+-}puZ4ze0ltY+)#Nkw%|14e}pY^Q+qcX#+-#E<}Y2f5y z^B=YgKx+HnJ|IByzvv0{yg`SEDcr70gx!~_=0gk<_Y`W*iHVPEUTjJZD_Mwl#lfi) z1YMc&w}W4IXV0U)(=%>7Ej{#yzX3Rz2aH(>&4QMa{$9iWwcP*ruh@nPikmWSHk)<0 zBW_{Z+|FNTSgjr=i3NUn(R@ng`gXwG$fK(Rh}z5cKH(_8$J@>+R3( z+E$!gV&Si{`bJ|O(O`xyb~IRKVwLj|;QJG~rrZDV)BpR08OUV>`uDjz3%ACF^AQoG zu=`Ne3*5iOR{r-#gs6zJ8AUWW>!*;mFW_WH|$l5^i zpJv2%wY#dk$rTrI#^F`*olb3HOjX~k}m&QPK((G%6$yh`R?z5miE{`o@Jo`9mC z96XtRcJsHlea?fSYlFTUb1eeNh%e3$U8-_*9u6{L|COU0(gpti!2j|8t8%36hbgl;iWmm` z_TaQLXg%>h00Mq1z%f9@517_8mwA5ANNm#}A<$@tI(^ti1?U^N*o~Uyn5pwb-m>Ed z&5^E$YgD6gJ;AV1=`Zr56+m2V%M3NuWN=`FlS<#G$@Hs~OeVXGRQHZZlp;!|*0Iv1 z3yVnLwmHH3^l&ft@DXT2BhVbkXDb@myibJZ5(?;n)!N6K8#Ng`E2Q^!fOhYa+z-U{ zFpA3)h)H~`Yv(?)676}mG@Uy)6Jz3+;EI_jlXdznf%lBwB6t?);$!{qJA+R=7i7Cc zrRUEVZyludK%DEl#w+K)~G|H7gFG-a%znZ6!R0s@S<(~LuYJzGNYAsx=YvjF7km1jbD*Q4+F z{z{S>o0z||1Rx-byB1r^tv}yqHj{u+XUf72T0p7jO*LEtgvr1vWoUH&cAU4yS3=E0tbJB^3r3eqk1iT^B!@sM* z58mCuV%q9|F-a%`beang6y0j3=T3dL0+%h8n`;*UH>yX#pfsj7(9|YmYn2NP*K`p< zXEiRC_}s$U`}7K?*1CAp6T5Tq$|5~VczU=J-x!2bQEo6RGQIhpPWN%|=mRt}CH>`W zOH9&Zy5jwh58F^az;hYe1bXzFrS5t-V>Gf$4eDCs;OGc3IM%)9dDdvYo&*fTS#Ha17;00q}IuHlMFA{$7@)`^xjx%~K{qnerG47u^pJ3L z(9}0@(Av;GKI{Nr9W>WG(NC8Km7W8??|yvo8*d<`;(y>I2Q2R4bEoqMFFpRu=WhR{to~6xW%N$$yfkX$ zf%f3`?%QPwa3_s}f3^bp_d|weEu&w+HDE?&y>c0Ou)LhWQH-Z&#R;;y@m?XTq(UHX zI>t-<(0{{y!j?QXVXN3s;SNn9aM3C4E%fx~Brwipf}Z7WW)oqc&;z4H0C}UU+7kk; zx;dr*{QEdb7RF74F$w9I+x+)JZ3_jZS?rf1CYwioU&*TOpRrxaoV;1ut%jFKk%DmC z;2H$TpqsA-xhk`_*%C(EuqIEFNye3d#=rOg8?L#KWE6^+tLPfWE%f;H@W-T3pfn=^ z%rIHsgpd^nk^b<7exrpQm7SfqKX2|{PSChQ=X&AL02&Y*0vw$m%_@C--e3vH_m}EW z+oL92PA^00B_M!_^CXM`nR^po75$J3_fe&Zv32a}1S+)0RdK+O3R8qk9o}HO6(e{1 z>dwHpX)m6moI<4_x^ZIq3#I5f?i{HJQX~G(WU_Dq5ni0@V$`Ovx_^LQ@9RGRma4ty zPj*&{P(O?7E5OF1K|v*W8#*Nk*ep81&S3xx%X13XDcK#=FyMArG)K?1H2}(Lz;g0m zw^TesDjbqMM-CJgCoQgPMDqF4-PWfQ-W}}GK8UDu@3bmo>dCQK4l{a9$3I$DH)i3U zJPX&g8K2|ZI7=Sc|jS*X|^_?^tX{)Ke(JkzEFB#A9_f^$le-hbA3(;|4H9sEtTq~_po>cqE9*YN7Q<2 zZsqc_=jcZtqLzN(11fu)b>k!eIjBkY+gnI&SgBCw<~)^D-&B8Zxr+qMT37-WSDVQm zv+}lIO3rg;c2bKZl~@@hUP9~JF3{s!0w|9|V?zgiCOkXm19pz3+VN0p4RSbmDTw7j zSHgFIpo#?&aWvBa`pI~5ZXv*0{f4ogbtUA%;X#x0!2ZZq=`nq()r$?yQSBw~p{uLL z4dp%yhGTH_yLwnE5`dtY_T#zk{*sPjm_}M+3dLr=CY}SR(6F62pw$psh#+lK$jRz{KnBdBQBKWIcWL$L9sv;ig`gonppE>~1G(P4nAZv%V*Gn}!-82lZ@flI zi@pN}*p&cM_GEJyr7+AjzgkimkY8k;|K#`Stu~5JO$oF4k&f{gwRc?b3q2nonKQCzVJoynYr^ z+w>4Vtmfrn-uaNv(KWvzUlL2FncjNa3O`+yMkD1LtC!zmPUJr`~>E|VJHoBaGrZd zT0n(zPtcIU+-S;y^q$-GJU0wDftU0hm_OWY~0M zlIiED2gZKoZ8+YcF9#-8t{9>l(Djk;MpG#*M)`s3e01-x^*$-`6sOW$#Q)4!RiSGku6*iuu zB<1LO2O2UoavvXa0A7Z#$o1NCMwKt~TYy8pk@uTEIHUx*6M;3>P|_7gBi?xrB8Q zQSl_x1$!c08OV4j8eAxC$)yzG>T*_aTe49KYU$$(faV6<2Q0Jfr~m@k)9(^6npxM) z+0l8|<&Dr0sn;Bk(RU%NgUftpcajJt{>gV<`HPRyn>mvJ5qlcUa;A_gsgC~GsexEI zgSe1f13>b1iQ~Hj-z<5kVO!`JLA>}EWXdJZv-?rb0}TSb!WdK)aV*z>_?0j8j`YQ(ZETS}1zego-?uG6>&QJ=<+%ok-mT;_i);$9SfIq~QaBsVSiO&Hcr0 zkBfkt^BKN%qK?G6-qAj{Mn4?Xr5^>%jbVX8U80>Tp0|>?IA6zzX#CO998`E3bZO0x zchWM6E&P$o3eTr|G{~V2)+?8*1GW1}%wHom&+bAh@Qk@QSYaa$h^boajNqgzS0GHF zF>xD*IOz;8qH+*)ty|Oa$d)TGoQmsX2Jqke~QPo#W= za-Rex$%FcY5p2u&ND^*YAo^`$TP){YU%?_W|GB?#iZ7UmN-hil4m4*x{1NT4r-N3m zfgN02&L*=k@E1irbtpZ(cSkb%KS|dDfnkgWghNhm0YrCVkU*~ z7qqW`_{?7pc~&JkqN{4s);2EJQ{%`gAzW-oBf)zVv5lXVb{rrwD=6#A^=H++;_Ko& zIzQ(G(QhQ8@GqW_xVH8$7S6y>-U&1?M_~MI8vDW>$wjZ+Eh72nbKM(VU*0mzUCu|% z*LWwO_(1n+Kfcx3Sp}vnxC4muYS6F}Zco?{;ByqlQ1G!WgJq-DRf==Yu$PBbXSI`zZ*HZtW@ue{_`w?1ER%{%?> z4+W-Eu??})YRG%_v2(HeK;qpAC%gY{68VYgY%-@USJi0m6H(o+2eeO#AJcaG0YBso zc78aD{pwpg^C72+?(J}{oJnobI-`CUT{zS#oSi}8<1I_(PZgzK0f6z8I}i>gVq=bV zF=)ESa>fhtH^nd^X_r9dPYSKMs+H7ZH^VbyIF7YyT~J)9QkOE5N(3FZ`X;{Bw3#rv zg;CzCeQ~}G?^ONi9L)Y16%7L{_~RdP?Q*L+M6w&7CX;J3i}a81-@IFq{X{M6H9($RPr5_$0Ah*tDgyqFj?DO9+)lTqwh+0GK3X$ORYL9}LO z&8?wLy_?4`M+1S!D5B)Jj-*KuLGrlxOtjfO)fA@wsX|Kmvd^YD#fj$|MUzMpniWm? z=FId1^lmw*HM0&KE=j9uV;0h4Jm*KS(NUO>5v}xDYkw2&($R!$!|D5`=s*Op92IfW zS(PDxaO(@gm;!*W6i=XVapyVgW&_J#C}`AlU}mVQPS#)w$Q@ES+y}oAsSMSz9Z}H+ z>u{=%YN+LIZ6GU{mlD0F=?Jbqk?JOH&V9-HYq!QZb;l`_c3)4U`{Q<5aHwC#ik;50h|tOKv(HfY{*Yp3_qLYf zAdZnzCG3y%4+&mk??@h;f2R#>`7Q%DH(z6KQ`he|Hx*=SHZT3j>)w6QIq~Y$yo~ch z0BK68LN4Tx?@}M$FkIW;+3>*c4OHzz7JB4DX$X0Jh*Cl zJd_8fp|1(;cFlP>^fNUtEA#FkI!W8oUM>^6(ChfZYG$XV+(xdRcG_vT+=@3 z;+5mRJ#bVL8aZTDC{wE4!nHYi$H;D_C)-q}Q~K5I$Gn`h>g1?f7YVKf3eG7yQ&k#A zs-H2n`vjXL1^wKcd#ZQE6txZFWLjWdtK(&x3Ity>!_~saNx^FO>!w3CSU1+@5YfrZ zTovaHp&p4YuZwPdUef`(RgZ~#kHscI#eHrz|G1`_XV&8RQpiOMKXo-ybr|?3{`%{d z+{hgrK}2DY3Ogq=y33-&o_V_OEAM8*lsQ&lJWyphjEE7-ur;17?7au>H(%_O0MKk4 z)!DWyA?3NFcY-j1h0CQ|y4WIj3X{WGt&2>HQ|lBy5;^b5YBrRZt7ADoI-P#ENTqV! zz*@{RDCDDQ8#$TEv_!dWk64%F8-#aUQJF;$Wwi_>&lNlFt7ZkL%qMN~nu(<1Q9&*o zsi8w`^=2;~KG)5yJFx8HrI`qVxFCvP1qB-fq%I;1BYUCd=L?BVx4WY$!Ugq10b#l) z=eYFN{H<3pMrNjn`o5bv@P5mwuYL7sAN|j=k2*BeW}6&W>>>H2avA%j3%ajIB4^#l zkA{hHW?U{LEz(v!dzBsTYT+byRxaQgm3wq<7R4kPn=11C$D%7lUvyC)o9J@ozdzQQ z#UUq6hg7d!5}p$jkeid{%YK@cHnay2-P5pdXbr(dy&PM!BI-k*9LwAAGBG2uNc8z=buDbRsCa^c;&U=d{ zSB8}-&-K^>FyvhL@cNtCYRzck#xj1RDJ+&REg(%sO0Pwhbqaj;44dZUdH43xt`uQj zF}G4$g-yxuyX4AcmnyJXbF5F5voFO8ug~!q%tqxcQOhhv`F5WRGEE7EvyK=FlBgr_mLFx?46hgj2H4Ym=(Xn zQxK-)uOuP#UovoI!JTlpzG?3TK2lJy39>omfb!$aAM3Imkt1Q`3* zBEK=Mo-wdL<4it5DWP-t8&sb1oP4%2BQoTvWDI7Z3A*Q&{F4)$7#fw@7K5PhKdaKR z`!nArr@5Paxsp^*aSTaBbCMfnOr#8+|ENgt8C-Z(fsEl`$JI!MbYHoZhraEaKKj;r zH97ZQ#d%9~7Gb6E;4K?f>7rQ>RJcjcf&aSBO)vdPOF#3Dh!cS-#po*#^sP84U4=Ds z^WVCVRpJ0CwM~4IXa)OShYw+5I;E{~jwkz8igy+3u(DJUWu|q7dUcLZ;H~5`r2CG& ze1a$x0$JkhiaBXY1_JB#CD`s)137ooLb6? z5n;T}=ag1*W43X-*Ma#NV1)@;X;U~Mi_U_?u3?``zyEdJ#JL!dmAXSSKi%`=11bYz zcA^6kc>5oi2|v3`NXxY&Pc5T%kPRvTRp}<$?u)F(yhgUzp@9V-poFVuh8p%1HIZlY*Ut#<{3ayBuYJJkfP6vh3i%46#8 z9p4fK7{Ay(7VY>-mEf6;Sy6&^)Xf=3mA73e4Ye7?m+kkdOdW(?oDiB_73Y##y0fX6 zcyJib)gR52e#1z<+VY}n)|g6|S*641`@SCaNsVSY=G?kQD;d&S+SVugWh`6)x$+@0)V@fp0?cA64zaJCzT|-eLN$R&Whhew9D0o2P8d+wJG5qs$5TSzkTUK z{k!Rkzn#oXPya}7S3f`hqQCBMPd445b(JAD9O4z-qvpY(0oZ#1N}))cYlh(}P<*B+R66zOGm3sKN7R%wKn>XHF- zV;uq^^-$8oq?NW2mAhakmpZNRgLQb!%xS;={hnhVXNvsPj%00fMYD_=lLs>Yl33>r z1DlfIjyD#bb$#vq;m7O<5oJyzi_Ak+q64mn6WdN4sJsW6ZCO0^_+bTKZf|VCDuJ8pUOr$y4LBaP8%nQ2j*J$X-t~MTiHEw zXXn)&o^iG9b0=nx6g2p`l|lv}xv>X6l!NpoFMydTli|m!MC~}Xu_AP)3~)B;ekboY z=6*ttO^*U*!N<9%7lH4Eyk8{#%I9K(G^S@-i)^+F62?@h)xqJ)YI3fYw5gt$U^I5+ci?kZ58S|sZQ)Noh_RHBM@fK5a{CpXRt?&-d zSxU}^yMiE>7dl*t!)54B@aN_6jv&(t6zrN;@UwI0YsUu07@tS8v;`*;S5Lh$Z)+_- zmeg6)&^*DHJ*t&b5uK#VU%LT&{UTQ|V}M*gl{wMWiPqT!no5OXr-(5|GoqZh>vDRZ zSrob0qN(cg>V5Bbg9|^E_muW!9J-dQSn&!!I^DK^Kw3a8`Hq9@nSt^YYa3hZ5N7QG z**x=gzHi_0Is-lc{C>dFbo^uqD0p2-7>zh`^@ zS}gr#xROPgt1l&8vN>EYXz-|i@b6U=c)=RK=Tgd)`F&4}rBUGMx72VYn}p2761cKg zoB6W`?fmilZeb|LdvjHaKmjI8KACl(HTtXW2~QpK%dd2g*+K1G!f+1QwUkP^Gh%0u z{C44G{1=hzvh#9T`hy>4`X+fv;l#rY^2^8LV0%c4%<)G&QMh9YkE_eCXWHfd*d zR(qTCqWcf6*4D!gH8#p>AMp5u;xoB=Uz^Rtz0I+`_?|%#=W40*aQUDFM-Ua4wf`A1 za}?%#DhCYag_Uu>HF)sPBZl8)w}`J{u}}>aKu>&`Qc18F)qEUWiKu_ulI) zpYStm`#jtzJ5#oDET<2|sW?5Np71yH@8#viMUzPh<*pIpg|IKkki8(jO!$$R(K--M z>#QB>vTX2|<@C;hpJbi=eZZWxn8riZ<_?)3;VKxS|us)f1wSAVkxgWV3VsWU-G0yN6k@L z(5M(c_mGC_y31V)rWwSEdAgb!bHKo7;H1>*9T=`s^g;Rs4xxA6ljUy4RH`}v$|YR^ zkDp2Yg$sGGhw`$VM%s+D>t^XP&82DnF5O|-vj#fsW`MU>jcSV5c`?{^6=>#7$9LAY@A23UZb>P?dmVQG@KN?`>mRujP905*$F_=dye-=zyC{!&VOL z3o4dXlws?o%kivEc+a5trLhfdc7;RuDB19mXi6$u5VY%XQPKRDKal-AP-YJF7_ZBN zxeVSmLB?A0?1E6_p|&~2t^Xu*M(%kBB|veuf-X&_*PAKl?J8u5hlZmg4|V+R2{}sI zi-W-PZ!SfOU+3Wh`)+IA|8;o-%4r_Rk=Mg(QMzblN93^uK zP}hQv{Ruew=i++PtwMpMsohR1s=46w%ksIf_pJy)9F|x_%JeFrkVc^M=nKLI#V+ly zcm;76hvq%rF%Tx`Pe4kGneu@{cr}Pcd4025kBAca6#3Rit7qyg7lTFP+@_1_L;XHc ztM!Z~HK0OViU~xGt3e7**BS3z5h}+9Ag$|2*&U zbiT<)si%SF9a`+~c5RXeR)h`_6WaCYtFo488kLn+^%6RkSas8{6R~`NQ)mYQ55fC{ z?Y8kd^c3cP5$ggwH{L8~rWz(+C;AUfe%r=e4K{!LUX+sUcefZ;4 z;yxqLT{n7Gb^UK$UA^8pbJlk^TETifu&Md3z|IH$(_af}dTxlttwgi2RdtHC$YmY$ zaS7_hig6e@(fHr2XKuIw@>bZLIfY(v6p%yWzEq$nb4xt#Ws2jjOjTUxuQ*D1M>*Fm z3nTv7r6hkNLdUmm&nGGz4tCdTXPUb-r%QsO8HKtz=Ix;u6{;=<#&8&%`!R7=B+n%; zi$0tiUV^w)AK3k)XDp7-y}yV7=ZjB}jte|jCLvYm0mwhyARIFJS%=l9)Wcbhxs?^r zEVf*cy;Vsq`?YAik3ZQanZxA)9Ze_spvmyH@@04vFyR?Y;$J!<%P-N#Bp zseJdN9X7y_B9|z97g?b(q_d|zflyBq$YHFe<-yu)MRnx(u znd1oaZawVW3L}|XKrVfimTe@`3#%v^(nT`-8A!*tfH*Nz{Ey&Y6Eo?T#|NkZrs&!a zMt$VvRFlNrFUZNv$&p@rv>ZQ-Avq9eg>RG}quo`R9NK20zwq;@V*uXRua;yuf&B>y zwBjw=Y%>w7Q5&*d**_ucvu(VS<*xRfeIZp)WQ;&<`$CAVPXHrzc~13t4_zd~^GQWO zQrz|V7tsQ>rJfJ!wof;mAG}2=+}U=jT?=ay+WrD0oX5-(>L!GA%d{PKKH;;Tp)!6X zQ=aS&qa^B=!=x`zM#{O;N?MNYMT!YO@-Ei{j-=IlkAD2T!1~sTz`~snvgTU+rz>dL zo1aT>c7!t1Ga};Jm|EY&4w+=!KlHq`Aqv&6*k0ZvZ|u@DR%BIyU%mHsUrmmejE_ge zC}zKQm8VmQsb&R6+)gn_Y@j@O&2ce%sZ9!ca&vKOLn1lNjDV?@nq4UMT&2#{G@QoS zQQC&ExKdf?anq$gmn0ygqD{Wdpbeog_p7pN^VU&qf_DbY*FIaOP$&Uk<&C%~Ej#w( ztAWK+FM07wU?`+`tbg-5F#kJ@^#dyyFJlj)6klOBc;z9Aze^7`4!6Ng@2 zce?TF!i{IIGY!JH&)$~1^5NdbyVvJK4fut%on&-MGljFhp1Pf)jXK=`#BT&kHQMfSFu*}pl@bObT z!*xnI6`{m6!b^k}eDrJzeHWxoG!Sl~wkK|BKw=_?v=sC0W)-9i)05m0X`Q7)ejhfE zRj#D{3QUj1l>GaTSi;IlpUD!`PNckq%QyuULlidD zibG8j=FL3Y(#b+aSi0ZrI$^CVW6fX7Qj5Gjj{8>PNug213>jv#NmQWtJRAQAU%+f%-(W}9IUWl$d+yfNK{s>{cA+ED#<@m9pUgK(;6GU43HigzM z9?S^7Gm1*>%nQlSsMn;fe!SpQnBnVRIQ>0P%l+i4rqo2vCLK#(E!8MQFsq8YF;}zh zYyOJ+R)s>oo8G|1=`Z-V%6D|qtFIib{h9pj_=h|%u^95Z>1Z{)mL+l>Ph01o8#5Zy zGHs_h(U2Qj)Nccn>sI)MI^dUwN+RRI&;PG~X8i~JeN$YRf(xwez!B&}%qo%%Ohclh6}L2O<#wqyh&MhLOe z2BRnp9Y5D``*;V<-0lZq?Syi`8sgNDYXaX_j%*6A4}6=v)9 zBT;{Melep%Z(hA≪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%0Aerb4AtrSHkm8s$ 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 I#q`zx1KOmQPXGV_ literal 0 HcmV?d00001 diff --git a/apps/docs/public/static/enterprise/data-retention.png b/apps/docs/public/static/enterprise/data-retention.png index 1b8d559e764249ae32f94c0943c1280f362a7793..92e3a510b44e7cbd01ced0c0cfb7f91dcbdbfa31 100644 GIT binary patch literal 196552 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�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%0D*n2Me-+Ft{mWpP&DOAJwM7 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 Ou#5H=>d*ap>wf_@dB-RK literal 107407 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≶=vON|G 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%0A)_!XK?xK zr=5q$-0cr&P(q!u{3R$Rs~5CUF>0XqJDTAg(t!}a4nPJC&8Mbom0wE~D%(=b;-gtX z((b3{M+s@;4h`TX(;*iN1VE0=K5#g^>G{aGh7eIcCm-r1WHsv?W@n0uB3I%XyD|+L zJHT!%zcdVai)E_j2jXe3z+0>`UQNr;?i01*V$1pgAvT-|gB#yki;0s0p+g=2?Ej8G zX^ebf<*Xz<(?vHn$QFE?(Bjy`(mp0CzKC;lrNy*~T&J8k(Fu@m)tqK&czq`am~z2j z8G9|xlb72sd3`yv?&WH6quHcaqhzpIBhT6evT67;C~aH%k;&{wWun#(GaR$jhvWV1 z9&RcHoK8`lT2%_Q%8NoEHgFU)nctj1?rqLAfKX8JCI{;4qxy3MrM^t_1t0LmS^JeW zz)rrxg!UE$11%X}$6UNPN0k!D6|je{Lk)*)T9+FkcQMY?4z)K=DLF0_p>LY`c2#i+ z?*`2bh(Z(^rGjQ~7dx3}j&RR>0@ELf6n#!53Tp)v{x1fcB;(8iFf(b|GOohd?GFw#<&ef-W7bYZgCy%|XajtCA>@ zC+=BSA6BEj_IVP$HFwu!S5wtDP0wDy5xxQiUCqW60}kA@G%Yd|v({XgwF)9#x@Py8 z$5!BU9-QHQU~NU!qS%e!-A68k#`77&-1aAJfTRZSQ((dMBgZkt9J)rj6|5`%`AaRg zX1J+cU+U?sEOcw4m^?-50K?Ipcy4D(-4^S5tO{~lFK9UvT%9!}y4rvp#1Z!$ z^A_=4@>`w5eIk+SlYEd)cW}EcW1`ei2$r9LS%lUK%<9DQ0k{s2#BmZw7cP&fpjZ$h z)evvv)%e&l=9ghL));ywU1J*1mpz`-8#wHs3S>SDD_5wOqU zNq&;&H(4Te;_@1+|G`~s=74`2=9v-xbsGK^Q{qDd(eVl%hYQdb_S)KQeH9?ytF6C$ z8y$z&(EPZ;I78>fQ*Zz6xBq{C;m;esy4oYJW6!Sqx2l~{R5Bqgg)k8BgW`FrS Z)8u*geKXxn%fP{(hx^{`WnTm&{s-gElC%H- diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 666088d3a69..898123fc7b4 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -101,27 +101,49 @@ interface EditingPolicy { isNew: boolean } +/** Day bounds the retention contract accepts (1 day … 5 years). */ +const MIN_RETENTION_DAYS = 1 +const MAX_RETENTION_DAYS = 1825 + +/** + * Hours → display days, clamped to the contract's range. Sub-day values would + * otherwise round to `0` and be re-sent as `0`, wedging every save on the page. + */ +function clampDisplayDays(hours: number): string { + const days = Math.round(hours / 24) + return String(Math.min(MAX_RETENTION_DAYS, Math.max(MIN_RETENTION_DAYS, days))) +} + +/** Day count → hours. Throws rather than send a value the contract rejects. */ +function toRetentionHours(days: string): number { + const parsed = Number(days) + if (!Number.isFinite(parsed) || parsed < MIN_RETENTION_DAYS) { + throw new Error(`Invalid retention period: ${JSON.stringify(days)}`) + } + return Math.min(MAX_RETENTION_DAYS, Math.round(parsed)) * 24 +} + function hoursToDisplayDays(hours: number | null): string { if (hours === null) return 'never' - return String(Math.round(hours / 24)) + return clampDisplayDays(hours) } function daysToHours(days: string): number | null { if (days === 'never') return null - return Number(days) * 24 + return toRetentionHours(days) } /** Override field: `INHERIT` ⇄ undefined, `'never'` ⇄ null (forever), day count ⇄ hours. */ function hoursToOverrideValue(hours: number | null | undefined): string { if (hours === undefined) return INHERIT if (hours === null) return 'never' - return String(Math.round(hours / 24)) + return clampDisplayDays(hours) } function overrideValueToHours(value: string): number | null | undefined { if (value === INHERIT) return undefined if (value === 'never') return null - return Number(value) * 24 + return toRetentionHours(value) } function buildRetentionOverride(workspaceId: string, draft: PolicyDraft): RetentionOverride | null { From 513292f17b78e90cb0c00882529ecc0073d76dc4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 00:34:16 -0700 Subject: [PATCH 17/32] feat(sso): DNS domain verification gating org SSO registration (#5909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sso): DNS domain verification gating org SSO registration Add org-scoped domain ownership verification (DNS TXT challenge) as the security precondition for configuring SSO. Closes the first-come domain-claim vuln where any org could wire another company's domain to its own IdP. - New sso_domain table + migration 0266; existing org SSO domains are grandfathered as verified so live tenants are unaffected - Verified-domains settings UI (enterprise-gated) with add/verify/remove - Register route now requires a verified domain for org-scoped registration; personal SSO and already-grandfathered domains are unaffected - Self-host register script writes the verified sso_domain row directly, so script-driven registration stays backwards compatible * fix(sso): harden domain verification against concurrency + fix CI lint Addresses review findings on state invariants under concurrent/failed writes: - Add unique index on (organization_id, domain) so concurrent claims can't create duplicate pending rows; POST re-reads and stays idempotent on conflict - Verify flips the row only if it's still the exact pending challenge checked (guards deletion/token-rotation mid-DNS-lookup) and maps the partial unique index violation to 409 instead of an unhandled 500 - Wrap the self-host script's provider write + verified-domain upsert in a transaction so a failed ownership write can't leave a provider committed - Format 0266 snapshot/journal with biome (fixes @sim/db lint:check) * fix(sso): re-check domain verification before provider write (TOCTOU) The register gate checked the verified sso_domain row only at handler entry, then ran OIDC discovery before writing the provider. A verified row removed during that window could still complete registration. Extract the check into a closure and call it both as an entry fast-fail and authoritatively right before registerSSOProvider, alongside the existing domain-conflict re-check. * fix(sso): stop rotating verification token on idempotent re-add Re-adding a pending domain rotated its verification token, which invalidated a TXT record the admin may have already published and — under two concurrent re-adds — could return a token the racing write had already superseded, so the admin's DNS record would never verify. Return the existing row unchanged instead; the pending token is always shown in the UI, so it is never lost. * fix(sso): close register TOCTOU with compensating delete + harden edges Audit-driven hardening: - Close the residual register TOCTOU: registerSSOProvider is create-only (throws if the providerId exists), so a compensating delete after the write is provably safe — it can only remove the just-created row. If verification was revoked during the write, roll the provider back and 403. - Verify is now idempotent under concurrency: a same-org row already flipped to verified by a racing request returns 200, not a confusing 409. - Grandfather backfill + self-host script now match normalizeSSODomain's dominant transforms (lower + trim + strip leading wildcard) so a non-canonical legacy domain can't miss the runtime gate's lookup. Prod backfill result is unchanged. - Cleanup: drop dead default export, align card radius to sibling convention. * fix(sso): redact domain tokens from non-admins + fix script stale-update Round-5 review findings: - GET /domains redacted the pending TXT verification token (a management secret) to any org member. Now only owner/admins read it; members see the list and status without it. Non-Enterprise orgs get an empty list (entitlement flag only), never the domains/tokens. - Self-host script decided update-vs-insert from a read taken OUTSIDE the transaction; a provider deleted mid-flight made the UPDATE match zero rows silently while the verified-domain upsert still committed (orphaned domain). The decision now happens inside the transaction from the UPDATE's row count. * docs(sso): drop unshipped enforce-SSO / auto-join copy from verified domains Verified domains currently only gate SSO configuration. Remove the forward-looking references to enforcing SSO and auto-joining members (deferred to a later release) from the docs, settings copy, nav description, and schema comment so we don't promise unshipped features. * fix(sso): guard rollback to new providers only + Enterprise-gate domain removal Round-6 review findings: - The compensating provider rollback now only fires when the provider did not exist before this request (providerExistedBefore). registerSSOProvider is create-only today so reaching the rollback already implies a fresh create, but this makes the safety local and future-proof: if Better Auth ever allowed updating an existing provider, a revoked-verification rollback must not delete that pre-existing row. - DELETE /domains now requires an Enterprise plan like add/list/verify, so all domain mutations share one entitlement (the UI already hides removal from non-Enterprise orgs). Adds a delete-route test. * fix(sso): roll back the SSO provider by row id, not logical keys The compensating rollback deleted by (providerId, orgId). providerId is unique, so if this request's row were deleted and recreated by a concurrent registration in the narrow window before the rollback, the logical-key delete would remove that other request's provider. Delete by the primary-key id registerSSOProvider returns instead, so only the exact row this request created is ever removed. * chore(sso): final-review polish — trim script read, unify copy, doc migration edge Cosmetic cleanup from a final 4-track adversarial review (no bugs found in the new logic): - Self-host script: narrow the pre-transaction existence read to select({ id }) instead of SELECT * (it only feeds a log line now). - Unify invalid-domain copy ("for example acme.com") and the verified-elsewhere 409 wording ("is already verified by another organization") across routes. - p-3 shorthand on the domain row card. - Document the migration's rare two-orgs-share-a-domain grandfather behavior (login unaffected; validated no such duplicates in prod). * fix(sso): apply attribute mapping + make SSO edit work; drop dead guard Two pre-existing SSO bugs the final review surfaced (prod has one SSO org, RVW, script-registered with the default mapping, so neither change affects it): - Attribute mapping was passed at the top level of the register payload, which Better Auth ignores — it reads oidcConfig.mapping / samlConfig.mapping. Nest it so custom mappings actually apply. (Default mapping is unchanged, so existing logins are unaffected.) - Editing an SSO provider was broken: registerSSOProvider is create-only and threw on the existing providerId → generic 500. Route now detects a provider the caller already owns and updates it via Better Auth's updateSSOProvider, and surfaces Better Auth's own error status/message instead of a blanket 500. Also drops the now-unnecessary providerExistedBefore guard (the rollback deletes by the created row's primary-key id and register is create-only) and the earlier final-review polish (script read, unified copy, migration edge note). Smoke-test SSO login + edit on staging before merge (auth-path change). * fix(sso): require null org on personal-mode provider lookups (gate bypass) The personal branch of both provider-ownership lookups keyed on (providerId, userId) without requiring organizationId IS NULL. Because org providers store userId = their creator and providerId is globally unique, an org admin could send a personal-mode request (no orgId) — which skips the membership check and the domain-verification gate — yet still match, and then via the new update path move, their org's provider to an unverified domain. Add isNull(organizationId) to the personal branch of both clauses so it can only match a genuinely personal provider, matching the route's own isOwnedByCaller. Found by an adversarial review of the update path added in 394bda9f7. * fix(sso): script updates the observed provider by id, not providerId Inside the registration transaction the script updated WHERE providerId — the logical key. If the observed provider was deregistered and a replacement created with the same providerId before the transaction ran, that update would clobber the replacement's config and ownership. Update the specific observed row by its primary-key id instead; if it's gone we insert, which fails cleanly on the providerId unique constraint rather than overwriting the replacement. * fix(sso): script upserts provider via delete-then-insert (no unique constraint) sso_provider.provider_id is a plain (non-unique) index and prod holds legitimate duplicates, so the previous "update by id, else insert" could create a duplicate provider when the observed row was deregistered and replaced before the transaction — the fallback insert would succeed. Delete every row for the providerId then insert exactly one, inside the transaction, so the providerId ends up as exactly this config atomically. Linked accounts key on the providerId string (not the row id), so existing logins are unaffected. * fix(sso): guard compensating-delete row id so rollback can't silently no-op * chore(sso): regenerate migration as 0268 after merging staging Staging landed migrations 0266/0267, colliding with our 0266. Removed our migration, merged staging, and regenerated cleanly with drizzle-kit as 0268_sso_domain_verification (identical sso_domain table + indexes), then re-appended the grandfather backfill. api-validation baseline reconciled to 973 (staging 970 + our 3 domain routes). Also make the register-route test's registerSSOProvider mock return an id so the guarded compensating delete runs. * refactor(sso): share normalizeSSODomain via @sim/utils so script matches gate The self-host script canonicalized SSO domains with a minimal inline transform (lower+trim+wildcard) that diverged from the app's full normalizeSSODomain (protocol, port, path, trailing dot, email local part) — equivalent spellings could store a different ownership key than the runtime gate looks up. Move normalizeSSODomain into @sim/utils/sso-domain (a pure function) so the register route, the domain-claim route, and the script all use the identical canonicalizer. The script now skips the verified-domain record when SSO_DOMAIN isn't a valid registrable domain instead of storing a malformed key. --- .../docs/en/platform/enterprise/meta.json | 1 + .../platform/enterprise/verified-domains.mdx | 57 + .../app/api/auth/sso/register/route.test.ts | 84 +- apps/sim/app/api/auth/sso/register/route.ts | 164 +- .../[id]/domains/[domainId]/route.test.ts | 86 + .../[id]/domains/[domainId]/route.ts | 87 + .../domains/[domainId]/verify/route.test.ts | 126 + .../[id]/domains/[domainId]/verify/route.ts | 162 + .../organizations/[id]/domains/route.test.ts | 206 + .../api/organizations/[id]/domains/route.ts | 217 + .../settings/[section]/settings.tsx | 6 + .../[workspaceId]/settings/navigation.test.ts | 1 + .../components/settings/navigation.test.ts | 3 + apps/sim/components/settings/navigation.ts | 28 +- .../organization-settings-renderer.tsx | 6 + .../sim/ee/sso/components/domain-settings.tsx | 195 + apps/sim/ee/sso/hooks/domains.ts | 72 + apps/sim/lib/api/contracts/organization.ts | 86 + .../lib/auth/sso/domain-verification.test.ts | 79 + apps/sim/lib/auth/sso/domain-verification.ts | 107 + packages/audit/src/types.ts | 3 + .../0268_sso_domain_verification.sql | 48 + .../db/migrations/meta/0268_snapshot.json | 17686 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 51 + packages/db/scripts/register-sso-provider.ts | 85 +- packages/testing/src/mocks/audit.mock.ts | 3 + packages/testing/src/mocks/schema.mock.ts | 11 + packages/utils/package.json | 4 + packages/utils/src/index.ts | 1 + .../utils/src/sso-domain.test.ts | 2 +- .../utils/src/sso-domain.ts | 5 + scripts/check-api-validation-contracts.ts | 4 +- 33 files changed, 19647 insertions(+), 36 deletions(-) create mode 100644 apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx create mode 100644 apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts create mode 100644 apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts create mode 100644 apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts create mode 100644 apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts create mode 100644 apps/sim/app/api/organizations/[id]/domains/route.test.ts create mode 100644 apps/sim/app/api/organizations/[id]/domains/route.ts create mode 100644 apps/sim/ee/sso/components/domain-settings.tsx create mode 100644 apps/sim/ee/sso/hooks/domains.ts create mode 100644 apps/sim/lib/auth/sso/domain-verification.test.ts create mode 100644 apps/sim/lib/auth/sso/domain-verification.ts create mode 100644 packages/db/migrations/0268_sso_domain_verification.sql create mode 100644 packages/db/migrations/meta/0268_snapshot.json rename apps/sim/lib/auth/sso/domain.test.ts => packages/utils/src/sso-domain.test.ts (96%) rename apps/sim/lib/auth/sso/domain.ts => packages/utils/src/sso-domain.ts (76%) diff --git a/apps/docs/content/docs/en/platform/enterprise/meta.json b/apps/docs/content/docs/en/platform/enterprise/meta.json index 924a8263768..0b5066c495d 100644 --- a/apps/docs/content/docs/en/platform/enterprise/meta.json +++ b/apps/docs/content/docs/en/platform/enterprise/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "sso", + "verified-domains", "session-policies", "access-control", "custom-blocks", diff --git a/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx new file mode 100644 index 00000000000..d56dc3230c6 --- /dev/null +++ b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx @@ -0,0 +1,57 @@ +--- +title: Verified Domains +description: Prove ownership of your email domains before configuring single sign-on +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +Verified Domains let organization owners and admins on Enterprise plans prove they control an email domain (like `acme.com`) with a DNS TXT record. Verifying a domain is the security precondition for configuring single sign-on for it. + + + Configuring SSO for a domain requires it to be verified first. Verifying proves your organization controls the domain — without it, anyone could point another company's domain at their own identity provider. Domains you had already configured for SSO are automatically treated as verified. + + +--- + +## Verify a domain + +Go to **Settings → Security → Verified domains** in your organization settings. + +1. Enter the domain, for example `acme.com`, and click **Add domain**. +2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`). +3. Add that TXT record at your DNS provider. +4. Click **Verify**. Sim looks up the record; on success the domain is marked **Verified**. + +DNS changes can take up to 48 hours to propagate — if verification does not succeed immediately, wait and retry. You can remove the TXT record after the domain is verified; the verification persists. + +Add each domain you own separately. Subdomains (`eng.acme.com`) are verified independently of the apex. + +--- + +## FAQ + +, rather than the root of your domain — this avoids colliding with your SPF, DMARC, or other root TXT records.', + }, + { + question: 'What happens to domains we already use for SSO?', + answer: + 'They are automatically treated as verified, so existing single sign-on keeps working with no action needed.', + }, + { + question: 'Can two organizations verify the same domain?', + answer: + 'No. A verified domain belongs to exactly one organization. Once verified, another organization cannot claim it.', + }, + { + question: 'What if I remove a verified domain?', + answer: + 'You lose the ownership proof, so you cannot configure SSO for that domain until you re-add and re-verify it. Removing it does not sign anyone out — an already-configured SSO provider keeps working.', + }, + ]} +/> diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts index 67b4beb8b13..02d10f217d1 100644 --- a/apps/sim/app/api/auth/sso/register/route.test.ts +++ b/apps/sim/app/api/auth/sso/register/route.test.ts @@ -16,12 +16,14 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, mockRegisterSSOProvider, + mockUpdateSSOProvider, mockHasSSOAccess, mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockRegisterSSOProvider: vi.fn(), + mockUpdateSSOProvider: vi.fn(), mockHasSSOAccess: vi.fn(), mockValidateUrlWithDNS: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), @@ -45,14 +47,19 @@ function queueProviders(rows: Array>) { vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, - auth: { api: { registerSSOProvider: mockRegisterSSOProvider } }, + auth: { + api: { + registerSSOProvider: mockRegisterSSOProvider, + updateSSOProvider: mockUpdateSSOProvider, + }, + }, })) vi.mock('@/lib/billing', () => ({ hasSSOAccess: mockHasSSOAccess, })) -vi.mock('@/lib/auth/sso/domain', () => ({ +vi.mock('@sim/utils/sso-domain', () => ({ normalizeSSODomain: (input: unknown): string | null => { if (typeof input !== 'string') return null const value = input.trim().toLowerCase() @@ -93,7 +100,17 @@ describe('POST /api/auth/sso/register', () => { mockHasSSOAccess.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test')) - mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) + mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' }) + mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) + // Default: the org has already verified the domain, so the ownership gate + // passes and each test exercises the logic beyond it. The gate is checked + // three times for a successful org-scoped registration (fail-fast entry + + // authoritative re-check before the write + compensating re-check after the + // write), so queue three rows. Gate-specific tests reset the queue to assert + // the unverified paths. + queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) + queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) + queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }]) }) afterAll(() => { @@ -122,6 +139,43 @@ describe('POST /api/auth/sso/register', () => { expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) + it('rejects configuring org SSO for a domain the org has not verified', async () => { + resetDbChainMock() + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueTableRows(schemaMock.ssoDomain, []) // no verified sso_domain row + const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) + const json = await res.json() + expect(res.status).toBe(403) + expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') + expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + }) + + it('re-checks verification before the write and 403s if it was revoked mid-registration', async () => { + resetDbChainMock() + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified + queueTableRows(schemaMock.ssoDomain, []) // re-check before write: revoked + const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) + const json = await res.json() + expect(res.status).toBe(403) + expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') + expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + }) + + it('rolls back the newly-created provider if verification is revoked after the write', async () => { + resetDbChainMock() + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified + queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified + queueTableRows(schemaMock.ssoDomain, []) // post-write compensating check: revoked + const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) + const json = await res.json() + expect(res.status).toBe(403) + expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED') + expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created… + expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back + }) + it('rejects a domain already registered by another organization', async () => { queueMembers([{ organizationId: 'org-attacker', role: 'owner' }]) queueProviders([{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }]) @@ -152,6 +206,30 @@ describe('POST /api/auth/sso/register', () => { expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) + it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => { + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + await POST( + request({ ...OIDC_BODY, orgId: 'org1', mapping: { id: 'oid', email: 'upn', name: 'name' } }) + ) + expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) + const sent = mockRegisterSSOProvider.mock.calls[0][0].body + expect(sent.mapping).toBeUndefined() // not passed at the top level (silently ignored there) + expect(sent.oidcConfig.mapping).toMatchObject({ id: 'oid', email: 'upn', name: 'name' }) + }) + + it('routes an edit of an existing owned provider through updateSSOProvider', async () => { + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #1 → no conflict + queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #2 → no conflict + queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → edit + const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.message).toContain('updated') + expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1) + expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + }) + it('allows the owning tenant to update its own provider for the same domain', async () => { queueMembers([{ organizationId: 'org1', role: 'owner' }]) queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]) diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index d826c4641fb..23f872c5077 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -1,12 +1,12 @@ -import { db, member, ssoProvider } from '@sim/db' +import { db, member, ssoDomain, ssoProvider } from '@sim/db' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, sql } from 'drizzle-orm' +import { normalizeSSODomain } from '@sim/utils/sso-domain' +import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { ssoRegistrationContract } from '@/lib/api/contracts/auth' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth' -import { normalizeSSODomain } from '@/lib/auth/sso/domain' import { hasSSOAccess } from '@/lib/billing' import { env } from '@/lib/core/config/env' import { @@ -117,9 +117,48 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const domain = normalizeSSODomain(body.domain) if (!domain) { - return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 }) + return NextResponse.json( + { error: 'Enter a valid domain, for example acme.com' }, + { status: 400 } + ) } + // Security gate: configuring org SSO for a domain requires the org to have + // proven ownership of it (DNS TXT verification). Without this, the old + // first-come claim let any org wire another company's domain to their own + // IdP — an account-takeover primitive. Existing domains were grandfathered + // as verified by migration 0266, so live tenants are unaffected. Personal + // (org-less) SSO is not gated. + const isOrgDomainVerified = async (): Promise => { + if (!orgId) return true + const [verified] = await db + .select({ id: ssoDomain.id }) + .from(ssoDomain) + .where( + and( + eq(ssoDomain.organizationId, orgId), + eq(ssoDomain.domain, domain), + eq(ssoDomain.status, 'verified') + ) + ) + .limit(1) + return Boolean(verified) + } + + const domainNotVerifiedResponse = () => + NextResponse.json( + { + error: `Verify ownership of ${domain} under Settings → Verified domains before configuring SSO for it.`, + code: 'SSO_DOMAIN_NOT_VERIFIED', + }, + { status: 403 } + ) + + // Fail fast before the expensive OIDC discovery. Re-checked immediately + // before the provider write below to close the TOCTOU window (the verified + // row could be removed while discovery is in flight). + if (!(await isOrgDomainVerified())) return domainNotVerifiedResponse() + const isOwnedByCaller = (provider: { userId: string | null organizationId: string | null @@ -166,7 +205,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { providerId, issuer, domain, - mapping, ...(orgId ? { organizationId: orgId } : {}), } @@ -187,7 +225,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (rawClientSecret === REDACTED_MARKER) { const ownerClause = orgId ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId)) - : and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.userId, session.user.id)) + : and( + eq(ssoProvider.providerId, providerId), + eq(ssoProvider.userId, session.user.id), + isNull(ssoProvider.organizationId) + ) const [existing] = await db .select({ oidcConfig: ssoProvider.oidcConfig }) .from(ssoProvider) @@ -378,6 +420,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } oidcConfig.skipDiscovery = true + // Better Auth reads the attribute mapping from oidcConfig.mapping, not a + // top-level field — nesting it here is what makes a custom mapping apply. + if (mapping) oidcConfig.mapping = mapping providerConfig.oidcConfig = oidcConfig } else if (providerType === 'saml') { const { @@ -459,6 +504,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm if (identifierFormat) samlConfig.identifierFormat = identifierFormat + // Better Auth reads the attribute mapping from samlConfig.mapping. + if (mapping) samlConfig.mapping = mapping providerConfig.samlConfig = samlConfig } @@ -499,11 +546,104 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return domainConflictResponse() } + // Authoritative verification re-check: the verified row could have been + // removed during OIDC discovery. Re-checking here (not just at handler + // entry) ensures ownership still holds at the moment of the write. + if (!(await isOrgDomainVerified())) { + logger.warn( + 'Rejected SSO registration: domain verification was revoked during registration', + { + domain, + orgId, + userId: session.user.id, + } + ) + return domainNotVerifiedResponse() + } + + // Better Auth's registerSSOProvider is create-only (it throws on an existing + // providerId). If the caller already owns a provider with this id, route the + // edit through updateSSOProvider so re-saving an SSO config works instead of + // failing. The verification gate above already ran against the target domain, + // so an edit that moves SSO to an unverified domain is still blocked. + // The personal branch MUST require a null org: org providers store + // userId = their creator, so without it an org admin could send a + // personal-mode request (which skips the membership check and the + // verification gate) yet still match — and then update — their org's + // provider, moving it to an unverified domain. Mirrors isOwnedByCaller. + const ownerClause = orgId + ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId)) + : and( + eq(ssoProvider.providerId, providerId), + eq(ssoProvider.userId, session.user.id), + isNull(ssoProvider.organizationId) + ) + const [existingOwnedProvider] = await db + .select({ id: ssoProvider.id }) + .from(ssoProvider) + .where(ownerClause) + .limit(1) + + if (existingOwnedProvider) { + await auth.api.updateSSOProvider({ + body: { + providerId, + issuer, + domain, + ...(providerConfig.oidcConfig ? { oidcConfig: providerConfig.oidcConfig } : {}), + ...(providerConfig.samlConfig ? { samlConfig: providerConfig.samlConfig } : {}), + }, + headers, + }) + logger.info('SSO provider updated successfully', { providerId, providerType, domain }) + return NextResponse.json({ + success: true, + providerId, + providerType, + message: `${providerType.toUpperCase()} provider updated successfully`, + }) + } + const registration = await auth.api.registerSSOProvider({ body: providerConfig, headers, }) + // Close the residual TOCTOU between the re-check above and Better Auth + // persisting the provider: the verified sso_domain row could be removed in + // that window. registerSSOProvider is create-only (it throws if the + // providerId already exists), so a successful call always created a brand-new + // row — we roll it back by its primary-key `id` (not the logical providerId, + // which a concurrent delete+recreate could point at a different row). Personal + // SSO is not gated, so this only runs for org-scoped registration. + if (orgId && !(await isOrgDomainVerified())) { + // registerSSOProvider spreads the created row's `id` at runtime, but the + // typed return omits it — read it defensively and only delete when it's a + // real id, so a future shape change can't turn the rollback into a silent + // no-op that leaves a provider on an unverified domain. + // double-cast-allowed: Better Auth's return type omits the runtime `id` + const createdRowId = (registration as unknown as { id?: unknown }).id + if (typeof createdRowId === 'string' && createdRowId.length > 0) { + await db + .delete(ssoProvider) + .where(and(eq(ssoProvider.id, createdRowId), eq(ssoProvider.organizationId, orgId))) + logger.warn('Rolled back SSO provider: domain verification revoked mid-registration', { + domain, + orgId, + providerId: registration.providerId, + userId: session.user.id, + }) + } else { + logger.error('Could not roll back SSO provider: registration returned no usable id', { + domain, + orgId, + providerId: registration.providerId, + userId: session.user.id, + }) + } + return domainNotVerifiedResponse() + } + logger.info('SSO provider registered successfully', { providerId, providerType, @@ -517,16 +657,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { message: `${providerType.toUpperCase()} provider registered successfully`, }) } catch (error) { - logger.error('Failed to register SSO provider', { + logger.error('Failed to save SSO provider', { error, errorMessage: getErrorMessage(error, 'Unknown error'), errorStack: error instanceof Error ? error.stack : undefined, errorDetails: JSON.stringify(error), }) + // Surface Better Auth's own APIError (e.g. a 409 when identity fields change + // while linked accounts exist, or a 404) with its status and message instead + // of a generic 500, so the client shows an actionable error. + const apiError = error as { statusCode?: unknown; body?: { message?: unknown } } + if (typeof apiError.statusCode === 'number' && typeof apiError.body?.message === 'string') { + return NextResponse.json({ error: apiError.body.message }, { status: apiError.statusCode }) + } + return NextResponse.json( { - error: 'Failed to register SSO provider', + error: 'Failed to save the SSO provider', details: getErrorMessage(error, 'Unknown error'), }, { status: 500 } diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts new file mode 100644 index 00000000000..6dd6c29ada1 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { member } from '@sim/db/schema' +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsEnterprise, mockRecordAudit } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockIsEnterprise: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsEnterprise, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { ORGANIZATION_DOMAIN_REMOVED: 'organization.domain.removed' }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +import { DELETE } from '@/app/api/organizations/[id]/domains/[domainId]/route' + +const routeContext = { params: Promise.resolve({ id: 'org-1', domainId: 'd1' }) } + +describe('remove org domain route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' }, + }) + mockIsEnterprise.mockResolvedValue(true) + }) + + it('401s when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(401) + }) + + it('403s for non-admins', async () => { + queueTableRows(member, [{ role: 'member' }]) + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(403) + }) + + it('403s for non-Enterprise orgs', async () => { + queueTableRows(member, [{ role: 'owner' }]) + mockIsEnterprise.mockResolvedValue(false) + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(403) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('404s when the domain does not exist', async () => { + queueTableRows(member, [{ role: 'owner' }]) + dbChainMockFns.returning.mockResolvedValueOnce([]) // delete matched nothing + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(404) + }) + + it('removes the domain and records an audit event', async () => { + queueTableRows(member, [{ role: 'owner' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }]) + const res = await DELETE(createMockRequest('DELETE'), routeContext) + expect(res.status).toBe(200) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.domain.removed' }) + ) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts new file mode 100644 index 00000000000..9dd220c2274 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts @@ -0,0 +1,87 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, ssoDomain } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { removeOrganizationDomainContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgDomainDeleteAPI') + +/** + * DELETE /api/organizations/[id]/domains/[domainId] + * Removes a claimed/verified domain. Requires owner/admin role. Removing a + * verified domain drops the ownership proof, so SSO can no longer be configured + * for it until it is re-verified. It does not retroactively un-register an + * already-configured SSO provider — that flows through the SSO provider itself. + */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; domainId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(removeOrganizationDomainContract, request, context) + if (!parsed.success) return parsed.response + const { id: organizationId, domainId } = parsed.data.params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can remove domains' }, + { status: 403 } + ) + } + // Enterprise-gate removal like add/verify so all domain mutations require the + // same entitlement (the UI already hides removal from non-Enterprise orgs). + if (isBillingEnabled && !(await isOrganizationOnEnterprisePlan(organizationId))) { + return NextResponse.json( + { error: 'Domain verification is available on Enterprise plans only' }, + { status: 403 } + ) + } + + const [removed] = await db + .delete(ssoDomain) + .where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId))) + .returning({ domain: ssoDomain.domain }) + + if (!removed) { + return NextResponse.json({ error: 'Domain not found' }, { status: 404 }) + } + + logger.info('Domain removed', { organizationId, domain: removed.domain }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_DOMAIN_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Removed domain ${removed.domain}`, + metadata: { domain: removed.domain }, + request, + }) + + return NextResponse.json({ success: true }) + } +) diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts new file mode 100644 index 00000000000..11b14ddd877 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { member, ssoDomain } from '@sim/db/schema' +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsEnterprise, mockRecordAudit, mockCheckDomainTxtRecord } = vi.hoisted( + () => ({ + mockGetSession: vi.fn(), + mockIsEnterprise: vi.fn(), + mockRecordAudit: vi.fn(), + mockCheckDomainTxtRecord: vi.fn(), + }) +) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsEnterprise, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { ORGANIZATION_DOMAIN_VERIFIED: 'organization.domain.verified' }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +vi.mock('@/lib/auth/sso/domain-verification', () => ({ + checkDomainTxtRecord: mockCheckDomainTxtRecord, + toDomainResponse: (row: { id: string; status: string }) => ({ id: row.id, status: row.status }), +})) + +import { POST } from '@/app/api/organizations/[id]/domains/[domainId]/verify/route' + +const ORG_ID = 'org-1' +const DOMAIN_ID = 'd1' +const routeContext = { params: Promise.resolve({ id: ORG_ID, domainId: DOMAIN_ID }) } +const PENDING_ROW = { + id: DOMAIN_ID, + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok', + verifiedAt: null, +} + +/** Queues the membership + pending-row lookups shared by the happy path. */ +function queueAdminWithPendingRow() { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(ssoDomain, [PENDING_ROW]) // row lookup +} + +describe('verify org domain route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' }, + }) + mockIsEnterprise.mockResolvedValue(true) + mockCheckDomainTxtRecord.mockResolvedValue(true) + }) + + it('422s when the TXT record is not found', async () => { + queueAdminWithPendingRow() + mockCheckDomainTxtRecord.mockResolvedValue(false) + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(422) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('verifies the domain and records an audit event', async () => { + queueAdminWithPendingRow() + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + dbChainMockFns.returning.mockResolvedValueOnce([{ ...PENDING_ROW, status: 'verified' }]) + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ status: 'verified' }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.domain.verified' }) + ) + }) + + it('is idempotent when a concurrent same-org request already verified the row', async () => { + queueAdminWithPendingRow() + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + dbChainMockFns.returning.mockResolvedValueOnce([]) // our conditional update lost the race + queueTableRows(ssoDomain, [{ ...PENDING_ROW, status: 'verified' }]) // re-read: now verified + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ status: 'verified' }) + expect(mockRecordAudit).not.toHaveBeenCalled() // the winning request recorded it + }) + + it('409s when the row changed (deleted/re-tokenized) during the DNS lookup', async () => { + queueAdminWithPendingRow() + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + dbChainMockFns.returning.mockResolvedValueOnce([]) // conditional update matched no row + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(409) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('409s (not 500) when a concurrent cross-org verification wins the unique index', async () => { + queueAdminWithPendingRow() + queueTableRows(ssoDomain, []) // verified-elsewhere check → none at read time + dbChainMockFns.returning.mockRejectedValueOnce( + Object.assign(new Error('duplicate key'), { code: '23505' }) + ) + const res = await POST(createMockRequest('POST'), routeContext) + expect(res.status).toBe(409) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts new file mode 100644 index 00000000000..9f1c710a2cb --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts @@ -0,0 +1,162 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, ssoDomain } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyOrganizationDomainContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { checkDomainTxtRecord, toDomainResponse } from '@/lib/auth/sso/domain-verification' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgDomainVerifyAPI') + +/** + * POST /api/organizations/[id]/domains/[domainId]/verify + * Checks the domain's DNS TXT challenge record; on success flips it to + * `verified`. Requires enterprise plan and owner/admin role. A domain already + * verified by another org is refused (the partial unique index also guards it). + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; domainId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(verifyOrganizationDomainContract, request, context) + if (!parsed.success) return parsed.response + const { id: organizationId, domainId } = parsed.data.params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can verify domains' }, + { status: 403 } + ) + } + if (isBillingEnabled && !(await isOrganizationOnEnterprisePlan(organizationId))) { + return NextResponse.json( + { error: 'Domain verification is available on Enterprise plans only' }, + { status: 403 } + ) + } + + const [row] = await db + .select() + .from(ssoDomain) + .where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId))) + .limit(1) + if (!row) { + return NextResponse.json({ error: 'Domain not found' }, { status: 404 }) + } + if (row.status === 'verified') { + return NextResponse.json({ success: true, data: { domain: toDomainResponse(row) } }) + } + + const recordPresent = await checkDomainTxtRecord(row.domain, row.verificationToken) + if (!recordPresent) { + return NextResponse.json( + { + error: + 'The verification TXT record was not found yet. DNS changes can take up to 48 hours to propagate — add the record shown and try again.', + }, + { status: 422 } + ) + } + + // Friendly early return for the common case where another org already owns + // the domain (the partial unique index is the actual enforcer, below). + const [verifiedElsewhere] = await db + .select({ organizationId: ssoDomain.organizationId }) + .from(ssoDomain) + .where(and(eq(ssoDomain.domain, row.domain), eq(ssoDomain.status, 'verified'))) + .limit(1) + if (verifiedElsewhere && verifiedElsewhere.organizationId !== organizationId) { + return NextResponse.json( + { error: 'This domain is already verified by another organization' }, + { status: 409 } + ) + } + + // Flip to verified only if the row is still the exact pending challenge we + // checked. If it was deleted, re-tokenized, or already verified while the + // DNS lookup was in flight, this matches zero rows — we then ask for a retry + // instead of mapping an undefined row or trusting a superseded challenge. A + // concurrent cross-org verification trips the partial unique index; surface + // that as a 409 rather than an unhandled 500. + let updated: (typeof row)[] + try { + updated = await db + .update(ssoDomain) + .set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(ssoDomain.id, domainId), + eq(ssoDomain.verificationToken, row.verificationToken), + eq(ssoDomain.status, 'pending') + ) + ) + .returning() + } catch (error) { + if (getPostgresErrorCode(error) === '23505') { + return NextResponse.json( + { error: 'This domain is already verified by another organization' }, + { status: 409 } + ) + } + throw error + } + + if (updated.length === 0) { + // The conditional update matched nothing. If a concurrent request for this + // same org already flipped the row to verified, treat this as an idempotent + // success; otherwise the challenge is genuinely stale (row deleted or + // re-tokenized mid-lookup) and we ask the caller to retry. + const [current] = await db + .select() + .from(ssoDomain) + .where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId))) + .limit(1) + if (current?.status === 'verified') { + return NextResponse.json({ success: true, data: { domain: toDomainResponse(current) } }) + } + return NextResponse.json( + { error: 'The domain changed during verification. Refresh and try again.' }, + { status: 409 } + ) + } + + logger.info('Domain verified', { organizationId, domain: row.domain }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_DOMAIN_VERIFIED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Verified domain ${row.domain}`, + metadata: { domain: row.domain }, + request, + }) + + return NextResponse.json({ success: true, data: { domain: toDomainResponse(updated[0]) } }) + } +) diff --git a/apps/sim/app/api/organizations/[id]/domains/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/route.test.ts new file mode 100644 index 00000000000..5da2ec2c53a --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/route.test.ts @@ -0,0 +1,206 @@ +/** + * @vitest-environment node + */ +import { member, ssoDomain } from '@sim/db/schema' +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockIsEnterprise, mockRecordAudit } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockIsEnterprise: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsEnterprise, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { ORGANIZATION_DOMAIN_ADDED: 'organization.domain.added' }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +import { GET, POST } from '@/app/api/organizations/[id]/domains/route' + +const ORG_ID = 'org-1' +const routeContext = { params: Promise.resolve({ id: ORG_ID }) } + +describe('org domains route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' }, + session: { token: 'tok-1' }, + }) + mockIsEnterprise.mockResolvedValue(true) + }) + + describe('GET', () => { + it('401s when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(401) + }) + + it('403s for non-members', async () => { + queueTableRows(member, []) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(403) + }) + + const PENDING = { + id: 'd1', + domain: 'acme.com', + status: 'pending', + verificationToken: 'secret', + verifiedAt: null, + } + + it('returns the pending TXT token to admins', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(ssoDomain, [PENDING]) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domains[0]).toMatchObject({ + domain: 'acme.com', + status: 'pending', + txtRecordValue: 'sim-domain-verification=secret', + }) + }) + + it('redacts the pending TXT token for non-admin members', async () => { + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(ssoDomain, [PENDING]) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domains[0]).toMatchObject({ + domain: 'acme.com', + status: 'pending', + txtRecordValue: null, // secret hidden from members + }) + }) + + it('returns an empty list (no domains/tokens) for non-Enterprise orgs', async () => { + queueTableRows(member, [{ role: 'admin' }]) + mockIsEnterprise.mockResolvedValue(false) + const res = await GET(createMockRequest('GET'), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toEqual({ isEnterprise: false, domains: [] }) + }) + }) + + describe('POST', () => { + function req(body: unknown) { + return createMockRequest('POST', body) + } + + it('403s for non-admins', async () => { + queueTableRows(member, [{ role: 'member' }]) + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(403) + }) + + it('400s on an invalid domain', async () => { + queueTableRows(member, [{ role: 'owner' }]) + const res = await POST(req({ domain: 'not a domain' }), routeContext) + expect(res.status).toBe(400) + }) + + it('409s when the domain is verified by another org', async () => { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(ssoDomain, [{ organizationId: 'other-org' }]) + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(409) + }) + + it('claims a new domain as pending and records an audit event', async () => { + queueTableRows(member, [{ role: 'owner' }]) // membership + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + queueTableRows(ssoDomain, []) // org-domains read → none existing, under the cap + // insert().returning() + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'd-new', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok', + verifiedAt: null, + }, + ]) + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ + status: 'pending', + txtRecordValue: 'sim-domain-verification=tok', + }) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.domain.added' }) + ) + }) + + it('re-adds an existing pending domain idempotently without rotating its token', async () => { + queueTableRows(member, [{ role: 'owner' }]) // membership + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + queueTableRows(ssoDomain, [ + { + id: 'd-existing', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok-existing', + verifiedAt: null, + }, + ]) // org-domains read → already claimed, still pending + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ + id: 'd-existing', + status: 'pending', + txtRecordValue: 'sim-domain-verification=tok-existing', // unchanged, not rotated + }) + // No write occurred — the existing row is returned as-is. + expect(dbChainMockFns.returning).not.toHaveBeenCalled() + }) + + it('stays idempotent when a concurrent claim wins the unique index race', async () => { + queueTableRows(member, [{ role: 'owner' }]) // membership + queueTableRows(ssoDomain, []) // verified-elsewhere check → none + queueTableRows(ssoDomain, []) // org-domains read → none existing, under the cap + // insert().returning() loses the race and hits sso_domain_org_domain_unique + dbChainMockFns.returning.mockRejectedValueOnce( + Object.assign(new Error('duplicate key'), { code: '23505' }) + ) + queueTableRows(ssoDomain, [ + { + id: 'd-winner', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok-winner', + verifiedAt: null, + }, + ]) // re-read returns the row that landed + const res = await POST(req({ domain: 'acme.com' }), routeContext) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.domain).toMatchObject({ id: 'd-winner', status: 'pending' }) + }) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/domains/route.ts b/apps/sim/app/api/organizations/[id]/domains/route.ts new file mode 100644 index 00000000000..88ae74b8389 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/domains/route.ts @@ -0,0 +1,217 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, ssoDomain } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { normalizeSSODomain } from '@sim/utils/sso-domain' +import { and, asc, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + addOrganizationDomainContract, + MAX_ORGANIZATION_DOMAINS, +} from '@/lib/api/contracts/organization' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { generateVerificationToken, toDomainResponse } from '@/lib/auth/sso/domain-verification' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgDomainsAPI') + +/** + * GET /api/organizations/[id]/domains + * Lists the organization's claimed domains and their verification state. + * Accessible by any member. + */ +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: organizationId } = await params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + // Domain management is Enterprise-only, so a non-Enterprise org has no + // domains to return — surface only the entitlement flag (which drives the + // upgrade prompt) and never the list/tokens. + if (!isEnterprise) { + return NextResponse.json({ success: true, data: { isEnterprise: false, domains: [] } }) + } + + const rows = await db + .select() + .from(ssoDomain) + .where(eq(ssoDomain.organizationId, organizationId)) + .orderBy(asc(ssoDomain.createdAt)) + + // The pending TXT token is a management secret; only owner/admins (who can + // add/verify/remove) may read it. Members see the list and status without it. + const includeToken = isOrgAdminRole(memberEntry.role) + return NextResponse.json({ + success: true, + data: { + isEnterprise: true, + domains: rows.map((row) => toDomainResponse(row, { includeToken })), + }, + }) + } +) + +/** + * POST /api/organizations/[id]/domains + * Claims a domain and mints a DNS TXT verification token. Requires enterprise + * plan and owner/admin role. The domain starts `pending`; the org proves + * ownership by publishing the token and calling the verify endpoint. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(addOrganizationDomainContract, request, context, { + validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'), + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can manage domains' }, + { status: 403 } + ) + } + if (isBillingEnabled && !(await isOrganizationOnEnterprisePlan(organizationId))) { + return NextResponse.json( + { error: 'Domain verification is available on Enterprise plans only' }, + { status: 403 } + ) + } + + const domain = normalizeSSODomain(parsed.data.body.domain) + if (!domain) { + return NextResponse.json( + { error: 'Enter a valid domain, for example acme.com' }, + { status: 400 } + ) + } + + // A domain already verified by ANOTHER org cannot be claimed here; the + // partial unique index enforces this at write time, but check first for a + // clear error. Pending claims by others are allowed to coexist. + const [verifiedElsewhere] = await db + .select({ organizationId: ssoDomain.organizationId }) + .from(ssoDomain) + .where(and(eq(ssoDomain.domain, domain), eq(ssoDomain.status, 'verified'))) + .limit(1) + if (verifiedElsewhere && verifiedElsewhere.organizationId !== organizationId) { + return NextResponse.json( + { error: 'This domain is already verified by another organization' }, + { status: 409 } + ) + } + + // One bounded read (an org holds at most MAX_ORGANIZATION_DOMAINS rows) + // serves both the idempotent re-add check and the per-org cap. + const orgDomains = await db + .select() + .from(ssoDomain) + .where(eq(ssoDomain.organizationId, organizationId)) + + const existing = orgDomains.find((d) => d.domain === domain) + if (existing) { + // Idempotent: re-adding a domain the org already has returns the existing + // row unchanged. We deliberately do NOT rotate the token — the pending + // token is always shown in the UI (so it is never "lost"), rotating would + // invalidate a TXT record the admin may have already published, and under + // concurrent re-adds a rotation could hand back a token that a racing + // write has already superseded. + return NextResponse.json({ success: true, data: { domain: toDomainResponse(existing) } }) + } + + if (orgDomains.length >= MAX_ORGANIZATION_DOMAINS) { + return NextResponse.json( + { error: `An organization can claim at most ${MAX_ORGANIZATION_DOMAINS} domains` }, + { status: 400 } + ) + } + + let created: (typeof orgDomains)[number] + try { + ;[created] = await db + .insert(ssoDomain) + .values({ + id: generateId(), + organizationId, + domain, + status: 'pending', + verificationToken: generateVerificationToken(), + createdBy: session.user.id, + }) + .returning() + } catch (error) { + // A concurrent request for the same (org, domain) won the race and the + // sso_domain_org_domain_unique index rejected this insert. Stay + // idempotent: return the row that landed instead of surfacing a 500. + if (getPostgresErrorCode(error) === '23505') { + const [winner] = await db + .select() + .from(ssoDomain) + .where(and(eq(ssoDomain.organizationId, organizationId), eq(ssoDomain.domain, domain))) + .limit(1) + if (winner) { + return NextResponse.json({ success: true, data: { domain: toDomainResponse(winner) } }) + } + } + throw error + } + + logger.info('Domain claimed for verification', { organizationId, domain }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_DOMAIN_ADDED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Claimed domain ${domain} for verification`, + metadata: { domain }, + request, + }) + + return NextResponse.json({ success: true, data: { domain: toDomainResponse(created) } }) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index cf2bf5caf18..5d2a80a52e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -81,6 +81,9 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO)) +const DomainSettings = dynamic(() => + import('@/ee/sso/components/domain-settings').then((m) => m.DomainSettings) +) const SessionPolicySettings = dynamic(() => import('@/ee/session-policy/components/session-policy-settings').then( (m) => m.SessionPolicySettings @@ -163,6 +166,9 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'sso' && organizationId && } + {effectiveSection === 'domains' && organizationId && ( + + )} {effectiveSection === 'sessions' && organizationId && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index fc2e914762b..2996eb3c238 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -39,6 +39,7 @@ describe('unified settings navigation', () => { { id: 'inbox', label: 'Sim mailer', section: 'system' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, + { id: 'domains', label: 'Verified domains', section: 'enterprise' }, { id: 'sessions', label: 'Session policies', section: 'enterprise' }, { id: 'data-retention', label: 'Data retention', section: 'enterprise' }, { id: 'data-drains', label: 'Data drains', section: 'enterprise' }, diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 3693f88fd48..15cd1c21ed4 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -42,6 +42,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'sso', + 'domains', 'sessions', 'data-retention', 'data-drains', @@ -64,6 +65,7 @@ describe('settings navigation boundaries', () => { 'access-control', 'audit-logs', 'sso', + 'domains', 'sessions', 'data-retention', 'data-drains', @@ -119,6 +121,7 @@ describe('settings navigation boundaries', () => { 'billing', 'data-drains', 'data-retention', + 'domains', 'organization', 'sessions', 'sso', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index acf5ac6c8b6..c8eab37aea1 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -6,6 +6,7 @@ import { HexSimple, Key, KeySquare, + Link, Lock, LogIn, Palette, @@ -42,6 +43,7 @@ export type OrganizationSettingsSection = | 'access-control' | 'audit-logs' | 'sso' + | 'domains' | 'sessions' | 'data-retention' | 'data-drains' @@ -88,6 +90,7 @@ export type UnifiedSettingsSection = | 'teammates' | 'organization' | 'sso' + | 'domains' | 'whitelabeling' | 'copilot' | 'forks' @@ -541,6 +544,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] organization: { id: 'sso', group: 'security', order: 4 }, }, }, + { + label: 'Verified domains', + icon: Link, + docsLink: 'https://docs.sim.ai/platform/enterprise/verified-domains', + unified: { + id: 'domains', + description: 'Prove ownership of your email domains before configuring SSO.', + group: 'enterprise', + requiresHosted: true, + requiresEnterprise: true, + selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sso, + }, + planes: { + organization: { id: 'domains', group: 'security', order: 5 }, + }, + }, { label: 'Session policies', icon: Clock, @@ -554,7 +573,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, }, planes: { - organization: { id: 'sessions', group: 'security', order: 5 }, + organization: { id: 'sessions', group: 'security', order: 6 }, }, }, { @@ -571,7 +590,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, }, planes: { - organization: { id: 'data-retention', group: 'enterprise', order: 6 }, + organization: { id: 'data-retention', group: 'enterprise', order: 7 }, }, }, { @@ -587,7 +606,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, }, planes: { - organization: { id: 'data-drains', group: 'enterprise', order: 7 }, + organization: { id: 'data-drains', group: 'enterprise', order: 8 }, }, }, { @@ -603,7 +622,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, }, planes: { - organization: { id: 'whitelabeling', group: 'enterprise', order: 8 }, + organization: { id: 'whitelabeling', group: 'enterprise', order: 9 }, }, }, { @@ -739,6 +758,7 @@ export function getOrganizationSettingsFeatures( 'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, 'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso, + domains: SETTINGS_SELF_HOSTED_OVERRIDES.sso, sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx index 66ae7a1efc3..d2a3e0d6324 100644 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ b/apps/sim/components/settings/organization-settings-renderer.tsx @@ -23,6 +23,9 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) +const DomainSettings = dynamic(() => + import('@/ee/sso/components/domain-settings').then((module) => module.DomainSettings) +) const SessionPolicySettings = dynamic(() => import('@/ee/session-policy/components/session-policy-settings').then( (module) => module.SessionPolicySettings @@ -68,6 +71,9 @@ export function OrganizationSettingsRenderer({ } if (section === 'audit-logs') return if (section === 'sso') return + if (section === 'domains') { + return + } if (section === 'sessions') { return } diff --git a/apps/sim/ee/sso/components/domain-settings.tsx b/apps/sim/ee/sso/components/domain-settings.tsx new file mode 100644 index 00000000000..4185d96aeb8 --- /dev/null +++ b/apps/sim/ee/sso/components/domain-settings.tsx @@ -0,0 +1,195 @@ +'use client' + +import { useState } from 'react' +import { Button, ChipConfirmModal, ChipCopyInput, ChipInput, ChipTag, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { OrganizationDomain } from '@/lib/api/contracts/organization' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + useAddOrganizationDomain, + useOrganizationDomains, + useRemoveOrganizationDomain, + useVerifyOrganizationDomain, +} from '@/ee/sso/hooks/domains' + +interface DomainSettingsProps { + organizationId: string +} + +interface CopyFieldProps { + label: string + value: string +} + +function CopyField({ label, value }: CopyFieldProps) { + return ( +

+ ) +} + +interface DomainRowProps { + organizationId: string + domain: OrganizationDomain + onRemove: (domain: OrganizationDomain) => void +} + +function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { + const verifyDomain = useVerifyOrganizationDomain() + + async function handleVerify() { + try { + await verifyDomain.mutateAsync({ orgId: organizationId, domainId: domain.id }) + toast.success(`${domain.domain} verified`) + } catch (error) { + toast.error(getErrorMessage(error, 'Verification failed — check the DNS record and retry')) + } + } + + return ( +
+
+ {domain.domain} +
+ + {domain.status === 'verified' ? 'Verified' : 'Pending'} + + onRemove(domain), destructive: true }]} + /> +
+
+ + {domain.status === 'pending' && domain.txtRecordValue && ( +
+

+ Add this TXT record at your DNS provider, then verify. DNS changes can take up to 48 + hours to propagate. +

+ + +
+ +
+
+ )} +
+ ) +} + +export function DomainSettings({ organizationId }: DomainSettingsProps) { + const { data, isLoading } = useOrganizationDomains(organizationId) + const addDomain = useAddOrganizationDomain() + const removeDomain = useRemoveOrganizationDomain() + + const [newDomain, setNewDomain] = useState('') + const [pendingRemoval, setPendingRemoval] = useState(null) + + if (isLoading) { + return ( + + Loading domains... + + ) + } + + if (data && !data.isEnterprise) { + return ( + + + Domain verification is available on Enterprise plans only. + + + ) + } + + async function handleAdd() { + const value = newDomain.trim() + if (!value) return + try { + await addDomain.mutateAsync({ orgId: organizationId, body: { domain: value } }) + setNewDomain('') + toast.success(`${value} added — add the DNS record and verify`) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to add domain')) + } + } + + async function handleConfirmRemove() { + if (!pendingRemoval) return + try { + await removeDomain.mutateAsync({ orgId: organizationId, domainId: pendingRemoval.id }) + setPendingRemoval(null) + toast.success(`${pendingRemoval.domain} removed`) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to remove domain')) + } + } + + const domains = data?.domains ?? [] + + return ( + <> + +
+
+

+ Verify domains your organization owns. A domain must be verified before you can + configure SSO for it. +

+
+ setNewDomain(event.target.value)} + placeholder='acme.com' + className='min-w-0 flex-1' + /> + +
+
+ + {domains.length === 0 ? ( + No domains yet. + ) : ( +
+ {domains.map((domain) => ( + + ))} +
+ )} +
+
+ + !open && setPendingRemoval(null)} + title='Remove domain' + text={[ + 'Remove ', + { text: pendingRemoval?.domain ?? '', bold: true }, + "? You'll need to verify it again before you can configure SSO for it. Existing SSO sign-in is not affected.", + ]} + confirm={{ + label: 'Remove', + onClick: handleConfirmRemove, + pending: removeDomain.isPending, + pendingLabel: 'Removing...', + }} + /> + + ) +} diff --git a/apps/sim/ee/sso/hooks/domains.ts b/apps/sim/ee/sso/hooks/domains.ts new file mode 100644 index 00000000000..1b2b4a8906a --- /dev/null +++ b/apps/sim/ee/sso/hooks/domains.ts @@ -0,0 +1,72 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type AddOrganizationDomainBody, + addOrganizationDomainContract, + listOrganizationDomainsContract, + type OrganizationDomains, + removeOrganizationDomainContract, + verifyOrganizationDomainContract, +} from '@/lib/api/contracts/organization' + +export type DomainsResponse = OrganizationDomains + +export const DOMAINS_STALE_TIME = 60 * 1000 + +export const domainKeys = { + all: ['orgDomains'] as const, + lists: () => [...domainKeys.all, 'list'] as const, + list: (orgId: string) => [...domainKeys.lists(), orgId] as const, +} + +async function fetchDomains(orgId: string, signal?: AbortSignal): Promise { + const { data } = await requestJson(listOrganizationDomainsContract, { + params: { id: orgId }, + signal, + }) + return data +} + +export function useOrganizationDomains(orgId: string | undefined) { + return useQuery({ + queryKey: domainKeys.list(orgId ?? ''), + queryFn: ({ signal }) => fetchDomains(orgId as string, signal), + enabled: Boolean(orgId), + staleTime: DOMAINS_STALE_TIME, + }) +} + +export function useAddOrganizationDomain() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ orgId, body }: { orgId: string; body: AddOrganizationDomainBody }) => + requestJson(addOrganizationDomainContract, { params: { id: orgId }, body }), + onSettled: (_data, _error, { orgId }) => { + queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + }, + }) +} + +export function useVerifyOrganizationDomain() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ orgId, domainId }: { orgId: string; domainId: string }) => + requestJson(verifyOrganizationDomainContract, { params: { id: orgId, domainId } }), + onSettled: (_data, _error, { orgId }) => { + queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + }, + }) +} + +export function useRemoveOrganizationDomain() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ orgId, domainId }: { orgId: string; domainId: string }) => + requestJson(removeOrganizationDomainContract, { params: { id: orgId, domainId } }), + onSettled: (_data, _error, { orgId }) => { + queryClient.invalidateQueries({ queryKey: domainKeys.list(orgId) }) + }, + }) +} diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index c3ac2f94e85..ccaebe8547f 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -194,6 +194,51 @@ export const organizationSessionPolicyResponseSchema = z.object({ data: organizationSessionPolicyDataSchema, }) +export const MAX_ORGANIZATION_DOMAINS = 25 + +export const organizationDomainParamsSchema = z.object({ + id: z.string().min(1), + domainId: z.string().min(1), +}) + +export const addOrganizationDomainBodySchema = z.object({ + domain: z.string().min(1, 'Domain is required').max(253, 'Domain is too long'), +}) + +export type AddOrganizationDomainBody = z.input + +export const organizationDomainStatusSchema = z.enum(['pending', 'verified']) + +const organizationDomainSchema = z.object({ + id: z.string(), + domain: z.string(), + status: organizationDomainStatusSchema, + verifiedAt: z.string().nullable(), + /** DNS host the TXT record must live on (e.g. `_sim-challenge.acme.com`). */ + challengeHost: z.string(), + /** Exact TXT record value the org must publish. Null for grandfathered/verified rows. */ + txtRecordValue: z.string().nullable(), +}) + +export type OrganizationDomain = z.output + +const organizationDomainsDataSchema = z.object({ + isEnterprise: z.boolean(), + domains: z.array(organizationDomainSchema), +}) + +export type OrganizationDomains = z.output + +export const listOrganizationDomainsResponseSchema = z.object({ + success: z.boolean(), + data: organizationDomainsDataSchema, +}) + +export const organizationDomainResponseSchema = z.object({ + success: z.boolean(), + data: z.object({ domain: organizationDomainSchema }), +}) + export const revokeOrganizationSessionsResponseSchema = z.object({ success: z.boolean(), data: z.object({ @@ -581,6 +626,47 @@ export const revokeOrganizationSessionsContract = defineRouteContract({ }, }) +export const listOrganizationDomainsContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/domains', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: listOrganizationDomainsResponseSchema, + }, +}) + +export const addOrganizationDomainContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/domains', + params: organizationParamsSchema, + body: addOrganizationDomainBodySchema, + response: { + mode: 'json', + schema: organizationDomainResponseSchema, + }, +}) + +export const verifyOrganizationDomainContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/domains/[domainId]/verify', + params: organizationDomainParamsSchema, + response: { + mode: 'json', + schema: organizationDomainResponseSchema, + }, +}) + +export const removeOrganizationDomainContract = defineRouteContract({ + method: 'DELETE', + path: '/api/organizations/[id]/domains/[domainId]', + params: organizationDomainParamsSchema, + response: { + mode: 'json', + schema: z.object({ success: z.boolean() }), + }, +}) + // Read shape mirrors `OrganizationWhitelabelSettings` from // `@/lib/branding/types`. All fields are optional (nullable on the way in // for the PUT contract, but stored without nulls on the way out — the diff --git a/apps/sim/lib/auth/sso/domain-verification.test.ts b/apps/sim/lib/auth/sso/domain-verification.test.ts new file mode 100644 index 00000000000..3781924c137 --- /dev/null +++ b/apps/sim/lib/auth/sso/domain-verification.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildChallengeHost, + buildTxtRecordValue, + generateVerificationToken, + SSO_CHALLENGE_HOST_PREFIX, + toDomainResponse, +} from '@/lib/auth/sso/domain-verification' + +describe('domain-verification helpers', () => { + it('builds the challenge host on the underscore-prefixed label', () => { + expect(buildChallengeHost('acme.com')).toBe(`${SSO_CHALLENGE_HOST_PREFIX}.acme.com`) + expect(buildChallengeHost('eng.acme.com')).toBe('_sim-challenge.eng.acme.com') + }) + + it('prefixes the TXT value so it is unambiguous among other records', () => { + expect(buildTxtRecordValue('abc123')).toBe('sim-domain-verification=abc123') + }) + + it('generates high-entropy, unique tokens', () => { + const a = generateVerificationToken() + const b = generateVerificationToken() + expect(a).not.toBe(b) + expect(a.length).toBeGreaterThanOrEqual(32) + }) + + describe('toDomainResponse', () => { + it('exposes the TXT value only for pending domains', () => { + const pending = toDomainResponse({ + id: 'd1', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok', + verifiedAt: null, + }) + expect(pending).toEqual({ + id: 'd1', + domain: 'acme.com', + status: 'pending', + verifiedAt: null, + challengeHost: '_sim-challenge.acme.com', + txtRecordValue: 'sim-domain-verification=tok', + }) + }) + + it('redacts the pending TXT value when includeToken is false', () => { + const redacted = toDomainResponse( + { + id: 'd1', + domain: 'acme.com', + status: 'pending', + verificationToken: 'tok', + verifiedAt: null, + }, + { includeToken: false } + ) + expect(redacted.status).toBe('pending') + expect(redacted.txtRecordValue).toBeNull() + expect(redacted.challengeHost).toBe('_sim-challenge.acme.com') + }) + + it('never leaks the token for a verified domain', () => { + const verifiedAt = new Date('2026-07-23T00:00:00.000Z') + const verified = toDomainResponse({ + id: 'd2', + domain: 'acme.com', + status: 'verified', + verificationToken: 'secret', + verifiedAt, + }) + expect(verified.status).toBe('verified') + expect(verified.txtRecordValue).toBeNull() + expect(verified.verifiedAt).toBe(verifiedAt.toISOString()) + }) + }) +}) diff --git a/apps/sim/lib/auth/sso/domain-verification.ts b/apps/sim/lib/auth/sso/domain-verification.ts new file mode 100644 index 00000000000..76391991c86 --- /dev/null +++ b/apps/sim/lib/auth/sso/domain-verification.ts @@ -0,0 +1,107 @@ +import { Resolver } from 'node:dns/promises' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import type { OrganizationDomain } from '@/lib/api/contracts/organization' + +const logger = createLogger('SSODomainVerification') + +interface SsoDomainRow { + id: string + domain: string + status: string + verificationToken: string + verifiedAt: Date | null +} + +/** + * Maps a stored `sso_domain` row to its API shape. The TXT value (which embeds + * the verification token) is only returned for `pending` domains, and only when + * `includeToken` is set — an already-verified row has no reason to expose its + * token, and the token is a management secret that non-admin readers must not + * see. Callers that gate on owner/admin (add/verify) leave it defaulted; a + * member-readable listing passes `includeToken: false` for non-admins. + */ +export function toDomainResponse( + row: SsoDomainRow, + options: { includeToken?: boolean } = {} +): OrganizationDomain { + const { includeToken = true } = options + const status = row.status === 'verified' ? 'verified' : 'pending' + return { + id: row.id, + domain: row.domain, + status, + verifiedAt: row.verifiedAt ? row.verifiedAt.toISOString() : null, + challengeHost: buildChallengeHost(row.domain), + txtRecordValue: + status === 'pending' && includeToken ? buildTxtRecordValue(row.verificationToken) : null, + } +} + +/** + * DNS label the verification TXT record lives under, prefixed to the domain + * being verified (e.g. `_sim-challenge.acme.com`). A dedicated underscore host + * — rather than the apex — avoids colliding with the domain's SPF/DMARC/other + * root TXT records and is the industry-standard placement. + */ +export const SSO_CHALLENGE_HOST_PREFIX = '_sim-challenge' + +/** Prefix on the TXT record value, so the token is unambiguous among other TXT records. */ +const TXT_VALUE_PREFIX = 'sim-domain-verification=' + +/** Public nameservers used for the challenge lookup, so verification does not + * depend on (or get poisoned by) the host's local resolver/split-horizon DNS. */ +const VERIFICATION_NAMESERVERS = ['1.1.1.1', '8.8.8.8'] + +const DNS_TIMEOUT_MS = 5000 + +/** + * Shared resolver pinned to the public nameservers. Its config is fully static + * and `resolveTxt` is safe to call concurrently, so a single module-scope + * instance avoids re-allocating one per verification. + */ +const verificationResolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 2 }) +verificationResolver.setServers(VERIFICATION_NAMESERVERS) + +/** The fully-qualified host an org must create the TXT record on. */ +export function buildChallengeHost(domain: string): string { + return `${SSO_CHALLENGE_HOST_PREFIX}.${domain}` +} + +/** The exact TXT record value an org must publish for a given token. */ +export function buildTxtRecordValue(token: string): string { + return `${TXT_VALUE_PREFIX}${token}` +} + +/** + * Generates a high-entropy verification token (~190 bits, URL-safe). Unguessable + * so an attacker cannot pre-create the TXT record for a domain they don't own. + */ +export function generateVerificationToken(): string { + return generateShortId(32) +} + +/** + * Resolves the challenge host's TXT records against public nameservers and + * returns true when the expected `sim-domain-verification=` value is + * present. Never throws — resolution failures (NXDOMAIN, timeout, missing + * record) resolve to `false` so a not-yet-propagated record simply reads as + * unverified. + */ +export async function checkDomainTxtRecord(domain: string, token: string): Promise { + const host = buildChallengeHost(domain) + const expected = buildTxtRecordValue(token) + + try { + const records = await verificationResolver.resolveTxt(host) + // Each TXT record may be split into multiple strings — join the chunks. + return records.some((chunks) => chunks.join('') === expected) + } catch (error) { + logger.debug('TXT verification lookup failed (treated as unverified)', { + host, + error: getErrorMessage(error), + }) + return false + } +} diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index a7bfb980408..a3a7e5b3775 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -133,6 +133,9 @@ export const AuditAction = { ORGANIZATION_UPDATED: 'organization.updated', ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked', + ORGANIZATION_DOMAIN_ADDED: 'organization.domain.added', + ORGANIZATION_DOMAIN_VERIFIED: 'organization.domain.verified', + ORGANIZATION_DOMAIN_REMOVED: 'organization.domain.removed', ORG_MEMBER_ADDED: 'org_member.added', ORG_MEMBER_REMOVED: 'org_member.removed', ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', diff --git a/packages/db/migrations/0268_sso_domain_verification.sql b/packages/db/migrations/0268_sso_domain_verification.sql new file mode 100644 index 00000000000..2e839b907d6 --- /dev/null +++ b/packages/db/migrations/0268_sso_domain_verification.sql @@ -0,0 +1,48 @@ +CREATE TABLE "sso_domain" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "domain" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "verification_token" text NOT NULL, + "verified_at" timestamp, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "sso_domain" ADD CONSTRAINT "sso_domain_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sso_domain" ADD CONSTRAINT "sso_domain_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 "sso_domain_organization_id_idx" ON "sso_domain" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "sso_domain_domain_idx" ON "sso_domain" USING btree ("domain");--> statement-breakpoint +CREATE UNIQUE INDEX "sso_domain_org_domain_unique" ON "sso_domain" USING btree ("organization_id","domain");--> statement-breakpoint +CREATE UNIQUE INDEX "sso_domain_verified_unique" ON "sso_domain" USING btree ("domain") WHERE status = 'verified';--> statement-breakpoint +-- Grandfather existing org-scoped SSO provider domains as verified: they were +-- claimed under the old first-come model, so treat them as owned/verified to +-- avoid breaking live SSO. The normalized value must match normalizeSSODomain +-- (the runtime gate's canonicalizer) so grandfathered lookups hit — hence +-- lower + trim + strip a leading wildcard label; other artifacts (protocol, +-- port, path) never occur in stored SSO domains. Computing it in a subquery +-- keeps the DISTINCT ON dedup key identical to the inserted value, so the +-- global partial unique index on verified rows can never be violated. The +-- token is a placeholder since these rows are already verified. +-- NOTE: if two orgs somehow share a domain (possible only for data predating the +-- register-route conflict check), DISTINCT ON grandfathers the lowest org_id and +-- the other org gets no row. Its existing SSO login is unaffected (login never +-- reads sso_domain); it just can't re-register that domain until an admin +-- resolves the duplicate. Validated against prod: no such duplicates exist. +INSERT INTO "sso_domain" ("id", "organization_id", "domain", "status", "verification_token", "verified_at", "created_at", "updated_at") +SELECT DISTINCT ON ("norm_domain") + gen_random_uuid()::text, + "organization_id", + "norm_domain", + 'verified', + gen_random_uuid()::text, + now(), + now(), + now() +FROM ( + SELECT "organization_id", lower(regexp_replace(btrim("domain"), '^\*\.', '')) AS "norm_domain" + FROM "sso_provider" + WHERE "organization_id" IS NOT NULL +) AS "normalized" +ORDER BY "norm_domain", "organization_id"; \ No newline at end of file diff --git a/packages/db/migrations/meta/0268_snapshot.json b/packages/db/migrations/meta/0268_snapshot.json new file mode 100644 index 00000000000..f4e52b58bcf --- /dev/null +++ b/packages/db/migrations/meta/0268_snapshot.json @@ -0,0 +1,17686 @@ +{ + "id": "e341f9c4-ac9a-4386-b6a6-4dfb31b952dd", + "prevId": "25435972-1fe1-4766-a728-d8e5c8818168", + "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": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": 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": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "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 + }, + "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_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": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "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": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS 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.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 + }, + "inputs": { + "name": "inputs", + "type": "json", + "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 + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_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 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_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.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "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": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "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 + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "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": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "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": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "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": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "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 + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "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_operation": { + "name": "workflow_deployment_operation", + "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": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_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": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "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": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "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 + }, + "deployment_operation_id": { + "name": "deployment_operation_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_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "organization_assigned_at": { + "name": "organization_assigned_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": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "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_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", + "workflow_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 7ec307a7e73..c9e4db84853 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1870,6 +1870,13 @@ "when": 1784861110986, "tag": "0267_dark_romulus", "breakpoints": true + }, + { + "idx": 268, + "version": "7", + "when": 1784861741441, + "tag": "0268_sso_domain_verification", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 2badf305690..5f67654c9fd 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3033,6 +3033,57 @@ export const ssoProvider = pgTable( }) ) +/** + * An email domain an organization has claimed, and its verification state. + * + * A domain must be **verified** (via a DNS TXT challenge — the org places + * `verificationToken` in a `_sim-challenge.` record) before it can be + * configured for single sign-on: verifying proves the org controls the domain, + * which is the security precondition for wiring it to an identity provider. + * Existing `sso_provider` domains are grandfathered as `verified` by the + * backfill in migration 0266. + */ +export const ssoDomain = pgTable( + 'sso_domain', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + /** Normalized (lowercase, registrable) domain — see `normalizeSSODomain`. */ + domain: text('domain').notNull(), + /** `'pending'` until the DNS TXT record is observed, then `'verified'`. */ + status: text('status').notNull().default('pending'), + /** High-entropy token placed in the domain's `_sim-challenge` TXT record. */ + verificationToken: text('verification_token').notNull(), + verifiedAt: timestamp('verified_at'), + 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('sso_domain_organization_id_idx').on(table.organizationId), + domainIdx: index('sso_domain_domain_idx').on(table.domain), + /** + * An org holds at most one row per domain. Makes claims idempotent under + * concurrency: two admins racing to add the same domain cannot create + * duplicate pending rows — the second insert hits this constraint. + */ + orgDomainUnique: uniqueIndex('sso_domain_org_domain_unique').on( + table.organizationId, + table.domain + ), + /** + * A verified domain is globally unique — exactly one org owns it. Pending + * rows may coexist (multiple orgs can race to prove ownership), so the + * constraint is a partial unique index scoped to verified rows. + */ + verifiedDomainUnique: uniqueIndex('sso_domain_verified_unique') + .on(table.domain) + .where(sql`status = 'verified'`), + }) +) + /** * Workflow MCP Servers - User-created MCP servers that expose workflows as tools. * These servers are accessible by external MCP clients via API key authentication, diff --git a/packages/db/scripts/register-sso-provider.ts b/packages/db/scripts/register-sso-provider.ts index a52c6fa45e1..a5f2ecf61ec 100644 --- a/packages/db/scripts/register-sso-provider.ts +++ b/packages/db/scripts/register-sso-provider.ts @@ -40,10 +40,11 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { normalizeSSODomain } from '@sim/utils/sso-domain' +import { and, eq } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { ssoProvider, user } from '../schema' +import { ssoDomain, ssoProvider, user } from '../schema' interface SSOMapping { id: string @@ -561,7 +562,7 @@ async function registerSSOProvider(): Promise { } const existingProviders = await db - .select() + .select({ id: ssoProvider.id }) .from(ssoProvider) .where(eq(ssoProvider.providerId, ssoConfig.providerId)) @@ -625,21 +626,69 @@ async function registerSSOProvider(): Promise { providerData.samlConfig = JSON.stringify(samlConfig) } - if (existingProviders.length > 0) { - await db - .update(ssoProvider) - .set({ - issuer: providerData.issuer, - domain: providerData.domain, - oidcConfig: providerData.oidcConfig, - samlConfig: providerData.samlConfig, - userId: providerData.userId, - organizationId: providerData.organizationId, - }) - .where(eq(ssoProvider.providerId, ssoConfig.providerId)) - } else { - await db.insert(ssoProvider).values(providerData) - } + // Write the provider and its verified-domain ownership record atomically so + // a failed domain upsert (e.g. the domain is already owned by another org) + // can never leave a live provider without its ownership row. Both commit or + // neither does. + await db.transaction(async (tx) => { + // Idempotent, race-safe upsert for a providerId that has NO unique + // constraint (sso_provider.provider_id is a plain index, and prod already + // holds legitimate duplicates): delete every row for this providerId, then + // insert exactly one. A prior "update by id, else insert" could create a + // duplicate when the observed row was deregistered and replaced before the + // transaction — with no unique constraint the fallback insert would + // succeed. Delete-then-insert inside the transaction guarantees the + // providerId ends up as exactly this config, atomically. Deleting the + // ssoProvider row does not touch linked accounts (they key on the + // providerId string, not the row id), so existing SSO logins keep working. + await tx.delete(ssoProvider).where(eq(ssoProvider.providerId, ssoConfig.providerId)) + await tx.insert(ssoProvider).values(providerData) + + // Keep the verified-domains model consistent with script registration: a + // domain registered for org SSO here is owned by that org, so mark it + // verified (mirrors migration 0266's grandfathering). Without this, a + // domain added by the script after the migration would be missing its + // verified record and the UI/API would treat it as unverified. Org-scoped + // only — user-scoped providers have no org to attach a domain to. + // Canonicalize with the SAME shared normalizer the runtime gate uses, so a + // script-created ownership row keys on the exact value the gate looks up + // (no divergence for protocol/port/path/@/trailing-dot spellings). + const normalizedDomain = providerData.organizationId + ? normalizeSSODomain(ssoConfig.domain) + : null + if (providerData.organizationId && !normalizedDomain) { + logger.warn( + 'Skipping verified-domain record: SSO_DOMAIN is not a valid registrable domain', + { domain: ssoConfig.domain } + ) + } + if (providerData.organizationId && normalizedDomain) { + const existingDomain = await tx + .select({ id: ssoDomain.id }) + .from(ssoDomain) + .where( + and( + eq(ssoDomain.organizationId, providerData.organizationId), + eq(ssoDomain.domain, normalizedDomain) + ) + ) + if (existingDomain.length === 0) { + await tx.insert(ssoDomain).values({ + id: generateId(), + organizationId: providerData.organizationId, + domain: normalizedDomain, + status: 'verified', + verificationToken: generateId(), + verifiedAt: new Date(), + }) + } else { + await tx + .update(ssoDomain) + .set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() }) + .where(eq(ssoDomain.id, existingDomain[0].id)) + } + } + }) logger.info('✅ SSO provider registered successfully in database!', { providerId: ssoConfig.providerId, diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 356ebc4174d..91f2fb0955b 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -107,6 +107,9 @@ export const auditMock = { ORGANIZATION_UPDATED: 'organization.updated', ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', ORGANIZATION_SESSIONS_REVOKED: 'organization.sessions.revoked', + ORGANIZATION_DOMAIN_ADDED: 'organization.domain.added', + ORGANIZATION_DOMAIN_VERIFIED: 'organization.domain.verified', + ORGANIZATION_DOMAIN_REMOVED: 'organization.domain.removed', ORG_MEMBER_ADDED: 'org_member.added', ORG_MEMBER_REMOVED: 'org_member.removed', ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index f7f4084c798..7adad24619b 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -916,6 +916,17 @@ export const schemaMock = { providerId: 'providerId', organizationId: 'organizationId', }, + ssoDomain: { + id: 'id', + organizationId: 'organizationId', + domain: 'domain', + status: 'status', + verificationToken: 'verificationToken', + verifiedAt: 'verifiedAt', + createdBy: 'createdBy', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, workflowMcpServer: { id: 'id', workspaceId: 'workspaceId', diff --git a/packages/utils/package.json b/packages/utils/package.json index db5ffdc6768..e97b74ddbef 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -49,6 +49,10 @@ "./retry": { "types": "./src/retry.ts", "default": "./src/retry.ts" + }, + "./sso-domain": { + "types": "./src/sso-domain.ts", + "default": "./src/sso-domain.ts" } }, "scripts": { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index fe2ab2f3b98..36cc743b146 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -32,4 +32,5 @@ export { } from './random.js' export type { BackoffOptions } from './retry.js' export { backoffWithJitter, parseRetryAfter } from './retry.js' +export { normalizeSSODomain } from './sso-domain.js' export { normalizeEmail, truncate } from './string.js' diff --git a/apps/sim/lib/auth/sso/domain.test.ts b/packages/utils/src/sso-domain.test.ts similarity index 96% rename from apps/sim/lib/auth/sso/domain.test.ts rename to packages/utils/src/sso-domain.test.ts index 333a6576b5b..8d2f102ce0d 100644 --- a/apps/sim/lib/auth/sso/domain.test.ts +++ b/packages/utils/src/sso-domain.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { normalizeSSODomain } from '@/lib/auth/sso/domain' +import { normalizeSSODomain } from './sso-domain' describe('normalizeSSODomain', () => { it('lowercases and trims', () => { diff --git a/apps/sim/lib/auth/sso/domain.ts b/packages/utils/src/sso-domain.ts similarity index 76% rename from apps/sim/lib/auth/sso/domain.ts rename to packages/utils/src/sso-domain.ts index 30f6470b156..4bf0b5fd338 100644 --- a/apps/sim/lib/auth/sso/domain.ts +++ b/packages/utils/src/sso-domain.ts @@ -3,6 +3,11 @@ * strips protocol, path, query, port, a leading wildcard/`@`, an email local * part, and a trailing dot, then lowercases. Returns `null` for inputs that are * not a registrable domain (e.g. `example.com`), which callers treat as invalid. + * + * Shared so the HTTP route, the domain-claim API, and the self-host registration + * script all key SSO-domain ownership on the exact same canonical value — a + * divergence here would let equivalent spellings create separate ownership rows + * or make the runtime verification gate miss a script-created record. */ export function normalizeSSODomain(input: string): string | null { if (typeof input !== 'string') return null diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index b6f582a143f..7bbfe14e8c6 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: 970, - zodRoutes: 970, + totalRoutes: 973, + zodRoutes: 973, nonZodRoutes: 0, } as const From 70313cd2ada90bcbedab686f60409e37969e05f9 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 10:33:47 -0700 Subject: [PATCH 18/32] feat(providers): add Claude Opus 5 model (#5925) * feat(providers): add Claude Opus 5 model * fix(providers): route Opus 5 through adaptive thinking Opus 5 supports only adaptive thinking (no extended thinking / budget_tokens), same as Opus 4.8/4.7. Add opus-5 to supportsAdaptiveThinking so thinking requests use thinking.type: adaptive instead of falling through to budget_tokens extended thinking, which Opus 4.7+ rejects with a 400. * docs(agent): regenerate agent-stream docs for Opus 5 --- .../docs/en/workflows/blocks/agent.mdx | 2 +- .../providers/anthropic/core.thinking.test.ts | 2 ++ apps/sim/providers/anthropic/core.ts | 5 +++-- apps/sim/providers/models.ts | 22 ++++++++++++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/docs/en/workflows/blocks/agent.mdx b/apps/docs/content/docs/en/workflows/blocks/agent.mdx index ccfc2d8fbe8..0048e4858cf 100644 --- a/apps/docs/content/docs/en/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/agent.mdx @@ -108,7 +108,7 @@ Live tool-call chips stream for **Anthropic, Azure Anthropic, Google, Vertex AI, |----------|-------------------|--------| | OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | | Anthropic | Full thinking deltas | `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-opus-4-1`, `claude-sonnet-4-5`, `claude-haiku-4-5` | -| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5`, `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7` | +| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7` | | Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | | Azure Anthropic | Full thinking deltas | `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | | Google | Summaries only | `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | diff --git a/apps/sim/providers/anthropic/core.thinking.test.ts b/apps/sim/providers/anthropic/core.thinking.test.ts index 8e20f19a92a..1213fcf8751 100644 --- a/apps/sim/providers/anthropic/core.thinking.test.ts +++ b/apps/sim/providers/anthropic/core.thinking.test.ts @@ -14,6 +14,7 @@ describe('buildThinkingConfig', () => { for (const model of [ 'claude-fable-5', 'claude-sonnet-5', + 'claude-opus-5', 'claude-opus-4-8', 'claude-opus-4-7', ]) { @@ -27,6 +28,7 @@ describe('buildThinkingConfig', () => { for (const model of [ 'claude-fable-5', 'claude-sonnet-5', + 'claude-opus-5', 'claude-opus-4-8', 'claude-opus-4-7', ]) { diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 787e8eb4882..bdd690efe1d 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -98,7 +98,7 @@ const ANTHROPIC_THINKING_OUTPUT_HEADROOM = 4096 * Checks if a model supports adaptive thinking (thinking.type: "adaptive"). * Fable 5 supports ONLY adaptive thinking (always on; type: "disabled" is rejected). * Sonnet 5 supports ONLY adaptive thinking (manual budget_tokens returns a 400 error). - * Opus 4.8 and Opus 4.7 support ONLY adaptive thinking (no extended thinking / budget_tokens). + * Opus 5, Opus 4.8, and Opus 4.7 support ONLY adaptive thinking (no extended thinking / budget_tokens). * Opus 4.6 and Sonnet 4.6 support both extended and adaptive thinking — use adaptive. * Opus 4.5 supports effort but NOT adaptive thinking — it uses budget_tokens with type: "enabled". */ @@ -107,6 +107,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { return ( normalizedModel.includes('fable-5') || normalizedModel.includes('sonnet-5') || + normalizedModel.includes('opus-5') || normalizedModel.includes('opus-4-8') || normalizedModel.includes('opus-4.8') || normalizedModel.includes('opus-4-7') || @@ -121,7 +122,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { /** * Builds the thinking configuration for the Anthropic API based on model capabilities and level. * - * - Fable 5, Sonnet 5, Opus 4.8, Opus 4.7: Uses adaptive thinking only (no extended thinking support) + * - Fable 5, Sonnet 5, Opus 5, Opus 4.8, Opus 4.7: Uses adaptive thinking only (no extended thinking support) * - Opus 4.6, Sonnet 4.6: Uses adaptive thinking with effort parameter * - Other models: Uses budget_tokens-based extended thinking * diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 6c046c26489..c103bf2b083 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -797,6 +797,27 @@ export const PROVIDER_DEFINITIONS: Record = { releaseDate: '2026-06-30', recommended: true, }, + { + id: 'claude-opus-5', + pricing: { + input: 5.0, + cachedInput: 0.5, + output: 25.0, + updatedAt: '2026-07-24', + }, + capabilities: { + nativeStructuredOutputs: true, + maxOutputTokens: 128000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'high', + streamed: 'summary', + }, + }, + contextWindow: 1000000, + releaseDate: '2026-07-24', + recommended: true, + }, { id: 'claude-opus-4-8', pricing: { @@ -816,7 +837,6 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1000000, releaseDate: '2026-05-28', - recommended: true, }, { id: 'claude-opus-4-7', From e83d8b84b4d6a8f17a02b9b31f9dbf143ec346e6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 10:56:44 -0700 Subject: [PATCH 19/32] content(library): add observability, procurement, and MCP security guides (#5928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * content(library): add observability, procurement, and MCP security guides Three AEO guides, each dated into a recent empty day in the posting calendar (2026-07-19, 07-21, 07-22) so publication is spread across days rather than dumped on one. - ai-agent-observability: what observability is, why APM falls short for non-deterministic agents, what to instrument per lifecycle stage, and the signals to track - ai-agents-in-procurement: what procurement agents do, buy-vs-build, where they add value, and how to start narrow - mcp-security: tool poisoning, confused-deputy/OAuth flaws, and supply-chain risk, plus how to build and govern MCP servers securely Every third-party factual claim carries an outbound citation to a verified primary source (OpenTelemetry semconv, Fiddler, LangChain and PwC surveys, Icertis/ProcureCon, Ironclad, GEP, the MCPoison/CurXecute CVEs on NVD, Anthropic's MCP announcement, RFC 8707/9700, OAuth 2.1, the MCP auth spec), and each post carries 3 internal links to related library posts. One claim from the source copy — a "November 2025" date on the WhatsApp MCP exfil case — could not be verified and was dropped; the case itself is cited to Docker's writeup. Covers come from the autogeneration pipeline via the standard /library//cover.jpg path. * fix(library): correct two unverifiable claims in the new guides Accuracy pass on the three new posts turned up two claims that could not be substantiated: - HIPAA compliance: the procurement post claimed Sim has "SOC2 and HIPAA compliance," but the canonical compliance data (lib/compare/data/sim.ts) states SOC2 only, and explicitly that Sim offers self-hosting "beyond SOC2, rather than additional certifications." Removed HIPAA; kept SOC2 plus self-hosting for data residency. (The same claim exists in ~5 pre-existing library posts and should be corrected separately.) - "more than 18,000 servers were listed on MCP Market": no source substantiates this figure. Replaced with "thousands of community-built servers," which the ecosystem supports, keeping the Anthropic and MCP Market links. Every other third-party claim was verified against a primary source (both CVEs on NVD, RFC 8707 title, RFC 9700 as January 2025, and all six survey statistics). --- .../library/ai-agent-observability/index.mdx | 134 ++++++++++++++++++ .../ai-agents-in-procurement/index.mdx | 123 ++++++++++++++++ .../content/library/mcp-security/index.mdx | 118 +++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 apps/sim/content/library/ai-agent-observability/index.mdx create mode 100644 apps/sim/content/library/ai-agents-in-procurement/index.mdx create mode 100644 apps/sim/content/library/mcp-security/index.mdx diff --git a/apps/sim/content/library/ai-agent-observability/index.mdx b/apps/sim/content/library/ai-agent-observability/index.mdx new file mode 100644 index 00000000000..6d49debe728 --- /dev/null +++ b/apps/sim/content/library/ai-agent-observability/index.mdx @@ -0,0 +1,134 @@ +--- +slug: ai-agent-observability +title: 'AI Agent Observability: Why It Is Essential' +description: AI agent observability gives step-by-step visibility into how agents reason, call tools, and decide, so you can trace failures, control costs, and ship with confidence. +date: 2026-07-19 +updated: 2026-07-19 +authors: + - andrew +readingTime: 9 +tags: [AI Agent Observability, Observability, AI Agents, Monitoring, Sim] +ogImage: /library/ai-agent-observability/cover.jpg +ogAlt: AI agent observability turning an agent from a black box into an inspectable glass box. +canonical: https://www.sim.ai/library/ai-agent-observability +draft: false +faq: + - q: "What is AI agent observability?" + a: "AI agent observability is the practice of capturing and analyzing an agent's internal behavior to understand and improve how it works. It rests on four pillars: traces (the full task path), logs (step-level events), metrics (latency, cost, and error rates), and evaluations (output quality scoring)." + - q: "How is AI agent observability different from traditional monitoring?" + a: "Traditional monitoring answers 'is the system up?' by tracking uptime, response times, and status codes. Agent observability answers 'is the agent making good decisions?' by inspecting reasoning and tool choices. It exists because agents are non-deterministic, so the same prompt can produce different behavior each run." + - q: "What is the difference between traces and spans?" + a: "A trace is the complete path of a single task from start to finish. Spans are the individual steps within that trace, such as one LLM call or one tool invocation. Together they form a span tree that shows how the whole task unfolded." + - q: "When does AI agent observability become critical?" + a: "In prototyping, it is optional, since print statements and instant reruns are enough. It becomes essential in production, where you need full execution context to reproduce reported failures. It becomes even more important in multi-agent systems, where failures happen between agents and across turns." + - q: "What metrics should I track for AI agents?" + a: "Track latency per task and step, cost per run and per model, request and tool-call error rates, and success rates by task type. Add agent-specific signals like tool-selection accuracy and hallucination detection, since these predict reliability in ways generic metrics cannot." + - q: "Do I need a separate observability tool?" + a: "Dedicated observability tools exist and work well, especially for large, multi-framework deployments. But if you build in a workspace with native logging, you can cover core needs like execution logs, trace spans, and per-model cost tracking without setting up a separate stack. Match the choice to your scale and existing tooling." + - q: "Does observability help control agent costs?" + a: "Yes. By attributing token usage, latency, and cost to individual steps, observability shows exactly which prompts, tools, or loops drive spend. That lets you catch expensive patterns during testing, before they compound across production traffic." +--- + +Your agent aced every question in the demo. In production, it confidently returns a wrong answer, calls the wrong tool, or loops on itself, and the dashboard stays green the whole time. You know something broke, but you have no way to see where or why. + +AI agent observability helps you fix this issue. It exposes how an agent reasons, which tools it calls, what it retrieves, and where it goes off track, so debugging becomes an evidence-based process instead of guesswork. + +This guide covers what observability is, why traditional monitoring falls short for agents, what to instrument at each stage, the signals worth tracking, and how to start. + +## Key Takeaways + +- **Observability makes agents inspectable:** It captures reasoning steps, tool calls, retrievals, and outputs so you can understand and improve agent behavior. +- **Observability rests on four key pillars:** Traces, logs, metrics, and evaluations together turn a black box into a glass box. +- **Traditional monitoring doesn't give you the visibility you need:** APM confirms the system is up, not whether the agent made good decisions on non-deterministic runs. +- **Flying blind is expensive:** Untraced failures erode trust, hide cost leaks, and create compliance gaps as agents act autonomously. +- **Instrumentation scales with lifecycle:** Print statements suffice in prototyping; full execution context is non-negotiable in production. +- **Open standards reduce lock-in:** [OpenTelemetry's GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) standardize what to capture across frameworks and vendors. + +## What Is AI Agent Observability? + +AI agent observability is the practice of capturing and analyzing an agent's internal behavior – its reasoning steps, tool calls, retrievals, and outputs – to understand and improve how it works. It answers not just whether the agent ran, but what it decided and why. + +Four building blocks make this possible. Traces record the full path of a task from start to finish. Logs capture detailed events at each step. Metrics measure latency, token usage, cost, and error or success rates. Evaluations judge whether outputs are accurate, relevant, and safe. + +Together, they turn the agent from a black box into a glass box you can inspect, debug, and improve as you observe how it works. + +| Pillar | What It Captures | Example Data Point | Why It Matters | +| --- | --- | --- | --- | +| Traces | The full path of a single task | Span tree from user request through tool calls | Reproduces exactly how a task unfolded | +| Logs | Detailed events at each step | Prompt version sent to the model | Pinpoints the moment behavior changed | +| Metrics | Quantitative performance signals | Tokens and cost per run | Surfaces expensive or slow patterns | +| Evaluations | Output quality scoring | Faithfulness or relevance score | Confirms whether the answer was any good | + +## Why Traditional Monitoring Falls Short for Agents + +Traditional application performance monitoring was built for deterministic software. It tracks uptime, response times, CPU, memory, and HTTP status codes, all reliable proxies for health when the same input always produces the same output. + +Agents break that assumption. The same prompt can trigger different tool sequences, retrieve different documents, and produce different answers on each run, so a green dashboard tells you nothing about decision quality. As [Fiddler's analysis of OpenTelemetry](https://www.fiddler.ai/blog/opentelemetry-ai-observability-guide) puts it, telemetry captures what happened, but it does not assess whether what happened was good. + +Monitoring agent output requires a shift in mindset. You're not just asking "Is the system healthy?" You also need to know whether the agent reasoned soundly and chose the right tools. Establishing this requires data that legacy tools cannot collect: prompts, reasoning chains, tool invocations, context retrieval, and multi-agent handoffs. + +## Why Observability Is Essential: The Risks of Flying Blind + +Running agents without visibility exposes you to significant risk in four important areas. + +The business impact comes first. Incorrect responses erode revenue and customer trust, and you cannot fix a root cause you cannot trace. In [LangChain's State of AI Agents report](https://www.langchain.com/stateofaiagents), quality remains the biggest barrier to production. This year, one-third of respondents cited quality as their primary blocker. + +Operationally, hallucinations, hallucinated tool calls, decision loops, and drift degrade performance, and each failure compounds across multi-step systems. On compliance, missing audit trails and weak explainability create regulatory exposure, especially in regulated industries where agents act autonomously on sensitive data. On cost, spend that looked affordable in a pilot leaks unchecked at scale without visibility into token usage and tool-invocation patterns. + +These risks scale with adoption. [PwC's AI agent survey](https://www.pwc.com/us/en/tech-effect/ai-analytics/ai-agent-survey.html) found that 79 percent say AI agents are already being adopted in their companies – the more organizations that adopt AI, the greater your potential liability. + +## What to Instrument and When + +Instrumentation needs to scale with the stage of the agent's lifecycle. Match your effort to where you are instead of over-building early or under-building late. + +### Prototyping + +Print statements and local logs are usually enough when you run one execution at a time and can rerun instantly. Focus on watching tool calls and outputs while you iterate quickly. This is where edge cases such as ambiguous queries, retrieval failures, and tool timeouts first surface. + +### Pre-Production + +Move to structured traces that capture tool calls, prompt versions, and model outputs so you can compare behavior across test runs. Start building evaluation datasets from real runs, so your tests reflect real-world behavior rather than idealized inputs. + +### Production + +You need full execution context, including conversation history, retrieval results, and reasoning, to reproduce reported failures. Track per-step token usage, latency, and cost to catch expensive patterns before they hit the budget, then feed production traces back into regression tests and evaluations to drive continuous improvement. + +| Stage | Primary Goal | What to Capture | Tooling Approach | +| --- | --- | --- | --- | +| Prototyping | Iterate fast on behavior | Tool calls, outputs, edge cases | Print statements and local logs | +| Pre-Production | Compare runs reliably | Structured traces, prompt versions, eval sets | Structured tracing and eval datasets | +| Production | Reproduce and improve | Full execution context, per-step cost and latency | Continuous tracing plus regression evals | + +## Core Metrics and Signals to Track + +Focus on tracking metrics that indicate how reliably agents perform. Start with the fundamentals: latency per task and per step, cost per run and per model, request and tool-call error rates, and success rates broken out by task type. + +Then, add the agent-specific signals traditional tools miss: + +- **Tool selection accuracy:** Did the agent choose the right tool for the step? +- **Reasoning-path quality:** Was the decision chain sound, or did it wander? +- **Context window utilization:** How much of the window is doing useful work? +- **Hallucination detection:** Did the output contradict retrieved sources? + +Evaluations score the qualitative side. LLM-as-a-judge and code-based evals grade correctness, relevance, and tool-usage accuracy. In multi-agent systems, session-level and thread-level visibility matters more than isolated single traces, because failures happen between agents and across turns. + +Retrieval-heavy agents add their own failure surface, which we cover in [the best AI agents for data extraction and RAG](/library/best-ai-agents-for-data-extraction-and-rag-in-2026). If your agents call external tools over the Model Context Protocol, [what an MCP server is](/library/what-is-an-mcp-server) explains the tool boundary you'll be tracing across. + +## How to Get Started With AI Agent Observability + +Here are some practical first steps you can take immediately: + +- **Emit telemetry from day one.** Instrument agents to produce traces, logs, and metrics before you need them, not after an incident. +- **Adopt open standards.** Use [OpenTelemetry's GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/), which standardize how GenAI operations are recorded. This avoids vendor lock-in. +- **Trace at the decision layer.** Capture reasoning and tool choices, not just request-response boundaries. +- **Close the loop.** Build test datasets from real production traces and run continuous evaluations. + +Plan for common challenges too: trace volume at scale, alert fatigue, fragmented visibility across systems, and privacy or PII handling in telemetry. + +Building in a workspace with native logging removes much of the complexity of this process. When you manage observability from the environment where you build and deploy agents, you get execution logs, trace spans, and per-model cost tracking without assembling a separate stack. Sim's Logs module works this way, giving full workflow logs, trace spans, and cost breakdowns per model and token type inside the visual workflow builder itself. If you are still assembling that workflow, [how to build AI agents with Sim](/library/how-to-create-an-ai-agent) walks through the first one. + +## The Bottom Line + +If your agents touch production, treat observability as a launch requirement, not a later add-on, because you cannot debug, cost-control, or trust what you cannot see. The fastest way to start is to instrument at the decision layer today and route those traces somewhere you can query them. + +[Create your next agent in a workspace with built-in observability](https://sim.ai), so execution logs, trace spans, and per-model cost tracking come standard from your very first run. diff --git a/apps/sim/content/library/ai-agents-in-procurement/index.mdx b/apps/sim/content/library/ai-agents-in-procurement/index.mdx new file mode 100644 index 00000000000..e1538081ba9 --- /dev/null +++ b/apps/sim/content/library/ai-agents-in-procurement/index.mdx @@ -0,0 +1,123 @@ +--- +slug: ai-agents-in-procurement +title: 'AI Agents in Procurement: A Comprehensive Guide' +description: AI agents in procurement automate intake, sourcing, contracts, and supplier risk. Learn what they do, where they add value, and how to build your own. +date: 2026-07-21 +updated: 2026-07-21 +authors: + - andrew +readingTime: 8 +tags: [AI Agents, Procurement, Automation, Sim] +ogImage: /library/ai-agents-in-procurement/cover.jpg +ogAlt: AI agents in procurement automating intake, sourcing, contracts, and supplier risk. +canonical: https://www.sim.ai/library/ai-agents-in-procurement +draft: false +faq: + - q: "What are AI agents in procurement?" + a: "AI agents in procurement are software programs that use an LLM to interpret a goal, plan steps, and act across your systems with limited supervision. They handle tasks like intake and routing, sourcing research, contract renewals, PO creation, and supplier risk monitoring, escalating key decisions to a human." + - q: "How are AI agents different from RPA or traditional procurement software?" + a: "Traditional software and RPA bots follow fixed, predefined rules and break when inputs change. AI agents reason over context, interpret messy or unstructured data, and adapt across multiple steps. Rules-based tools suit stable, high-volume work, while agents handle judgment-heavy tasks." + - q: "What procurement tasks can AI agents automate first?" + a: "Good starting points include intake and orchestration, sourcing research, contract renewals, purchase order creation, and supplier risk monitoring. Start narrow with one low-risk, repeatable task, assess the value added by the agent, then expand to adjacent workflows." + - q: "Will AI agents replace procurement jobs?" + a: "Agents clear repetitive, transactional work rather than replacing the function wholesale. Procurement professionals shift toward orchestration, oversight, supplier relationships, and category strategy. They take on more high-level, strategic work as more routine tasks are automated." + - q: "Do I need to code to build a procurement agent?" + a: "No. In an AI workspace like Sim, you can build agents visually with drag-and-drop blocks or conversationally by describing what you want. Coding is optional for teams that want deeper customization." + - q: "How do I keep procurement agents secure and compliant?" + a: "Set clear guardrails and thresholds, and require human approval on decisions that touch spend. Use role-based access control, audit trails, and self-hosting or bring-your-own-keys for data control. Choose a platform with SOC2 compliance, and self-hosting for data-residency needs, to meet enterprise standards." +--- + +Procurement leaders are being asked to move faster and spend less while keeping a close watch on supplier risk, usually with the same headcount and a queue full of manual intake, purchase orders, and email threads. AI agents in procurement offer a practical way out: software that reads a request, plans the steps, and acts across your systems with light supervision. + +This guide covers what these agents are, where they add the most value, and how to get one running. Two decisions are particularly important, so we'll focus there: which procurement tasks to automate first, and whether to buy a pre-built agent or build your own. + +## Key Takeaways + +- **AI agents are autonomous coworkers:** AI agents use an LLM to interpret a goal, plan steps, and act across your procurement systems with limited human oversight. +- **Adoption is accelerating:** 90 percent of procurement leaders have considered or are already using AI agents to optimize operations, per an [Icertis and ProcureCon survey](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/). +- **Best first tasks include** intake and orchestration, sourcing research, contract renewals, PO creation, and supplier risk monitoring. +- **Buy vs build:** Buy for a narrow, standardized need; build when workflows are unique, systems are many, and data control matters. +- **Start narrow:** Implement one low-risk agent with clear guardrails and well-defined human approvals, then monitor and expand. + +## What Are AI Agents in Procurement? + +AI agents in procurement use a large language model (LLM) to interpret a goal, break it into steps, and act across your systems with limited human supervision. Agentic AI is the broader layer above that: multiple agents coordinating toward complex, multi-stage goals, like running a full sourcing event end to end. + +Agents work in a simple loop. They perceive by monitoring spend, supplier data, and inbound requests. They reason by weighing tradeoffs against policy and thresholds. Then they act, executing or recommending a decision within set guardrails. + +Under the hood, agents combine several building blocks: + +- LLMs for language understanding +- Orchestration logic to sequence tasks +- Memory for context, tools, and API integrations to reach your systems +- Retrieval-augmented generation to ground answers in your real data +- Human-in-the-loop controls for approvals + +### AI Agents vs Traditional Procurement Software + +Legacy procurement tools automate specific tasks using static, predefined rules and lean heavily on human oversight. RPA (robotic process automation) bots automate workflows with clearly defined rules, inputs, outputs, and process triggers. AI agents adapt, interpret messy inputs, and make context-based decisions across multiple steps. We cover this distinction in depth in [AI agents vs RPA](/library/ai-agents-vs-rpa). + +| Approach | Adaptability | Human Oversight Needed | Best For | +| --- | --- | --- | --- | +| Traditional procurement software | Low, fixed rules | High, manual steps and review | Structured forms, catalogs, approvals | +| RPA bots | Low, breaks on change | Medium, exception handling | Repetitive, high-volume data entry | +| AI agents | High, reasons over context | Low to medium, approvals on key calls | Judgment-heavy, multi-step work | + +Rules-based tools remain a solid fit for stable, high-volume steps. Agents provide the most value on judgment-heavy, multi-step work where inputs vary. + +## Where AI Agents Deliver Value in Procurement + +The fastest wins come where there's abundant unstructured data and repeatable knowledge work a human can review. Four areas stand out. + +**Intake and orchestration.** Agents translate a business request into structured intake, check policy and spend thresholds, then route the buyer to the right channel or an existing contract. This matches what practitioners already prioritize: a recent [Ironclad survey](https://ironcladapp.com/resources/webinars/virtual-panel-state-of-ai-procurement) found the top AI use cases were tracking supplier contractual commitments (77%) and workflow automation and procurement orchestration (67%). + +**Strategic sourcing.** Agents run always-on market research, shortlist suppliers, analyze bids, and prepare recommendations. Humans use these resources to decide who to award a contract to. + +**Contract lifecycle and renewals.** Agents surface key terms, flag anomalies, monitor compliance, and prompt renewals before deadlines slip. + +**Purchase orders, supplier management, and risk.** Agents automate PO creation, watch supplier performance and external risk signals, and escalate issues to a person. Throughout, humans manage strategy, relationships, and final approvals while agents clear the repetitive load. + +## Should You Buy a Pre-Built Agent or Build Your Own? + +Buying makes sense when you have a narrow, standardized need and a mature vendor already serves it. Building may have the edge if your workflows are unique, you run multiple existing systems, or you have strict data control requirements. + +| Criteria | Pre-Built Suite | Build in a Workspace | +| --- | --- | --- | +| Fit to your process | Vendor's template | Shaped to your workflows | +| Integration with existing tools | Limited to the suite | Broad, connects your stack | +| Speed to first agent | Fast if it fits | Fast with templates | +| Customization | Constrained | Full control | +| Vendor lock-in | High | Low, open options | +| Data control and governance | Vendor-defined | You define it | + +Sim is the open-source AI workspace where procurement and IT teams build agents visually, conversationally, or with code. It connects 1,000+ integrations including Salesforce, Slack, Gmail, databases, and ERP systems, without adopting a rigid suite. + +For regulated procurement, it also fits governance needs: real-time collaboration, role-based access control, self-hosting for data residency, bring-your-own-keys, and SOC2 compliance. If you're weighing platforms more broadly, [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) compares the field. + +## How to Get Started With Procurement Agents + +Start with one narrow, low-risk agent rather than a full transformation. A strong first candidate is supplier email triage, an agent that scans inbound messages, flags delays, price increases, or contract issues, and logs each one to your system. + +Break the process down into smaller tasks: + +- **Pick a repeatable task:** Choose something high-volume with clear inputs. +- **Confirm data and systems:** Identify the sources and tools the agent needs. +- **Define goals and thresholds:** Set what "good" looks like and when to escalate. +- **Add guardrails and approvals:** Keep a human on decisions that touch spend. +- **Measure, then expand:** Track time saved, cycle time, and spend under management before rolling out more. + +Data readiness and guardrails are the two most common failure points, so address both before scaling. Sim's pre-built templates for email triage, data enrichment, and feedback analysis give teams a fast starting point they can customize and deploy quickly. For a step-by-step first build, see [how to build AI agents with Sim](/library/how-to-create-an-ai-agent). + +## Challenges and Best Practices + +Adoption is rarely painless. The most significant hurdles are messy or siloed data, integration complexity across ERP and spend tools, change management, and trust in autonomous decisions. Data is often the biggest blocker: [GEP-supported research](https://www.gep.com/blogs/strategy/clean-data-agentic-ai-orchestration-key-to-procurement-transformation) found that more than half of organizations (53%) do not have their key procurement data integrated into a single system or architecture. Icertis [reported similar friction](https://www.icertis.com/company/news/90-of-procurement-leaders-to-adopt-ai-agents-in-2025-according-to-icertis-sponsored-study/), with integration issues (88%) and data quality issues (75%) detracting from procurement confidence in AI. + +A few best practices keep programs on track. Clean and consolidate your data first, set clear standards and guardrails, keep humans in the loop on strategic decisions, and introduce agents gradually. This incremental path is the norm, since a lot of companies are already using agentic AI in some cross-functional capacity, most of them starting small. + +Agents should clear repetitive work while procurement professionals shift toward orchestration, oversight, and category strategy. Avoid seeing AI agents as a direct replacement for human procurement individuals, but hold them to the same security expectations. If they can act on spend or take other actions a human worker could, access control, audit trails, and data residency are non-negotiable. + +## The Bottom Line + +Start gradually and ship one narrow agent this quarter – the teams pulling ahead are the ones learning from a live use case rather than taking an over-theoretical approach. Pick a repeatable task like supplier email triage, wire in your real systems and approvals, and measure the time it saves. + +You can [build that first agent in Sim](https://sim.ai) from a template today, then expand once the results are on the table. diff --git a/apps/sim/content/library/mcp-security/index.mdx b/apps/sim/content/library/mcp-security/index.mdx new file mode 100644 index 00000000000..b8c61cc90bb --- /dev/null +++ b/apps/sim/content/library/mcp-security/index.mdx @@ -0,0 +1,118 @@ +--- +slug: mcp-security +title: 'MCP Security: A Practical Guide to Secure MCP Server Development' +description: MCP security covers the risks, auth flaws, and prompt-injection threats in Model Context Protocol servers; here's how to build and deploy MCP servers securely. +date: 2026-07-22 +updated: 2026-07-22 +authors: + - andrew +readingTime: 9 +tags: [MCP Security, MCP, Model Context Protocol, Security, Sim] +ogImage: /library/mcp-security/cover.jpg +ogAlt: Securing Model Context Protocol servers against tool poisoning, auth flaws, and supply-chain risk. +canonical: https://www.sim.ai/library/mcp-security +draft: false +faq: + - q: "What is MCP security?" + a: "MCP security is the practice of keeping MCP servers, clients, and connections from exposing data or executing unintended actions. The main risk categories are prompt and tool injection, authentication flaws like over-scoped tokens and confused-deputy issues, and supply-chain risk from untrusted third-party servers." + - q: "Is MCP secure by default?" + a: "No. The protocol standardizes how agents connect to tools, but security depends entirely on your implementation. Authorization is optional for MCP implementations, so your servers, token handling, and tool definitions determine whether a deployment is actually safe." + - q: "What are the biggest MCP security risks?" + a: "The three to prioritize are prompt and tool injection (especially tool poisoning, where malicious instructions hide in tool metadata), over-scoped tokens and confused-deputy problems in OAuth, and untrusted third-party servers that ship hidden behavior. Tool poisoning is the most prevalent and impactful client-side vulnerability." + - q: "How do you prevent prompt injection in MCP servers?" + a: "Validate and sanitize the inputs and outputs the model can act on, review and version tool definitions to catch silent changes, and require human approval for destructive actions like deletes, writes, and payments. Treating tool metadata as a security boundary is important, since the model reads descriptions as instructions." + - q: "Are local or remote MCP servers safer?" + a: "There's no definitive answer here; each has different trade-offs. Local servers give you control but execute code on infrastructure you host, so a flaw can mean local code execution. Remote servers can live on localhost, a private URL, or a public URL and reduce local execution risk, but they share your data with a third party you must vet and trust." + - q: "How does Sim help secure MCP deployments?" + a: "Sim provides self-hosted deployment for full data control, bring-your-own-keys, workspace and group access permissions, and approval flows. It also captures full run logs with trace spans for every execution, so MCP activity stays observable and governed as usage grows across a team." +--- + +You wired an agent to your internal tools in an afternoon, and it worked. That speed is exactly why MCP security deserves your attention before you ship. The same connection that lets an agent read your database, call your APIs, and run commands is a live attack surface for credential leakage, prompt injection, and unvetted third-party code. + +This guide is for builders who want to ship securely while enjoying the benefits offered by MCP connectivity. We'll take you through defining the risk, building a hardened server, and governing it in production. If you're new to the protocol itself, start with [what an MCP server is](/library/what-is-an-mcp-server). + +## Key Takeaways + +- **MCP security is implementation-dependent:** The protocol standardizes connections, but your servers, tokens, and tool definitions determine whether you're safe. +- **Tool poisoning is the sharpest risk:** Malicious instructions hidden in tool metadata can turn an approved server destructive; these evade reviews that only scan user input. +- **Auth details decide everything:** Over-scoped tokens and token passthrough create confused-deputy problems, so scope narrowly and validate the token audience. +- **Third-party servers are executable code:** Treat unknown MCP servers like any untrusted dependency, vet, sign, and pin them for ultimate security. +- **Security continues after deployment:** Sandboxing, secrets management, egress limits, full run logs, and RBAC keep MCP usage controlled as it scales. + +## What MCP Security Actually Means + +Strong MCP security practices prevent MCP servers, clients, and connections from exposing data or executing unintended actions. A good security strategy should span authentication, tool design, input validation, deployment, and ongoing monitoring. + +MCP connectivity is broken down into three roles. The host runs the LLM, the client speaks the protocol, and the server accesses the actual tools and data while holding the credentials. That server is where most risk concentrates, because it sits closest to your systems. + +The type of threats you should anticipate depends on deployment type. Local servers run on infrastructure you control and often execute OS-level commands, so a flaw can mean code execution on your box. Remote MCP servers can live on localhost, a private URL, or a public URL and are run by others, yet they still touch your data, so you're trusting a third party with sensitive access. + +Adoption is currently outpacing the maturity of MCP servers as a business tool, so the potential for security issues is significant. [Anthropic introduced MCP](https://www.anthropic.com/news/model-context-protocol) in late 2024, and the ecosystem grew fast — public directories like [MCP Market](https://mcpmarket.com/) now index thousands of community-built servers. Many ship faster than they're secured. Platforms like Sim use MCP for custom integrations, which makes MCP security a significant product concern. + +## The Fundamental MCP Security Risks You Need to Know + +These are the threats you design against. The table maps each risk to how it happens and how to shut it down. + +| Risk | How It Happens | Real Impact | Primary Mitigation | +| --- | --- | --- | --- | +| Prompt / Tool Injection | Malicious instructions hidden in tool metadata or external content | Data exfiltration, destructive actions | Review tool definitions, validate inputs/outputs, human approval | +| Confused Deputy / OAuth token misuse | Server forwards or accepts tokens meant for another resource | Privilege escalation across APIs | Validate token audience; use resource indicators | +| Token Passthrough & Over-scoped Credentials | Broad tokens passed to the server or downstream | One breach exposes everything | Least-privilege scopes, per-tool credentials | +| Supply Chain Risk | Untrusted third-party server ships hidden behavior | Backdoors, RCE | Signing, provenance, dependency scanning | +| Local Server Misconfiguration | Unauthenticated server executing OS commands | Local code execution | Auth on every server, sandboxing | +| Session Hijacking | Stolen or predictable session identifiers | Impersonation | Secure session binding, TLS | +| SSRF | Server fetches attacker-controlled URLs | Internal network access | Egress limits, URL allowlists | + +## Three Top MCP Security Concerns To Be Aware Of + +### Prompt and tool injection + +Tool poisoning, where malicious instructions are embedded in tool metadata, is the most prevalent and impactful client-side vulnerability. An amended tool definition can quietly instruct an agent to delete resources or redirect data while looking like ordinary configuration. + +Two documented cases, [MCPoison (CVE-2025-54136)](https://nvd.nist.gov/vuln/detail/CVE-2025-54136) and [CurXecute (CVE-2025-54135)](https://nvd.nist.gov/vuln/detail/CVE-2025-54135), proved the same structural point in Cursor's MCP handling: a trusted configuration could be swapped for a malicious one and reach code execution. Researchers separately demonstrated a [WhatsApp MCP integration flaw](https://www.docker.com/blog/mcp-horror-stories-whatsapp-data-exfiltration-issue/) where a malicious server poisoned tool descriptions, silently redirecting a user's message history to an attacker-controlled number. + +### Authentication + +If an MCP server accepts tokens with incorrect audiences and forwards them unmodified to downstream services, the downstream API incorrectly trusts the token. Over-scoped tokens compound this: hand a server broad credentials and one compromised path exposes everything. OAuth implementation details are where these bugs live. + +### Supply chain risk + +MCP servers are executable code, so an unvetted third-party server can carry hidden behavior. Signing, provenance checks, and dependency scanning are your defenses. + +## How to Build a Secure MCP Server + +### Design With Least Privilege + +Scope every token and permission to the minimum each tool needs, and avoid passing broad credentials to the server. Separate credentials per tool and data source so one compromised path doesn't open the rest. Decide local versus remote deployment based on data sensitivity and trust boundaries before you write any auth code. + +### Harden Authentication and Authorization + +Implement [OAuth](https://oauth.net/2.1/) correctly. An MCP client acts as an OAuth 2.1 client making requests on behalf of a resource owner, and the authorization server issues access tokens for use at the MCP server. + +Enforce per-client consent and validate redirect URIs to close confused-deputy gaps. Critically, the MCP server must not pass through the token it received from the client, and clients must use the resource parameter defined in [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) to specify the target resource. + +Never let the server act as an ambient super-user; enforce the requesting user's permissions on every call. Use the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700), the OAuth 2.0 security best practice published in January 2025, as a guide for best practice. + +### Treat Tool Definitions as Security-Critical Code + +Review and version tool definitions, and detect and block silent changes to tool behavior. Validate and sanitize the inputs and outputs the model can act on to shrink injection blast radius. Add guardrails or human approval for destructive actions like deletes, writes, and payments. + +### Secure the Supply Chain + +Run only trusted, signed servers, and vet third-party servers before connecting. Add SAST and software composition analysis to your build pipeline to catch vulnerable dependencies. Pin versions and monitor for behavioral changes in third-party servers. + +## Deploying and Governing MCP Servers in Production + +Shipping securely is half the job. MCP servers need ongoing governance and monitoring like any production system. + +Start with deployment controls. If you're self-hosting, isolate and sandbox servers so a compromise can't spread. You should also manage secrets outside the codebase, and limit network egress to reduce SSRF and lateral movement. + +Observability comes next. Log every tool call and MCP interaction, trace execution end to end, and alert on anomalous requests such as mass deletes, unusual data access, or injection patterns. You can't investigate what you don't record. + +Access governance keeps things controlled as teams scale. Implement RBAC, approval workflows, separate staging and production, and audit trails to keep track of activity surrounding your MCP servers. + +This is where a specialized platform helps. Sim is an AI workspace where teams build MCP-connected agents with enterprise controls, so security lives in the platform instead of being bolted on. You get self-hosted deployment for full data control, bring-your-own-keys, workspace and group access permissions, and full run logs with trace spans for every execution. Custom integrations connect through Sim's MCP support so your controls apply consistently. For the wider self-hosting landscape, see [open-source AI agent platforms](/library/open-source-ai-agent-platforms). + +## What To Do Next + +Treat your MCP server as untrusted until you've narrowed its tokens, versioned its tool definitions, and put every tool call under logs and approval gates. Pick your highest-risk server today and audit its security using the processes detailed above. If you're comparing where to run MCP-connected agents, [the best AI agent platforms in 2026](/library/best-ai-agent-platforms-2026) covers the field. If you're standardizing MCP across a team, [start building on Sim](https://sim.ai) so self-hosting, access control, and observability come built in. From 61053765932c14cbf43a9428a00ce290fc9e786f Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 11:01:20 -0700 Subject: [PATCH 20/32] improvement(ship): pre-flight regenerate artifacts + parallel audit suite (#5927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(ship): pre-flight regenerate artifacts + parallel audit suite Add a two-phase pre-ship step: (A) regenerate every committed artifact (agent-stream-docs, skills, contract syncs) in parallel so generated files never drift into a CI failure, then (B) run lint plus the full audit suite CI's Lint and Test job enforces, in parallel, aborting ship on any failure. Regenerate the ship command projections. * fix(ship): scope Phase A to in-repo generators + propagate failures Cursor review: (1) mship:generate is an umbrella over the 9 contract generators and biome-formats the shared generated dir, so running it alongside its constituents races/corrupts; it also reads an external copilot-contract source that ENOENTs in a bare worktree. (2) bare wait swallowed generator exit codes. Narrow Phase A to the always-in-repo generators (agent-stream-docs, skills) and collect each job's exit status in both phases; document domain generators as on-demand only. * fix(ship): make pre-flight status checks exit non-zero on failure Cursor/Greptile: grep && echo ❌ || echo ✅ always exits 0, so a failed generator/audit didn't actually gate ship (regressed the sequential bun-run checks whose non-zero exit agents rely on). Replace both Phase A and Phase B aggregations with an if-grep that exits 1 on any failure. * fix(ship): gate lint exit in Phase B pre-flight Cursor: bare bun run lint before the audit grep meant a non-zero lint was ignored — if audits then passed, the block exited 0 and ship continued. Gate lint with || { echo …; exit 1; } so an unfixable lint error aborts before the audits run. --- .agents/skills/ship/SKILL.md | 45 ++++++++++++++++++++++++++++++++---- .claude/commands/ship.md | 45 ++++++++++++++++++++++++++++++++---- .cursor/commands/ship.md | 45 ++++++++++++++++++++++++++++++++---- 3 files changed, 123 insertions(+), 12 deletions(-) diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 15ea661574f..8797df795b3 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -35,10 +35,47 @@ When the user runs `/ship`: 5. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`: - Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version). - `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy. -6. **Run pre-ship checks** from the repo root before staging: - - `bun run lint` to fix formatting issues - - `bun run check:api-validation:strict` to catch boundary contract failures before CI -7. **Stage and commit** the changes with the generated message +6. **Run pre-ship checks** from the repo root before staging. This has two phases: first **regenerate** every committed artifact so generated files never drift into a CI failure (this is what catches things like `agent-stream-docs` going stale after a `models.ts` edit), then run the **full audit suite** CI's `Lint and Test` job enforces. Both phases parallelize — but only across commands that write **disjoint** outputs — and a bare `wait` swallows child exit codes, so both phases below explicitly collect each job's status and abort ship if any failed. + + **Phase A — regenerate the always-in-repo committed artifacts (parallel), then let step 7 stage whatever changed.** Regenerate only the generators whose inputs live entirely in this repo and that any ordinary code change can drift — `agent-stream-docs:generate` (derives from the provider model registry) and `skills:sync` (derives from `.agents/skills/**`). They write disjoint trees (`apps/docs/…/agent.mdx` vs the `.claude`/`.cursor` command projections), so they parallelize safely, and each is idempotent (a no-op when already in sync): + ```bash + rm -f /tmp/ship-gen-results + for g in agent-stream-docs:generate skills:sync; do + ( bun run "$g" >"/tmp/ship-gen-${g//:/-}.log" 2>&1; echo "$? $g" >>/tmp/ship-gen-results ) & + done + wait + # any non-zero line is a FAILED generator — read /tmp/ship-gen-.log and fix before shipping; + # a silently-failed generate leaves a stale artifact that Phase B / CI then rejects. + # The `exit 1` makes this block itself exit non-zero on failure, so anything gating on the + # command's status (an agent, or a wrapping script) actually stops — do NOT collapse it to + # `grep … && echo ❌ || echo ✅`, which always exits 0 and silently lets ship continue. + if grep -vE '^0 ' /tmp/ship-gen-results; then echo "❌ generator(s) failed — do not ship"; exit 1; fi + echo "✅ artifacts regenerated" + ``` + Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes. + + **Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present. + + **Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree): + ```bash + # autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too — + # a non-zero lint (unfixable errors) must abort before the audits run, not be ignored. + bun run lint || { echo "❌ lint failed — do not ship"; exit 1; } + rm -f /tmp/ship-audit-results + for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \ + check:react-query check:client-boundary check:bare-icons check:icon-paths \ + check:realtime-prune skills:check agent-stream-docs:check; do + ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & + done + wait + # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. + # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is + # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. + if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi + echo "✅ all audits passed" + ``` + If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. +7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 8. **Push to origin** using the current branch name — `--force-with-lease` if step 2's sync check did any history rewrite (a clean rebase or a cherry-pick rebuild) on a branch that had already been pushed once; a plain push would be rejected in exactly the polluted-remote case diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index 0a5f01935b5..fdd40c011e6 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -34,10 +34,47 @@ When the user runs `/ship`: 5. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`: - Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version). - `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy. -6. **Run pre-ship checks** from the repo root before staging: - - `bun run lint` to fix formatting issues - - `bun run check:api-validation:strict` to catch boundary contract failures before CI -7. **Stage and commit** the changes with the generated message +6. **Run pre-ship checks** from the repo root before staging. This has two phases: first **regenerate** every committed artifact so generated files never drift into a CI failure (this is what catches things like `agent-stream-docs` going stale after a `models.ts` edit), then run the **full audit suite** CI's `Lint and Test` job enforces. Both phases parallelize — but only across commands that write **disjoint** outputs — and a bare `wait` swallows child exit codes, so both phases below explicitly collect each job's status and abort ship if any failed. + + **Phase A — regenerate the always-in-repo committed artifacts (parallel), then let step 7 stage whatever changed.** Regenerate only the generators whose inputs live entirely in this repo and that any ordinary code change can drift — `agent-stream-docs:generate` (derives from the provider model registry) and `skills:sync` (derives from `.agents/skills/**`). They write disjoint trees (`apps/docs/…/agent.mdx` vs the `.claude`/`.cursor` command projections), so they parallelize safely, and each is idempotent (a no-op when already in sync): + ```bash + rm -f /tmp/ship-gen-results + for g in agent-stream-docs:generate skills:sync; do + ( bun run "$g" >"/tmp/ship-gen-${g//:/-}.log" 2>&1; echo "$? $g" >>/tmp/ship-gen-results ) & + done + wait + # any non-zero line is a FAILED generator — read /tmp/ship-gen-.log and fix before shipping; + # a silently-failed generate leaves a stale artifact that Phase B / CI then rejects. + # The `exit 1` makes this block itself exit non-zero on failure, so anything gating on the + # command's status (an agent, or a wrapping script) actually stops — do NOT collapse it to + # `grep … && echo ❌ || echo ✅`, which always exits 0 and silently lets ship continue. + if grep -vE '^0 ' /tmp/ship-gen-results; then echo "❌ generator(s) failed — do not ship"; exit 1; fi + echo "✅ artifacts regenerated" + ``` + Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes. + + **Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present. + + **Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree): + ```bash + # autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too — + # a non-zero lint (unfixable errors) must abort before the audits run, not be ignored. + bun run lint || { echo "❌ lint failed — do not ship"; exit 1; } + rm -f /tmp/ship-audit-results + for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \ + check:react-query check:client-boundary check:bare-icons check:icon-paths \ + check:realtime-prune skills:check agent-stream-docs:check; do + ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & + done + wait + # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. + # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is + # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. + if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi + echo "✅ all audits passed" + ``` + If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. +7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 8. **Push to origin** using the current branch name — `--force-with-lease` if step 2's sync check did any history rewrite (a clean rebase or a cherry-pick rebuild) on a branch that had already been pushed once; a plain push would be rejected in exactly the polluted-remote case diff --git a/.cursor/commands/ship.md b/.cursor/commands/ship.md index 3d63eda0077..3049709300f 100644 --- a/.cursor/commands/ship.md +++ b/.cursor/commands/ship.md @@ -29,10 +29,47 @@ When the user runs `/ship`: 5. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`: - Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version). - `bun run check:migrations origin/staging` must pass (staging is the PR base). Do not silence a flagged statement with a `-- migration-safe:` annotation unless `/db-migrate` confirmed the old code no longer depends on it; otherwise split the destructive change into a later deploy. -6. **Run pre-ship checks** from the repo root before staging: - - `bun run lint` to fix formatting issues - - `bun run check:api-validation:strict` to catch boundary contract failures before CI -7. **Stage and commit** the changes with the generated message +6. **Run pre-ship checks** from the repo root before staging. This has two phases: first **regenerate** every committed artifact so generated files never drift into a CI failure (this is what catches things like `agent-stream-docs` going stale after a `models.ts` edit), then run the **full audit suite** CI's `Lint and Test` job enforces. Both phases parallelize — but only across commands that write **disjoint** outputs — and a bare `wait` swallows child exit codes, so both phases below explicitly collect each job's status and abort ship if any failed. + + **Phase A — regenerate the always-in-repo committed artifacts (parallel), then let step 7 stage whatever changed.** Regenerate only the generators whose inputs live entirely in this repo and that any ordinary code change can drift — `agent-stream-docs:generate` (derives from the provider model registry) and `skills:sync` (derives from `.agents/skills/**`). They write disjoint trees (`apps/docs/…/agent.mdx` vs the `.claude`/`.cursor` command projections), so they parallelize safely, and each is idempotent (a no-op when already in sync): + ```bash + rm -f /tmp/ship-gen-results + for g in agent-stream-docs:generate skills:sync; do + ( bun run "$g" >"/tmp/ship-gen-${g//:/-}.log" 2>&1; echo "$? $g" >>/tmp/ship-gen-results ) & + done + wait + # any non-zero line is a FAILED generator — read /tmp/ship-gen-.log and fix before shipping; + # a silently-failed generate leaves a stale artifact that Phase B / CI then rejects. + # The `exit 1` makes this block itself exit non-zero on failure, so anything gating on the + # command's status (an agent, or a wrapping script) actually stops — do NOT collapse it to + # `grep … && echo ❌ || echo ✅`, which always exits 0 and silently lets ship continue. + if grep -vE '^0 ' /tmp/ship-gen-results; then echo "❌ generator(s) failed — do not ship"; exit 1; fi + echo "✅ artifacts regenerated" + ``` + Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes. + + **Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present. + + **Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree): + ```bash + # autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too — + # a non-zero lint (unfixable errors) must abort before the audits run, not be ignored. + bun run lint || { echo "❌ lint failed — do not ship"; exit 1; } + rm -f /tmp/ship-audit-results + for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \ + check:react-query check:client-boundary check:bare-icons check:icon-paths \ + check:realtime-prune skills:check agent-stream-docs:check; do + ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & + done + wait + # any non-zero line is a failing audit — read its /tmp/ship-audit-.log and fix before shipping. + # `exit 1` on failure preserves the original sequential checks' semantics (their non-zero exit is + # what an agent gates on); never use `grep … && echo ❌ || echo ✅` here — it always exits 0. + if grep -vE '^0 ' /tmp/ship-audit-results; then echo "❌ audit(s) failed — do not ship"; exit 1; fi + echo "✅ all audits passed" + ``` + If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here. +7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6 8. **Push to origin** using the current branch name — `--force-with-lease` if step 2's sync check did any history rewrite (a clean rebase or a cherry-pick rebuild) on a branch that had already been pushed once; a plain push would be rejected in exactly the polluted-remote case From 5a8d21904deff9d454c9d7a2e54f22120ebe8124 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 11:32:27 -0700 Subject: [PATCH 21/32] fix(sso): surface DNS verification failures and the provider auto-append gotcha (#5931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sso): surface DNS verification failures and the provider auto-append gotcha Post-merge audit follow-ups for verified domains (#5909): - The host field handed admins the FQDN `_sim-challenge.acme.com`. GoDaddy, Namecheap, Hover and most cPanel panels append the zone to whatever is typed, yielding `_sim-challenge.acme.com.acme.com` — the record looks right in their panel but never verifies, and our 422 tells them to wait 48 hours. Add a hint under the field (and a docs callout) telling those admins to enter just the label. - DNS failures were logged at debug, but production log level is ERROR, so a blocked-egress or SERVFAIL condition was invisible to us and misreported to the admin as "record not found yet". Log infrastructure-class failures at warn with the DNS error code; keep the genuinely-absent codes at debug. - Trim the joined TXT value before comparing: several DNS panels pad the stored string, which otherwise fails an exact match forever. - Lower the resolver to 2s/1 try. c-ares multiplies timeout across servers and retries by ~7x, so the previous 5s/2-try config could block a verify request for ~35s when resolvers are unreachable. - Cover `checkDomainTxtRecord` — the function that decides whether the gate opens had no tests. Adds exact match, chunk-joined value, match among unrelated records, padded value, near-miss, another org's token, absent record, infrastructure failure, and empty-response cases. * fix(sso): log DNS infrastructure failures at error so prod actually surfaces them Production's default minimum log level is ERROR, so the warn introduced in the previous commit was still filtered out — the fault stayed invisible exactly as before. A resolver failure that is not 'record absent' is a genuine infrastructure error, so ERROR is both the visible and the honest severity. * fix(sso): state the zone-removal rule instead of a wrong subdomain hint The hint computed the bare label as the first segment of the challenge host, so for a subdomain like eng.acme.com it advised entering `_sim-challenge` when the host relative to the acme.com zone is `_sim-challenge.eng` — following it would publish the record on the wrong name and verification would never succeed, the exact failure the hint exists to prevent. Deriving the real zone needs the Public Suffix List (acme.co.uk defeats naive label-stripping), so state the rule instead: enter the host with the trailing zone removed. Docs show both the apex and the subdomain form. --- .../platform/enterprise/verified-domains.mdx | 6 +- .../sim/ee/sso/components/domain-settings.tsx | 10 ++- .../lib/auth/sso/domain-verification.test.ts | 81 ++++++++++++++++++- apps/sim/lib/auth/sso/domain-verification.ts | 47 +++++++++-- 4 files changed, 132 insertions(+), 12 deletions(-) diff --git a/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx index d56dc3230c6..fe91e17e83e 100644 --- a/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx @@ -23,7 +23,11 @@ Go to **Settings → Security → Verified domains** in your organization settin 3. Add that TXT record at your DNS provider. 4. Click **Verify**. Sim looks up the record; on success the domain is marked **Verified**. -DNS changes can take up to 48 hours to propagate — if verification does not succeed immediately, wait and retry. You can remove the TXT record after the domain is verified; the verification persists. + + Some DNS providers — GoDaddy, Namecheap, Hover, and most cPanel panels — append your zone to whatever you type in the host field. If yours does, enter the host with the trailing zone removed, or you will end up with `_sim-challenge.acme.com.acme.com` and verification will never succeed. Managing the `acme.com` zone, `_sim-challenge.acme.com` becomes `_sim-challenge`; verifying the subdomain `eng.acme.com` from that same zone, `_sim-challenge.eng.acme.com` becomes `_sim-challenge.eng`. Cloudflare and Route 53 take the full host as shown. + + +DNS changes can take up to 48 hours to propagate — if verification does not succeed immediately, wait and retry. Keep the TXT record published: leaving it in place means the domain stays verifiable if you ever need to verify it again. Add each domain you own separately. Subdomains (`eng.acme.com`) are verified independently of the apex. diff --git a/apps/sim/ee/sso/components/domain-settings.tsx b/apps/sim/ee/sso/components/domain-settings.tsx index 4185d96aeb8..04f10e6a8d3 100644 --- a/apps/sim/ee/sso/components/domain-settings.tsx +++ b/apps/sim/ee/sso/components/domain-settings.tsx @@ -21,13 +21,15 @@ interface DomainSettingsProps { interface CopyFieldProps { label: string value: string + hint?: string } -function CopyField({ label, value }: CopyFieldProps) { +function CopyField({ label, value, hint }: CopyFieldProps) { return (
{label} + {hint ? {hint} : null}
) } @@ -71,7 +73,11 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) { Add this TXT record at your DNS provider, then verify. DNS changes can take up to 48 hours to propagate.

- +
+
+
+ +
+ updateField('includeToolCalls', checked)} + aria-label='Include tool calls' + /> +
+ { expect(serialized.metadata.billingAttribution).toEqual(billingAttribution) }) - it('preserves includeThinking on pause so chat resume can emit thinking SSE', () => { + it('preserves independent chat event policies across pause and resume', () => { const context = createContext({ metadata: { ...createContext().metadata, includeThinking: true, + includeToolCalls: false, executionMode: 'stream', }, }) @@ -203,13 +204,15 @@ describe('serializePauseSnapshot', () => { const serialized = JSON.parse(snapshot.snapshot) expect(serialized.metadata.includeThinking).toBe(true) + expect(serialized.metadata.includeToolCalls).toBe(false) expect(serialized.metadata.executionMode).toBe('stream') }) - it('omits includeThinking when the live run did not enable it', () => { + it('omits chat event policies when the live run did not enable them', () => { const snapshot = serializePauseSnapshot(createContext(), ['next-block']) const serialized = JSON.parse(snapshot.snapshot) expect(serialized.metadata.includeThinking).toBeUndefined() + expect(serialized.metadata.includeToolCalls).toBeUndefined() }) }) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index 67eae7bce09..d6025fcc0fd 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -252,9 +252,14 @@ export function serializePauseSnapshot( startTime: metadataFromContext?.startTime ?? new Date().toISOString(), isClientSession: metadataFromContext?.isClientSession, executionMode: metadataFromContext?.executionMode, - // Preserve deployed-chat thinking gate across HITL pause/resume. + /** Preserve deployed-chat thinking gate across HITL pause/resume. */ includeThinking: metadataFromContext?.includeThinking === true ? true : undefined, - // Preserve the run-level agent-events opt-in across HITL pause/resume. + /** Preserve false as distinct from a legacy snapshot with no independent tool policy. */ + includeToolCalls: + typeof metadataFromContext?.includeToolCalls === 'boolean' + ? metadataFromContext.includeToolCalls + : undefined, + /** Preserve the run-level agent-events opt-in across HITL pause/resume. */ agentEvents: metadataFromContext?.agentEvents === true ? true : undefined, } diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index db7cda77917..02723e9fcdf 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -52,6 +52,13 @@ export interface ExecutionMetadata { * resume can re-enable thinking frames without hardcoding false. */ includeThinking?: boolean + /** + * Deployed-chat tool lifecycle policy half of the SSE dual gate. Persisted so + * HITL resume can re-enable tool frames without coupling them to thinking. + * Explicit false distinguishes new snapshots from legacy snapshots that + * inherit the thinking policy. + */ + includeToolCalls?: boolean /** * Run-level agent-events opt-in. True only on surfaces that consume thinking * and tool lifecycle events (canvas Run, dual-gated public chat). Enables the diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 453bf344019..17fa2f967d6 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1875,11 +1875,28 @@ describe('AgentBlockHandler', () => { expect(providerCallArgs.billingAttribution).toEqual(billingAttribution) }) - it('forwards agentEvents to executeProviderRequest on opted-in streaming runs', async () => { + it('forwards streaming and agent events on opted-in runs', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Stream this', apiKey: 'test-api-key', + tools: [ + { + type: 'mcp', + title: 'search_files', + schema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + params: { + serverId: 'mcp-search-server', + toolName: 'search_files', + serverName: 'search', + }, + usageControl: 'auto' as const, + }, + ], } const streamingContext = { @@ -1899,11 +1916,28 @@ describe('AgentBlockHandler', () => { expect(providerCallArgs.agentEvents).toBe(true) }) - it('does not set agentEvents on runs without the run-level opt-in', async () => { + it('forwards ordinary streaming without exposing agent events', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Stream this', apiKey: 'test-api-key', + tools: [ + { + type: 'mcp', + title: 'search_files', + schema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + params: { + serverId: 'mcp-search-server', + toolName: 'search_files', + serverName: 'search', + }, + usageControl: 'auto' as const, + }, + ], } const streamingContext = { @@ -1918,6 +1952,7 @@ describe('AgentBlockHandler', () => { expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1] + expect(providerCallArgs.stream).toBe(true) expect(providerCallArgs.agentEvents).toBe(false) }) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 48d7f24c851..fc721b990f2 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -46,7 +46,6 @@ import { shouldUseLargeFilePath, supportsFileAttachments, } from '@/providers/attachments' -import { supportsStreamingToolCalls } from '@/providers/streaming-tool-loop-shared' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' import { filterSchemaForLLM, type ToolSchema } from '@/tools/params' @@ -955,19 +954,10 @@ export class AgentBlockHandler implements BlockHandler { reasoningEffort: inputs.reasoningEffort, verbosity: inputs.verbosity, thinkingLevel: inputs.thinkingLevel, + promptCaching: inputs.promptCaching === true, previousInteractionId: inputs.previousInteractionId, - /** - * Agent-events opt-in and live tool lifecycle. Both are gated on the - * run-level {@link ExecutionMetadata.agentEvents} flag so runs without an - * agent-events consumer keep the exact pre-agent-events provider - * behavior (legacy loops, unchanged request payloads). - */ + /** Agent-events remains the opt-in for exposing thinking and tool lifecycle events. */ agentEvents: streaming && ctx.metadata?.agentEvents === true, - streamToolCalls: - streaming && - ctx.metadata?.agentEvents === true && - formattedTools.length > 0 && - supportsStreamingToolCalls(providerId), } } @@ -1041,9 +1031,11 @@ export class AgentBlockHandler implements BlockHandler { reasoningEffort: providerRequest.reasoningEffort, verbosity: providerRequest.verbosity, thinkingLevel: providerRequest.thinkingLevel, + promptCaching: providerRequest.promptCaching, + // Stable per-block identity; providers use it to route cache lookups. + blockId: block.id, previousInteractionId: providerRequest.previousInteractionId, agentEvents: providerRequest.agentEvents, - streamToolCalls: providerRequest.streamToolCalls, abortSignal: ctx.abortSignal, }) diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 39329739a43..4c311b190dd 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -39,6 +39,7 @@ export interface AgentInputs { reasoningEffort?: string verbosity?: string thinkingLevel?: string + promptCaching?: boolean files?: unknown } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 5afc6b640df..b992730f0cc 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -163,6 +163,34 @@ describe('EvaluatorBlockHandler', () => { }) }) + it('bills the cost the provider proxy decided rather than recomputing it', async () => { + // The proxy already resolved key provenance and the margin; recomputing + // here would re-charge a BYOK caller the proxy correctly zeroed. + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + content: JSON.stringify({ score1: 5, score2: 8 }), + model: 'mock-model', + tokens: { input: 50, output: 10, total: 60 }, + cost: { input: 0.001, output: 0.0005, total: 0.0015 }, + timing: { total: 200 }, + }), + }) + ) + + const result = await handler.execute(mockContext, mockBlock, { + content: 'This is the content to evaluate.', + }) + + expect((result as { cost: unknown }).cost).toEqual({ + input: 0.001, + output: 0.0005, + total: 0.0015, + }) + }) + it('should process JSON string content correctly', async () => { const contentObj = { text: 'Evaluate this JSON.', value: 42 } const inputs = { diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 3ab07bc3653..1f96fff1d6f 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -6,7 +6,8 @@ import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' -import { calculateCost, getProviderFromModel } from '@/providers/utils' +import { resolveProxiedModelCost } from '@/providers/cost-policy' +import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('EvaluatorBlockHandler') @@ -154,7 +155,7 @@ export class EvaluatorBlockHandler implements BlockHandler { const outputTokens = result.tokens?.output || result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION - const costCalculation = calculateCost(result.model, inputTokens, outputTokens, false) + const cost = resolveProxiedModelCost(result.cost) return { content: inputs.content, @@ -165,9 +166,9 @@ export class EvaluatorBlockHandler implements BlockHandler { total: result.tokens?.total || DEFAULTS.TOKENS.TOTAL, }, cost: { - input: costCalculation.input, - output: costCalculation.output, - total: costCalculation.total, + input: cost.input, + output: cost.output, + total: cost.total, }, ...metricScores, } diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index cb1c6bbcde2..90f8f25befa 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -51,13 +51,21 @@ describe('computePiCost', () => { }) it('returns zero cost for BYOK keys without billing', () => { - expect(computePiCost('claude', 100, 200, true)).toEqual({ input: 0, output: 0, total: 0 }) + expect(computePiCost('claude', 100, 200, true)).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) expect(mockCalculateCost).not.toHaveBeenCalled() }) it('returns zero cost for non-billable models', () => { mockShouldBill.mockReturnValue(false) - expect(computePiCost('local-model', 100, 200, false)).toEqual({ input: 0, output: 0, total: 0 }) + expect(computePiCost('local-model', 100, 200, false)).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) expect(mockCalculateCost).not.toHaveBeenCalled() }) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts index 870686ac3ee..14ebbcc148b 100644 --- a/apps/sim/executor/handlers/pi/keys.ts +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -10,14 +10,13 @@ import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent' import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok' -import { getCostMultiplier } from '@/lib/core/config/env-flags' +import { calculateBillableModelCost } from '@/providers/cost-policy' import type { PiSupportedProvider } from '@/providers/pi-provider-configs' import { getPiProviderApiKeyEnvVar, getPiWorkspaceBYOKProviderId, isPiSupportedProvider, } from '@/providers/pi-providers' -import { calculateCost, shouldBillModelUsage } from '@/providers/utils' /** Resolved provider key and BYOK flag for a Pi run. */ interface PiKeyResolution { @@ -74,11 +73,7 @@ export function computePiCost( outputTokens: number, isBYOK: boolean ) { - if (isBYOK || !shouldBillModelUsage(model)) { - return { input: 0, output: 0, total: 0 } - } - const multiplier = getCostMultiplier() - return calculateCost(model, inputTokens, outputTokens, false, multiplier, multiplier) + return calculateBillableModelCost(model, inputTokens, outputTokens, { isBYOK }) } /** diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 299eaf6e8dc..3c57fe2de8f 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -204,6 +204,34 @@ describe('RouterBlockHandler', () => { }) }) + it('bills the cost the provider proxy decided rather than recomputing it', async () => { + // The proxy already resolved key provenance and the margin; recomputing + // here would re-charge a BYOK caller the proxy correctly zeroed. + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + content: 'target-block-1', + model: 'mock-model', + tokens: { input: 100, output: 5, total: 105 }, + cost: { input: 0.004, output: 0.002, total: 0.006 }, + timing: { total: 300 }, + }), + }) + ) + + const result = await handler.execute(mockContext, mockBlock, { + prompt: 'Choose the best option.', + }) + + expect((result as { cost: unknown }).cost).toEqual({ + input: 0.004, + output: 0.002, + total: 0.006, + }) + }) + it('should throw error if target block is missing', async () => { const inputs = { prompt: 'Test' } mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2] diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 12042918b25..55184c40611 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -13,7 +13,8 @@ import { import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' -import { calculateCost, getProviderFromModel } from '@/providers/utils' +import { resolveProxiedModelCost } from '@/providers/cost-policy' +import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('RouterBlockHandler') @@ -145,12 +146,7 @@ export class RouterBlockHandler implements BlockHandler { total: DEFAULTS.TOKENS.TOTAL, } - const cost = calculateCost( - result.model, - tokens.input || DEFAULTS.TOKENS.PROMPT, - tokens.output || DEFAULTS.TOKENS.COMPLETION, - false - ) + const cost = resolveProxiedModelCost(result.cost) return { prompt: inputs.prompt, @@ -331,12 +327,7 @@ export class RouterBlockHandler implements BlockHandler { total: DEFAULTS.TOKENS.TOTAL, } - const cost = calculateCost( - result.model, - tokens.input || DEFAULTS.TOKENS.PROMPT, - tokens.output || DEFAULTS.TOKENS.COMPLETION, - false - ) + const cost = resolveProxiedModelCost(result.cost) return { context: inputs.context, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 09f41dad8f9..653d1c51d8e 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -185,7 +185,7 @@ export interface BlockToolCall { error?: string arguments?: Record input?: Record - result?: Record + result?: unknown output?: Record } diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 046bfcc9dbb..b0e4c37a4ab 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -177,6 +177,8 @@ export interface ChatFormData { selectedOutputBlocks: string[] /** When true, thinking may be streamed to clients that opt into agent-events-v1. Default false. */ includeThinking: boolean + /** When true, tool lifecycle may be streamed to opted-in clients. Default false. */ + includeToolCalls: boolean } /** @@ -266,6 +268,7 @@ function buildChatPayload( formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], outputConfigs, includeThinking: formData.includeThinking, + includeToolCalls: formData.includeToolCalls, } } diff --git a/apps/sim/lib/api/contracts/chats.include-thinking.test.ts b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts index 903e99e0baa..d569a9b44e2 100644 --- a/apps/sim/lib/api/contracts/chats.include-thinking.test.ts +++ b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts @@ -9,8 +9,8 @@ import { } from '@/lib/api/contracts/chats' import { chatDetailSchema } from '@/lib/api/contracts/deployments' -describe('chat includeThinking contracts (Step 4)', () => { - it('create defaults includeThinking to false', () => { +describe('chat agent-event policy contracts', () => { + it('create defaults both policies to false', () => { const parsed = createChatBodySchema.parse({ workflowId: 'wf-1', identifier: 'my-chat', @@ -21,9 +21,10 @@ describe('chat includeThinking contracts (Step 4)', () => { }, }) expect(parsed.includeThinking).toBe(false) + expect(parsed.includeToolCalls).toBe(false) }) - it('create accepts includeThinking true', () => { + it('create accepts independent policy values', () => { const parsed = createChatBodySchema.parse({ workflowId: 'wf-1', identifier: 'my-chat', @@ -33,17 +34,22 @@ describe('chat includeThinking contracts (Step 4)', () => { welcomeMessage: 'Hi', }, includeThinking: true, + includeToolCalls: false, }) expect(parsed.includeThinking).toBe(true) + expect(parsed.includeToolCalls).toBe(false) }) - it('update accepts includeThinking toggle', () => { + it('update accepts independent policy toggles', () => { expect(updateChatBodySchema.parse({ includeThinking: true }).includeThinking).toBe(true) expect(updateChatBodySchema.parse({ includeThinking: false }).includeThinking).toBe(false) expect(updateChatBodySchema.parse({ title: 'x' }).includeThinking).toBeUndefined() + expect(updateChatBodySchema.parse({ includeToolCalls: true }).includeToolCalls).toBe(true) + expect(updateChatBodySchema.parse({ includeToolCalls: false }).includeToolCalls).toBe(false) + expect(updateChatBodySchema.parse({ title: 'x' }).includeToolCalls).toBeUndefined() }) - it('chat detail and deployed config expose includeThinking (default false)', () => { + it('chat detail and deployed config expose both policies', () => { const detail = chatDetailSchema.parse({ id: 'chat-1', identifier: 'my-chat', @@ -57,12 +63,15 @@ describe('chat includeThinking contracts (Step 4)', () => { hasPassword: false, }) expect(detail.includeThinking).toBe(false) + expect(detail.includeToolCalls).toBe(false) const detailOn = chatDetailSchema.parse({ ...detail, includeThinking: true, + includeToolCalls: true, }) expect(detailOn.includeThinking).toBe(true) + expect(detailOn.includeToolCalls).toBe(true) const config = deployedChatConfigSchema.parse({ id: 'chat-1', @@ -72,5 +81,6 @@ describe('chat includeThinking contracts (Step 4)', () => { authType: 'public', }) expect(config.includeThinking).toBe(false) + expect(config.includeToolCalls).toBe(false) }) }) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index 9aa37e99723..528e20c5c28 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -43,6 +43,8 @@ export const createChatBodySchema = z.object({ outputConfigs: z.array(chatOutputConfigSchema).optional().default([]), /** When true, clients may receive thinking SSE if they also send the protocol header. Default off. */ includeThinking: z.boolean().optional().default(false), + /** When true, clients may receive tool lifecycle SSE if they also send the protocol header. */ + includeToolCalls: z.boolean().optional().default(false), }) export type CreateChatBody = z.input @@ -61,6 +63,7 @@ export const updateChatBodySchema = z.object({ allowedEmails: z.array(z.string()).optional(), outputConfigs: z.array(chatOutputConfigSchema).optional(), includeThinking: z.boolean().optional(), + includeToolCalls: z.boolean().optional(), }) export type UpdateChatBody = z.input @@ -106,6 +109,8 @@ export const deployedChatConfigSchema = z.object({ ), /** Policy for thinking SSE; clients still need the X-Sim-Stream-Protocol opt-in. */ includeThinking: z.preprocess((value) => value ?? false, z.boolean()), + /** Policy for tool lifecycle SSE; clients still need the protocol opt-in. */ + includeToolCalls: z.preprocess((value) => value ?? false, z.boolean()), }) export type DeployedChatConfig = z.output @@ -214,9 +219,12 @@ export const deployedChatPostContract = defineRouteContract({ params: chatIdentifierParamsSchema, body: deployedChatPostBodySchema, response: { - // Message posts return SSE (`text/event-stream`). Auth-only POSTs use - // authenticateDeployedChatContract (JSON). Terminal frames: `final` or one - // `error`, then `[DONE]`. Thinking frames require includeThinking + protocol header. + /** + * Message posts return SSE (`text/event-stream`). Auth-only POSTs use + * authenticateDeployedChatContract (JSON). Terminal frames: `final` or one + * `error`, then `[DONE]`. Thinking and tool frames use independent deployment + * policies; both require the protocol header. + */ mode: 'stream', }, }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index a4689c1c133..db8e82adb6b 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -177,6 +177,7 @@ export const chatDetailSchema = z.object({ ) ), includeThinking: z.preprocess((value) => value ?? false, z.boolean()), + includeToolCalls: z.preprocess((value) => value ?? false, z.boolean()), customizations: z.preprocess( (value) => value ?? undefined, z diff --git a/apps/sim/lib/api/contracts/providers.ts b/apps/sim/lib/api/contracts/providers.ts index 0e941a23ca7..7c40ed81154 100644 --- a/apps/sim/lib/api/contracts/providers.ts +++ b/apps/sim/lib/api/contracts/providers.ts @@ -316,6 +316,9 @@ const executeProviderResponseSchema = z input: z.number().optional(), output: z.number().optional(), total: z.number().optional(), + /** Prompt-cache buckets, reported separately from base input tokens. */ + cacheRead: z.number().optional(), + cacheWrite: z.number().optional(), }) .optional(), toolCalls: z.array(z.record(z.string(), z.unknown())).optional(), diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 406ef9f0953..9316dff9070 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -182,7 +182,6 @@ interface OrchestratorRequest { fileAttachments?: FileAttachment[] commands?: string[] provider?: string - streamToolCalls?: boolean version?: string prefetch?: boolean userName?: string diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 0622e78d977..14d4ef9694c 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -309,6 +309,8 @@ export async function executeDeployChat( outputConfigs: (existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [], includeThinking: existing[0].includeThinking ?? false, + includeToolCalls: + existing[0].includeToolCalls ?? existing[0].includeThinking ?? false, welcomeMessage: (existing[0].customizations as { welcomeMessage?: string } | null) ?.welcomeMessage || 'Hi there! How can I help you today?', @@ -400,6 +402,15 @@ export async function executeDeployChat( typeof params.includeThinking === 'boolean' ? params.includeThinking : (existingDeployment?.includeThinking ?? false) + /** + * Grandfathers off the thinking value this call resolves to, not the stored + * one: before `includeToolCalls` existed, `includeThinking` gated tool + * frames too, so turning thinking off must turn them off with it. + */ + const resolvedIncludeToolCalls = + typeof params.includeToolCalls === 'boolean' + ? params.includeToolCalls + : (existingDeployment?.includeToolCalls ?? resolvedIncludeThinking) const welcomeMessage = typeof params.welcomeMessage === 'string' ? params.welcomeMessage @@ -444,6 +455,7 @@ export async function executeDeployChat( allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, includeThinking: resolvedIncludeThinking, + includeToolCalls: resolvedIncludeToolCalls, workspaceId: workflowRecord.workspaceId, }) @@ -497,6 +509,7 @@ export async function executeDeployChat( allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, includeThinking: resolvedIncludeThinking, + includeToolCalls: resolvedIncludeToolCalls, welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', primaryColor: params.customizations?.primaryColor || diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 1218b25440b..6ca6bdb4a26 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -62,6 +62,7 @@ export async function executeCheckDeploymentStatus( allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, password: chat.password, customizations: chat.customizations, }) @@ -105,6 +106,7 @@ export async function executeCheckDeploymentStatus( allowedEmails: chatDeploy[0]?.allowedEmails || null, outputConfigs: chatDeploy[0]?.outputConfigs || null, includeThinking: chatDeploy[0]?.includeThinking ?? false, + includeToolCalls: chatDeploy[0]?.includeToolCalls ?? chatDeploy[0]?.includeThinking ?? false, welcomeMessage: chatCustomizations.welcomeMessage || null, primaryColor: chatCustomizations.primaryColor || null, hasPassword: Boolean(chatDeploy[0]?.password), diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index a1c585bde4a..a111a87f854 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -159,6 +159,7 @@ export interface DeployChatParams { allowedEmails?: string[] outputConfigs?: unknown[] includeThinking?: boolean + includeToolCalls?: boolean } export interface DeployMcpParams { diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts index 3b7e874847c..4e7cafbef42 100644 --- a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import type { ProviderTiming, TraceSpan } from '@/lib/logs/types' import { isConditionBlockType, @@ -17,6 +18,12 @@ const logger = createLogger('SpanFactory') /** A BlockLog that has already passed the id/type validity check. */ type ValidBlockLog = BlockLog & { blockType: string } +/** Converts arbitrary tool results to the object shape expected by trace spans. */ +function normalizeTraceOutput(value: unknown): Record | undefined { + if (value === undefined) return undefined + return isRecordLike(value) ? value : { value } +} + /** * Creates a TraceSpan from a BlockLog. Returns null for invalid logs. * @@ -190,6 +197,7 @@ function buildChildrenFromTimeSegments( const currentIndex = toolCallIndices.get(normalizedName) ?? 0 const match = callsForName[currentIndex] toolCallIndices.set(normalizedName, currentIndex + 1) + const output = normalizeTraceOutput(match?.result ?? match?.output) const toolChild: TraceSpan = { id: `${span.id}-segment-${index}`, @@ -200,9 +208,7 @@ function buildChildrenFromTimeSegments( endTime: segmentEndTime, status: match?.error || segment.errorMessage ? 'error' : 'success', input: match?.arguments ?? match?.input, - output: match?.error - ? { error: match.error, ...(match.result ?? match.output ?? {}) } - : (match?.result ?? match?.output), + output: match?.error ? { error: match.error, ...output } : output, } if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId if (segment.errorType) toolChild.errorType = segment.errorType @@ -269,6 +275,7 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS return toolCalls.map((tc, index) => { const startTime = tc.startTime ?? log.startedAt const endTime = tc.endTime ?? log.endedAt + const output = normalizeTraceOutput(tc.result ?? tc.output) return { id: `${span.id}-tool-${index}`, name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'), @@ -278,9 +285,7 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS endTime, status: tc.error ? 'error' : 'success', input: tc.arguments ?? tc.input, - output: tc.error - ? { error: tc.error, ...(tc.result ?? tc.output ?? {}) } - : (tc.result ?? tc.output), + output: tc.error ? { error: tc.error, ...output } : output, } }) } diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 2b2fff6e703..f763e08b995 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -196,6 +196,34 @@ describe('buildTraceSpans', () => { expect(secondToolCall.output).toEqual({ status: 200, data: 'response' }) }) + it.concurrent('normalizes scalar tool results only at the trace display boundary', () => { + const mockExecutionResult: ExecutionResult = { + success: true, + output: { content: 'Final output' }, + logs: [ + { + blockId: 'agent-scalar', + blockName: 'Scalar Agent', + blockType: 'agent', + startedAt: '2024-01-01T10:00:00.000Z', + endedAt: '2024-01-01T10:00:01.000Z', + durationMs: 1000, + success: true, + output: { + toolCalls: { + list: [{ name: 'boolean_tool', result: false }], + count: 1, + }, + }, + }, + ], + } + + const { traceSpans } = buildTraceSpans(mockExecutionResult) + + expect(traceSpans[0].children?.[0].output).toEqual({ value: false }) + }) + it.concurrent( 'extracts tool calls from agent block output with direct toolCalls array format', () => { diff --git a/apps/sim/lib/monitoring/metrics.ts b/apps/sim/lib/monitoring/metrics.ts index c4fd6d47d1b..4f6f980bd39 100644 --- a/apps/sim/lib/monitoring/metrics.ts +++ b/apps/sim/lib/monitoring/metrics.ts @@ -41,7 +41,8 @@ const MAX_BUFFER = 10_000 // hard cap; drop oldest beyond this if flushing stall type ThrottleReason = 'billing_actor_limit' | 'upstream_retries_exhausted' type QueueReason = 'actor_requests' | 'dimension' | 'queue_position' -type FailureReason = 'rate_limited' | 'auth' | 'other' +/** `metering` marks a provider call that succeeded but could not be priced. */ +type FailureReason = 'rate_limited' | 'auth' | 'other' | 'metering' // Deployed envs (app + trigger worker) carry static AWS creds; local dev does // not. No creds → no-op, so recorders stay always-safe to call (same contract diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 5c77fef1472..639ef051945 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -56,6 +56,8 @@ export interface ExecuteWorkflowOptions { billingAttribution?: BillingAttributionSnapshot /** Deployed-chat thinking policy; persisted on the snapshot for resume. */ includeThinking?: boolean + /** Deployed-chat tool lifecycle policy; persisted on the snapshot for resume. */ + includeToolCalls?: boolean /** * Run-level agent-events opt-in (see {@link ExecutionMetadata.agentEvents}). * Callers set this only when the surface consumes thinking/tool events. @@ -119,6 +121,10 @@ export async function executeWorkflow( fileKeys: streamConfig?.fileKeys, executionMode: streamConfig?.executionMode, includeThinking: streamConfig?.includeThinking === true ? true : undefined, + includeToolCalls: + typeof streamConfig?.includeToolCalls === 'boolean' + ? streamConfig.includeToolCalls + : undefined, agentEvents: streamConfig?.agentEvents === true ? true : undefined, } diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 13b5a51a371..3bfe35d3de7 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -31,6 +31,8 @@ export interface ChatDeployPayload { outputConfigs?: Array<{ blockId: string; path: string }> /** When true, public SSE may expose thinking if the client also opts into agent-events-v1. */ includeThinking?: boolean + /** When true, public SSE may expose tool lifecycle if the client opts into agent-events-v1. */ + includeToolCalls?: boolean workspaceId?: string | null } @@ -63,6 +65,7 @@ export async function performChatDeploy( allowedEmails = [], outputConfigs = [], includeThinking = false, + includeToolCalls = false, } = params const customizations = { @@ -145,6 +148,7 @@ export async function performChatDeploy( allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, includeThinking, + includeToolCalls, updatedAt: new Date(), }) .where(eq(chat.id, chatId)) @@ -164,6 +168,7 @@ export async function performChatDeploy( allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, includeThinking, + includeToolCalls, createdAt: new Date(), updatedAt: new Date(), }) diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts index a001ad1742a..b3c17aeb694 100644 --- a/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts @@ -5,8 +5,10 @@ import { describe, expect, it } from 'vitest' import { AGENT_STREAM_PROTOCOL_HEADER, AGENT_STREAM_PROTOCOL_V1, + clientAcceptsAgentStreamProtocol, isChatChunkFrame, isChatChunkResetFrame, + isChatToolFrame, shouldEmitAgentStreamEvents, } from '@/lib/workflows/streaming/agent-stream-protocol' @@ -14,6 +16,36 @@ function headers(init?: Record): Headers { return new Headers(init) } +describe('clientAcceptsAgentStreamProtocol', () => { + it('depends on the header alone, never on a deployment policy', () => { + expect( + clientAcceptsAgentStreamProtocol( + headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }) + ) + ).toBe(true) + expect( + clientAcceptsAgentStreamProtocol(headers({ [AGENT_STREAM_PROTOCOL_HEADER]: ' V1 ' })) + ).toBe(false) + expect(clientAcceptsAgentStreamProtocol(headers())).toBe(false) + }) +}) + +describe('tool frame guard', () => { + const base = { event: 'tool', blockId: 'agent-1', phase: 'end', id: 'call_1', name: 'search' } + + it('accepts every documented terminal status and a start frame without one', () => { + expect(isChatToolFrame({ ...base, phase: 'start' })).toBe(true) + for (const status of ['success', 'error', 'cancelled']) { + expect(isChatToolFrame({ ...base, status })).toBe(true) + } + }) + + it('rejects an unrecognized status instead of letting it settle as success', () => { + expect(isChatToolFrame({ ...base, status: 'timeout' })).toBe(false) + expect(isChatToolFrame({ ...base, status: 42 })).toBe(false) + }) +}) + describe('chunk_reset frame guard', () => { it('identifies reset frames and keeps them out of the chunk guard', () => { const reset = { blockId: 'agent-1', event: 'chunk_reset' } @@ -31,21 +63,24 @@ describe('shouldEmitAgentStreamEvents', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: false, + includeToolCalls: false, requestHeaders: headers(), }) ).toBe(false) expect( shouldEmitAgentStreamEvents({ includeThinking: undefined, + includeToolCalls: undefined, requestHeaders: headers(), }) ).toBe(false) }) - it('requires both includeThinking and protocol header', () => { + it('requires the protocol header and at least one event policy', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: true, + includeToolCalls: false, requestHeaders: headers(), }) ).toBe(false) @@ -53,6 +88,7 @@ describe('shouldEmitAgentStreamEvents', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: false, + includeToolCalls: false, requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), }) ).toBe(false) @@ -60,6 +96,15 @@ describe('shouldEmitAgentStreamEvents', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: true, + includeToolCalls: false, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(true) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + includeToolCalls: true, requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), }) ).toBe(true) @@ -69,13 +114,15 @@ describe('shouldEmitAgentStreamEvents', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: true, + includeToolCalls: false, requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: ' Agent-Events-V1 ' }), }) ).toBe(true) expect( shouldEmitAgentStreamEvents({ - includeThinking: true, + includeThinking: false, + includeToolCalls: true, requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: 'text, agent-events-v1', }), diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts index f55ebc18da3..62f46334cd5 100644 --- a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts @@ -2,23 +2,29 @@ * Public agent stream protocol: header negotiation and the wire frame * vocabulary for the public chat / simple SSE surface. * - * Exposure rule (locked) for public chat / simple SSE: - * emit thinking/tool SSE frames iff - * deployment.includeThinking === true - * AND request opts into agent-events-v1 via {@link AGENT_STREAM_PROTOCOL_HEADER} + * Two independent things are negotiated here: + * + * 1. Client capability — {@link AGENT_STREAM_PROTOCOL_HEADER} means the client + * understands v1 framing, so answer text may stream live and be retracted + * with `chunk_reset`. No deployment policy is involved. + * 2. Event exposure — thinking frames need `deployment.includeThinking`, tool + * frames need `deployment.includeToolCalls`, and both additionally need the + * client capability above. + * + * Keeping these separate is what lets a chat with both policies off still + * stream its answer token by token, exactly as it did before agent events. * * Canvas draft runs (execution-events) forward the same sink as live-only - * `stream:thinking` / `stream:tool` events without the includeThinking gate; + * `stream:thinking` / `stream:tool` events without the deployment policy gates; * the executor still disables the sink when block-output PII redaction is on. * - * Legacy clients omitting the header stay text-only even when the deployment - * has thinking enabled. Deployed chat UI always sends the header when loading - * its own deployment. + * Legacy clients omitting the header stay on settled final-turn text and never + * see thinking or tools. The deployed chat UI always sends the header. * * See docs: workflows/deployment/agent-events. */ -import type { ToolCallEndStatus } from '@/providers/stream-events' +import { isToolCallEndStatus, type ToolCallEndStatus } from '@/providers/stream-events' export const AGENT_STREAM_PROTOCOL_HEADER = 'x-sim-stream-protocol' as const @@ -30,9 +36,9 @@ export type AgentStreamProtocol = typeof AGENT_STREAM_PROTOCOL_V1 * Answer text. The only frame legacy clients append to the answer. * * Legacy clients (no protocol header) receive only settled final-turn text. - * Dual-gated clients receive answer text live as it streams — including text - * from a turn that may later resolve to tool calls — reconciled by - * {@link ChatStreamChunkResetFrame} when a turn turns out to be intermediate. + * Protocol-negotiated clients receive answer text live as it streams — + * including text from a turn that may later resolve to tool calls — reconciled + * by {@link ChatStreamChunkResetFrame} when a turn turns out to be intermediate. */ export interface ChatStreamChunkFrame { blockId: string @@ -40,23 +46,24 @@ export interface ChatStreamChunkFrame { } /** - * Dual-gated only: the live-streamed answer text for `blockId` belonged to an - * intermediate turn (tool calls follow). Clients discard the block's - * accumulated answer text; the final turn re-streams after tools settle. + * Negotiated agent-events streams only: the live-streamed answer text for + * `blockId` belonged to an intermediate turn (tool calls follow). Clients + * discard the block's accumulated answer text; the final turn re-streams after + * tools settle. */ export interface ChatStreamChunkResetFrame { blockId: string event: 'chunk_reset' } -/** Thinking / reasoning-summary delta. Dual-gated; never reuses `chunk`. */ +/** Thinking / reasoning-summary delta. Thinking-policy gated; never reuses `chunk`. */ export interface ChatStreamThinkingFrame { blockId: string event: 'thinking' data: string } -/** Tool lifecycle (name + status only — never args or results). Dual-gated. */ +/** Tool lifecycle (name + status only — never args or results). Tool-policy gated. */ export interface ChatStreamToolFrame { blockId: string event: 'tool' @@ -142,7 +149,11 @@ export function isChatToolFrame(value: unknown): value is ChatStreamToolFrame { typeof value.id === 'string' && value.id.length > 0 && typeof value.name === 'string' && - value.name.length > 0 + value.name.length > 0 && + // An unrecognized status is a protocol violation, not a success. Rejecting + // the frame leaves the chip running for the terminal settle (which knows + // the run's real outcome) instead of rendering it green on a guess. + (value.status === undefined || isToolCallEndStatus(value.status)) ) } @@ -162,18 +173,17 @@ export function isChatStreamErrorFrame(value: unknown): value is ChatStreamStrea } /** - * Returns true when both the deployment policy and the request protocol opt-in - * are present. Simple SSE checks this before emitting thinking/tool frames. + * Whether the client declared it understands agent-events-v1 framing. + * + * This is a statement about the *client*, not about what a deployment may + * expose: sending the header means the client appends `chunk` and honors + * `chunk_reset`, so answer text can stream live and be retracted. Thinking and + * tool exposure are separate deployment policies on top of this. */ -export function shouldEmitAgentStreamEvents(options: { - includeThinking: boolean | null | undefined +export function clientAcceptsAgentStreamProtocol( requestHeaders: Headers | { get(name: string): string | null } -}): boolean { - if (options.includeThinking !== true) { - return false - } - - const raw = options.requestHeaders.get(AGENT_STREAM_PROTOCOL_HEADER) +): boolean { + const raw = requestHeaders.get(AGENT_STREAM_PROTOCOL_HEADER) if (!raw) { return false } @@ -186,3 +196,24 @@ export function shouldEmitAgentStreamEvents(options: { return tokens.includes(AGENT_STREAM_PROTOCOL_V1) } + +/** + * Returns true when a negotiated client may receive thinking or tool frames — + * at least one deployment policy is on and the client accepts the protocol. + * + * Drives the run-level `agentEvents` flag, which asks providers for reasoning + * summaries. Answer-text cadence does *not* depend on this: a negotiated client + * streams live text even with both policies off. Frame emitters still apply + * each independent policy before exposing its corresponding frames. + */ +export function shouldEmitAgentStreamEvents(options: { + includeThinking: boolean | null | undefined + includeToolCalls: boolean | null | undefined + requestHeaders: Headers | { get(name: string): string | null } +}): boolean { + if (options.includeThinking !== true && options.includeToolCalls !== true) { + return false + } + + return clientAcceptsAgentStreamProtocol(options.requestHeaders) +} diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index da07914f4e9..7d1a25d4ee8 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -4,7 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readSSEStream } from '@/lib/core/utils/sse' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' -import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' const { mockDownloadFile } = vi.hoisted(() => ({ mockDownloadFile: vi.fn(), @@ -605,6 +608,131 @@ describe('createStreamingResponse', () => { }) }) +describe('final envelope tool payloads', () => { + const agentOutput = { + content: 'Done', + toolCalls: { + count: 1, + list: [ + { + name: 'get_weather', + duration: 12, + arguments: { city: 'private' }, + result: { temperature: 72 }, + }, + ], + }, + } + + function executeFnReturning(output: Record) { + return async () => + ({ + success: true, + output, + logs: [ + { + blockId: 'agent-1', + output, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + }) as any + } + + it('redacts tool arguments and results for public chat', async () => { + // No outputConfigs means the whole block output rides the envelope, which + // must not become a side channel around the tool-frame gate. + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: true, selectedOutputs: [] }, + executeFn: executeFnReturning(agentOutput), + }) + + const events = await collectSSEEvents(stream) + const final = events.find((event) => event.event === 'final') + const toolCall = (final?.data as any).output.toolCalls.list[0] + + expect(toolCall).toEqual({ name: 'get_weather', duration: 12 }) + expect(JSON.stringify(final)).not.toContain('private') + expect(JSON.stringify(final)).not.toContain('72') + }) + + /** + * A deployment almost always selects outputs, so redaction that only covered + * the empty-selection branch would be dead in the case it exists for. + */ + it('redacts tool payloads when the deployment selects toolCalls directly', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: true, selectedOutputs: ['block_toolCalls'] }, + executeFn: async ({ onBlockComplete }) => { + const toolOnlyOutput = { toolCalls: agentOutput.toolCalls } + await onBlockComplete('block', toolOnlyOutput) + return { + success: true, + output: {}, + logs: [ + { + blockId: 'block', + output: toolOnlyOutput, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + // The payload rides the chunk frame, not `final`, so assert on the whole + // stream — sanitizing only the envelope would still leak here. + const events = await collectSSEEvents(stream) + const serialized = JSON.stringify(events) + + expect(serialized).not.toContain('private') + expect(serialized).not.toContain('72') + expect(serialized).toContain('get_weather') + }) + + it('keeps tool results for the authenticated workflow API', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: false, selectedOutputs: [] }, + executeFn: executeFnReturning(agentOutput), + }) + + const events = await collectSSEEvents(stream) + const final = events.find((event) => event.event === 'final') + const toolCall = (final?.data as any).output.toolCalls.list[0] + + expect(toolCall.arguments).toEqual({ city: 'private' }) + expect(toolCall.result).toEqual({ temperature: 72 }) + }) +}) + +describe('agent stream protocol response headers', () => { + const requestHeaders = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + + it('echoes the protocol whenever the client negotiated it', () => { + // v1 framing (live text + chunk_reset) is in effect on client capability + // alone, so the echo must not depend on the event policies. + expect(agentStreamProtocolResponseHeaders({ requestHeaders })).toEqual({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + }) + + it('stays inactive for legacy clients and when no headers are supplied', () => { + expect(agentStreamProtocolResponseHeaders({ requestHeaders: new Headers() })).toEqual({}) + expect(agentStreamProtocolResponseHeaders({})).toEqual({}) + }) +}) + describe('createStreamingResponse agent-events-v1', () => { beforeEach(() => { vi.clearAllMocks() @@ -616,8 +744,14 @@ describe('createStreamingResponse agent-events-v1', () => { answer: string fail?: boolean tools?: Array< - | { type: 'tool_call_start'; id: string; name: string } - | { type: 'tool_call_end'; id: string; name: string; status: string } + | { type: 'tool_call_start'; id: string; name: string; args?: unknown } + | { + type: 'tool_call_end' + id: string + name: string + status: string + result?: unknown + } > }) { return async ({ @@ -710,22 +844,25 @@ describe('createStreamingResponse agent-events-v1', () => { .map((chunk) => chunk.slice(6)) } - it('legacy path without protocol header stays text-only (no thinking frames)', async () => { + it('legacy path without protocol header stays text-only', async () => { const stream = await createStreamingResponse({ requestId: 'request-1', streamConfig: { includeThinking: true, + includeToolCalls: true, selectedOutputs: ['agent-1_content'], }, // No requestHeaders → gate closed executeFn: createAgentStreamExecuteFn({ thinking: ['secret thought'], answer: 'Hello', + tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }], }), }) const events = await collectSSEEvents(stream) expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events.some((event) => event.event === 'tool')).toBe(false) expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Hello' }) expect(events.some((event) => event.event === 'final')).toBe(true) }) @@ -739,6 +876,7 @@ describe('createStreamingResponse agent-events-v1', () => { requestHeaders: headers, streamConfig: { includeThinking: true, + includeToolCalls: false, selectedOutputs: ['agent-1_content'], }, executeFn: createAgentStreamExecuteFn({ @@ -756,7 +894,7 @@ describe('createStreamingResponse agent-events-v1', () => { expect(events.some((event) => event.event === 'final')).toBe(true) }) - it('dual gate emits tool start/end frames without putting tools on chunk', async () => { + it('includeToolCalls emits tool start/end frames without exposing args or results', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', }) @@ -764,18 +902,25 @@ describe('createStreamingResponse agent-events-v1', () => { requestId: 'request-1', requestHeaders: headers, streamConfig: { - includeThinking: true, + includeThinking: false, + includeToolCalls: true, selectedOutputs: ['agent-1_content'], }, executeFn: createAgentStreamExecuteFn({ answer: 'Done', tools: [ - { type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }, + { + type: 'tool_call_start', + id: 'toolu_1', + name: 'get_weather', + args: { city: 'private' }, + }, { type: 'tool_call_end', id: 'toolu_1', name: 'get_weather', status: 'success', + result: { temperature: 72 }, }, ], }), @@ -809,7 +954,7 @@ describe('createStreamingResponse agent-events-v1', () => { ).toBe(false) }) - it('dual gate streams pending text live and resets intermediate turns', async () => { + it('tool-only policy streams pending text live and resets intermediate turns', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', }) @@ -817,7 +962,8 @@ describe('createStreamingResponse agent-events-v1', () => { requestId: 'request-1', requestHeaders: headers, streamConfig: { - includeThinking: true, + includeThinking: false, + includeToolCalls: true, selectedOutputs: ['agent-1_content'], }, executeFn: async ({ onStream }) => { @@ -894,6 +1040,89 @@ describe('createStreamingResponse agent-events-v1', () => { ) }) + it('streams answer text live for a negotiated client with both policies off', async () => { + // Answer cadence follows client capability, not event policy: a chat with + // thinking and tools both disabled must still stream token by token, the + // way it did before streaming tool loops existed. + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + includeToolCalls: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => {} + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: 'Final answer' }, + logs: [], + metadata: {}, + }, + } as any) + + await sink?.onEvent({ type: 'thinking_delta', text: 'secret reasoning' }) + await sink?.onEvent({ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }) + await sink?.onEvent({ type: 'turn_end', turn: 'intermediate' }) + await sink?.onEvent({ + type: 'tool_call_end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }) + await sink?.onEvent({ type: 'text_delta', text: 'Final ', turn: 'pending' }) + await sink?.onEvent({ type: 'text_delta', text: 'answer', turn: 'pending' }) + await sink?.onEvent({ type: 'turn_end', turn: 'final' }) + textController.enqueue(new TextEncoder().encode('Final answer')) + textController.close() + await onStreamPromise + + return { + success: true, + output: { content: 'Final answer' }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + + expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual( + ['Final ', 'answer'] + ) + // Capability alone must not expose either gated event type. + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events.some((event) => event.event === 'tool')).toBe(false) + }) + it('dual gate keeps byte-path chunks for response-format transformed streams', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', @@ -903,6 +1132,7 @@ describe('createStreamingResponse agent-events-v1', () => { requestHeaders: headers, streamConfig: { includeThinking: true, + includeToolCalls: false, selectedOutputs: ['agent-1_content'], }, executeFn: async ({ onStream }) => { @@ -963,7 +1193,7 @@ describe('createStreamingResponse agent-events-v1', () => { expect(events.some((event) => event.event === 'chunk_reset')).toBe(false) }) - it('protocol header without includeThinking does not emit tool frames', async () => { + it('includeThinking without includeToolCalls does not emit tool frames', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', }) @@ -971,7 +1201,8 @@ describe('createStreamingResponse agent-events-v1', () => { requestId: 'request-1', requestHeaders: headers, streamConfig: { - includeThinking: false, + includeThinking: true, + includeToolCalls: false, selectedOutputs: ['agent-1_content'], }, executeFn: createAgentStreamExecuteFn({ @@ -985,7 +1216,7 @@ describe('createStreamingResponse agent-events-v1', () => { expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) }) - it('protocol header without includeThinking does not emit thinking', async () => { + it('includeToolCalls without includeThinking does not emit thinking', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', }) @@ -994,6 +1225,7 @@ describe('createStreamingResponse agent-events-v1', () => { requestHeaders: headers, streamConfig: { includeThinking: false, + includeToolCalls: true, selectedOutputs: ['agent-1_content'], }, executeFn: createAgentStreamExecuteFn({ @@ -1064,6 +1296,7 @@ describe('createStreamingResponse agent-events-v1', () => { requestHeaders: headers, streamConfig: { includeThinking: true, + includeToolCalls: false, selectedOutputs: ['agent-1_content'], }, executeFn: async ({ onStream }) => { diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index b96918bcba0..96beb1c1da9 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' +import { isRecordLike, omit } from '@sim/utils/object' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' import { extractBlockIdFromOutputId, @@ -35,7 +35,7 @@ import { type ChatStreamStreamErrorFrame, type ChatStreamThinkingFrame, type ChatStreamToolFrame, - shouldEmitAgentStreamEvents, + clientAcceptsAgentStreamProtocol, } from '@/lib/workflows/streaming/agent-stream-protocol' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' @@ -60,10 +60,11 @@ const SELECTED_OUTPUT_TOO_LARGE_MESSAGE = * Simple SSE stream contract — frame shapes are the `ChatStreamFrame` union in * `agent-stream-protocol.ts`, consumed by both these emitters and the chat client: * - Answer text: `{ blockId, chunk }` only (`chunk` is forever answer text). - * Legacy clients get settled final-turn text; dual-gated clients get answer - * text live from the agent-events sink, reconciled by + * Legacy clients get settled final-turn text; protocol-negotiated clients get + * answer text live from the agent-events sink, reconciled by * `{ blockId, event: 'chunk_reset' }` when a turn resolves to tool calls. * - Thinking (opt-in): `{ blockId, event: 'thinking', data }` — never uses `chunk`. + * - Tool lifecycle (opt-in): `{ blockId, event: 'tool', ... }` — name/status only. * - Success terminal: `{ event: 'final', data }` then `[DONE]`. * - Failure terminal: exactly one `{ event: 'error', ... }` then `[DONE]`. No `final` after failure. * - Mid-block read issues may emit non-terminal `{ event: 'stream_error', blockId, error }`. @@ -78,11 +79,10 @@ interface StreamingConfig { includeFileBase64?: boolean base64MaxBytes?: number timeoutMs?: number - /** - * Deployment policy for thinking/tool SSE. Still requires the client to send - * {@link AGENT_STREAM_PROTOCOL_HEADER}: {@link AGENT_STREAM_PROTOCOL_V1}. - */ + /** Thinking SSE policy; still requires the negotiated agent-events protocol. */ includeThinking?: boolean + /** Tool lifecycle SSE policy; still requires the negotiated agent-events protocol. */ + includeToolCalls?: boolean } export type StreamingExecutorFn = (callbacks: { @@ -104,26 +104,23 @@ export interface StreamingResponseOptions { userId?: string /** Incoming fetch/request abort — combined with the stream timeout. */ requestSignal?: AbortSignal - /** Used with {@link StreamingConfig.includeThinking} for dual-gate thinking SSE. */ + /** Used with the independent event policies to negotiate agent-events SSE. */ requestHeaders?: Headers | { get(name: string): string | null } executeFn: StreamingExecutorFn } /** - * Extra response headers when the dual-gate agent stream protocol is active. - * Callers should merge these into the SSE response alongside {@link SSE_HEADERS}. + * Echoes the stream protocol back when the client negotiated it, so the client + * knows v1 framing is in effect and that `chunk_reset` may arrive. Driven by + * client capability alone — a negotiated client streams live answer text even + * when both event policies are off. Merge into the SSE response alongside + * {@link SSE_HEADERS}. */ export function agentStreamProtocolResponseHeaders(options: { - includeThinking?: boolean | null requestHeaders?: Headers | { get(name: string): string | null } }): Record { if (!options.requestHeaders) return {} - if ( - !shouldEmitAgentStreamEvents({ - includeThinking: options.includeThinking, - requestHeaders: options.requestHeaders, - }) - ) { + if (!clientAcceptsAgentStreamProtocol(options.requestHeaders)) { return {} } return { [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 } @@ -252,45 +249,69 @@ function assertSelectedOutputBytes(value: unknown): number { return bytes } +/** Tool-call payload keys that must never ride a public `final` envelope. */ +const TOOL_PAYLOAD_KEYS = ['arguments', 'input', 'result', 'output'] as const + +function redactToolCallPayloads(toolCall: unknown): unknown { + if (!toolCall || typeof toolCall !== 'object') return toolCall + return omit(toolCall as Record, [...TOOL_PAYLOAD_KEYS]) +} + /** - * Strips model internals from `providerTiming.timeSegments` before an output - * rides a simple-SSE `final` envelope: `thinkingContent`, intermediate - * `assistantContent`, and tool-call arguments would otherwise reach public - * chat clients wholesale, bypassing the dual-gated thinking/tool frames. - * Timing numbers and tool names stay — they carry no model internals. + * Strips model internals from an output before it rides a simple-SSE `final` + * envelope. + * + * `thinkingContent`, intermediate `assistantContent`, and tool-call arguments + * inside `providerTiming.timeSegments` would otherwise reach clients wholesale, + * bypassing the independently gated thinking/tool frames. Timing numbers and + * tool names stay — they carry no model internals. + * + * With `redactToolPayloads` (public chat, where the caller is an anonymous end + * user rather than the workflow owner), the block's own top-level `toolCalls` + * are reduced the same way. Without it, the authenticated workflow API keeps + * returning tool results, which callers legitimately consume. */ -function sanitizeProviderTimingForEnvelope( - output: Record +function sanitizeOutputForEnvelope( + output: Record, + options: { redactToolPayloads: boolean } ): Record { + let sanitized = output + const providerTiming = output.providerTiming as { timeSegments?: unknown } | undefined - if (!providerTiming || !Array.isArray(providerTiming.timeSegments)) { - return output + if (providerTiming && Array.isArray(providerTiming.timeSegments)) { + sanitized = { + ...sanitized, + providerTiming: { + ...providerTiming, + timeSegments: providerTiming.timeSegments.map((segment) => { + if (!segment || typeof segment !== 'object') return segment + const { toolCalls, ...rest } = omit(segment as Record, [ + 'thinkingContent', + 'assistantContent', + ]) as Record & { toolCalls?: unknown } + return { + ...rest, + ...(Array.isArray(toolCalls) + ? { toolCalls: toolCalls.map(redactToolCallPayloads) } + : {}), + } + }), + }, + } } - return { - ...output, - providerTiming: { - ...providerTiming, - timeSegments: providerTiming.timeSegments.map((segment) => { - if (!segment || typeof segment !== 'object') return segment - const { toolCalls, ...rest } = omit(segment as Record, [ - 'thinkingContent', - 'assistantContent', - ]) as Record & { toolCalls?: unknown } - return { - ...rest, - ...(Array.isArray(toolCalls) - ? { - toolCalls: toolCalls.map((toolCall) => - toolCall && typeof toolCall === 'object' - ? omit(toolCall as Record, ['arguments']) - : toolCall - ), - } - : {}), - } - }), - }, + + const blockToolCalls = sanitized.toolCalls as { list?: unknown } | undefined + if (options.redactToolPayloads && blockToolCalls && Array.isArray(blockToolCalls.list)) { + sanitized = { + ...sanitized, + toolCalls: { + ...blockToolCalls, + list: blockToolCalls.list.map(redactToolCallPayloads), + }, + } } + + return sanitized } async function buildMinimalResult( @@ -303,8 +324,12 @@ async function buildMinimalResult( includeFileBase64: boolean, base64MaxBytes: number | undefined, executionId?: string, - context: Omit = { requestId } + context: Omit & { + /** Public chat: reduce the block's own tool calls to name + lifecycle. */ + redactToolPayloads?: boolean + } = { requestId } ): Promise<{ success: boolean; error?: string; output: Record }> { + const envelopeOptions = { redactToolPayloads: context.redactToolPayloads === true } const durableContext = { workspaceId: context.workspaceId, workflowId: context.workflowId, @@ -320,7 +345,7 @@ async function buildMinimalResult( } if (result.status === 'paused') { - minimalResult.output = sanitizeProviderTimingForEnvelope(result.output || {}) + minimalResult.output = sanitizeOutputForEnvelope(result.output || {}, envelopeOptions) return compactExecutionPayload(minimalResult, { ...durableContext, preserveUserFileBase64: includeFileBase64, @@ -329,7 +354,7 @@ async function buildMinimalResult( } if (!selectedOutputs?.length) { - minimalResult.output = sanitizeProviderTimingForEnvelope(result.output || {}) + minimalResult.output = sanitizeOutputForEnvelope(result.output || {}, envelopeOptions) return compactExecutionPayload(minimalResult, { ...durableContext, preserveUserFileBase64: includeFileBase64, @@ -341,6 +366,23 @@ async function buildMinimalResult( return minimalResult } + /** + * Selected outputs are extracted from the sanitized block output, not the raw + * log. A deployment can select `toolCalls` or `providerTiming` directly, so + * sanitizing per selected path would leave the leak open for whichever path + * was missed; sanitizing the source closes it for every path at once. Cached + * per block because several descriptors can target the same one. + */ + const sanitizedBlockOutputs = new Map>() + const sanitizedOutputFor = (blockId: string, output: Record) => { + const cached = sanitizedBlockOutputs.get(blockId) + if (cached) return cached + + const sanitized = sanitizeOutputForEnvelope(output, envelopeOptions) + sanitizedBlockOutputs.set(blockId, sanitized) + return sanitized + } + let selectedOutputBytes = assertSelectedOutputBytes(minimalResult.output) for (const descriptor of getSelectedOutputDescriptors(selectedOutputs)) { const { blockId, path } = descriptor @@ -381,7 +423,11 @@ async function buildMinimalResult( getBase64DecodedByteBudget(remainingBytes) ), } - const value = await extractOutputValue(blockLog.output, path, extractionContext) + const value = await extractOutputValue( + sanitizedOutputFor(blockId, blockLog.output), + path, + extractionContext + ) if (value === undefined) { continue } @@ -452,12 +498,15 @@ export async function createStreamingResponse( ): Promise { const { requestId, streamConfig, executionId, executeFn } = options const timeoutController = createTimeoutAbortController(streamConfig.timeoutMs) - const emitAgentEvents = - Boolean(options.requestHeaders) && - shouldEmitAgentStreamEvents({ - includeThinking: streamConfig.includeThinking, - requestHeaders: options.requestHeaders!, - }) + /** + * Client capability, not deployment policy: a negotiated client can render + * live answer text and honor `chunk_reset`, so it streams token by token + * regardless of whether thinking or tools are exposed. + */ + const clientAcceptsProtocol = + Boolean(options.requestHeaders) && clientAcceptsAgentStreamProtocol(options.requestHeaders!) + const emitThinking = clientAcceptsProtocol && streamConfig.includeThinking === true + const emitToolCalls = clientAcceptsProtocol && streamConfig.includeToolCalls === true const maxThinkingChars = DEFAULT_MAX_THINKING_CHARS let requestAborted = false @@ -554,14 +603,19 @@ export async function createStreamingResponse( } /** - * Dual-gated clients get answer text live from the sink (pending deltas + * Negotiated clients get answer text live from the sink (pending deltas * stream as the model generates; `chunk_reset` clears an intermediate * turn). The byte stream then only feeds `streamedChunks` for logs. + * + * Legacy clients stay on the byte stream, which a streaming tool loop + * only writes once the turn is classified — correct for a consumer that + * cannot retract, at the cost of arriving in one piece. + * * Response-format projections rewrite the bytes, so those blocks keep - * the byte stream as the frame source. + * the byte stream as the frame source either way. */ const sinkAnswerText = - emitAgentEvents && + clientAcceptsProtocol && Boolean(streamingExec.subscribe) && streamingExec.clientStreamTransformed !== true @@ -581,15 +635,15 @@ export async function createStreamingResponse( } let unsubscribe: (() => void) | undefined - if (emitAgentEvents && streamingExec.subscribe) { + if (clientAcceptsProtocol && streamingExec.subscribe) { unsubscribe = streamingExec.subscribe({ onEvent: async (event) => { if (event.type === 'thinking_delta') { - sendThinking(blockId, event.text) + if (emitThinking) sendThinking(blockId, event.text) } else if (event.type === 'tool_call_start') { - sendTool(blockId, 'start', event.id, event.name) + if (emitToolCalls) sendTool(blockId, 'start', event.id, event.name) } else if (event.type === 'tool_call_end') { - sendTool(blockId, 'end', event.id, event.name, event.status) + if (emitToolCalls) sendTool(blockId, 'end', event.id, event.name, event.status) } else if (sinkAnswerText && event.type === 'text_delta') { if (event.turn !== 'intermediate') { emitAnswerChunk(event.text) @@ -659,6 +713,18 @@ export async function createStreamingResponse( (descriptor) => descriptor.blockId === blockId ) + /** + * A selected output is streamed here and then skipped in the `final` + * envelope, so this is the reachable path for a deployment that selects + * `toolCalls` or `providerTiming` — sanitizing only the envelope would + * leave the payload flowing through the chunk frame instead. + */ + const sanitizedOutput = isRecordLike(output) + ? sanitizeOutputForEnvelope(output, { + redactToolPayloads: streamConfig.isSecureMode === true, + }) + : output + for (const descriptor of matchingOutputs) { if (state.selectedOutputError) { break @@ -681,7 +747,11 @@ export async function createStreamingResponse( ), } const materializationContext = buildMaterializationContext(extractionContext) - const outputValue = await extractOutputValue(output, descriptor.path, extractionContext) + const outputValue = await extractOutputValue( + sanitizedOutput, + descriptor.path, + extractionContext + ) if (outputValue !== undefined) { const materializedOutput = await materializeInlineExecutionValue( @@ -802,6 +872,7 @@ export async function createStreamingResponse( fileKeys: result.metadata?.fileKeys ?? options.fileKeys, allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope, userId: options.userId, + redactToolPayloads: streamConfig.isSecureMode === true, } ) diff --git a/apps/sim/providers/anthropic/core.request.test.ts b/apps/sim/providers/anthropic/core.request.test.ts new file mode 100644 index 00000000000..afe13ce5ff8 --- /dev/null +++ b/apps/sim/providers/anthropic/core.request.test.ts @@ -0,0 +1,250 @@ +/** + * @vitest-environment node + */ +import type Anthropic from '@anthropic-ai/sdk' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeAnthropicProviderRequest } from '@/providers/anthropic/core' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +describe('executeAnthropicProviderRequest request identity and usage', () => { + beforeEach(() => { + mockExecuteTool.mockReset() + }) + + it('keeps registry identity while sending the resolved wire model and aggregating cache usage', async () => { + const create = vi.fn().mockResolvedValue({ + id: 'msg-test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Done' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + cache_creation: null, + output_tokens: 40, + }, + }) + + const result = (await executeAnthropicProviderRequest( + { + model: 'azure-anthropic/claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + messages: [{ role: 'system', content: 'Remain concise.' }], + }, + { + providerId: 'azure-anthropic', + providerLabel: 'Azure Anthropic', + resolveWireModel: () => 'claude-sonnet-4-5', + createClient: () => ({ messages: { create } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + )) as ProviderResponse + + expect(create.mock.calls[0][0]).toMatchObject({ + model: 'claude-sonnet-4-5', + // System is always block-shaped so cache_control has somewhere to live. + system: [{ type: 'text', text: 'Remain concise.' }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], + }) + expect(result.model).toBe('azure-anthropic/claude-sonnet-4-5') + expect(result.tokens).toEqual({ + input: 10, + output: 40, + total: 100, + cacheRead: 20, + cacheWrite: 30, + }) + expect(result.cost).toMatchObject({ + input: 0.0001485, + output: 0.0006, + total: 0.0007485, + }) + expect(result.timing?.timeSegments?.[0]).toMatchObject({ + provider: 'azure-anthropic', + tokens: { + input: 10, + output: 40, + total: 100, + cacheRead: 20, + cacheWrite: 30, + }, + }) + }) + + it('applies tool post-processing consistently in non-streaming tool loops', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { posted: true } }) + const create = vi + .fn() + .mockResolvedValueOnce({ + id: 'msg-tool', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'tool_use', id: 'tool-1', name: 'publish', input: {} }], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + .mockResolvedValueOnce({ + id: 'msg-answer', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Published' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + + await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + messages: [{ role: 'user', content: 'Publish this' }], + tools: [ + { + id: 'publish', + name: 'publish', + description: 'Publish a post', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { create } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'publish', + expect.any(Object), + expect.not.objectContaining({ skipPostProcess: true }) + ) + }) +}) + +describe('executeAnthropicProviderRequest prompt caching', () => { + function answerOnce() { + return vi.fn().mockResolvedValue({ + id: 'msg-cache', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Done' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + } + + const tools = [ + { + id: 'first', + name: 'first', + description: 'First tool', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + { + id: 'second', + name: 'second', + description: 'Second tool', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ] + + async function sendRequest(overrides: Partial) { + const create = answerOnce() + await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + systemPrompt: 'Remain concise.', + messages: [{ role: 'user', content: 'Hello' }], + ...overrides, + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { create } }) as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + } + ) + return create.mock.calls[0][0] as Anthropic.Messages.MessageCreateParams + } + + beforeEach(() => { + mockExecuteTool.mockReset() + }) + + it('breaks the cache after the last tool and the last system block when enabled', async () => { + const payload = await sendRequest({ promptCaching: true, tools }) + + expect(payload.system).toEqual([ + { type: 'text', text: 'Remain concise.', cache_control: { type: 'ephemeral' } }, + ]) + expect(payload.tools?.map((tool) => tool.cache_control)).toEqual([ + undefined, + { type: 'ephemeral' }, + ]) + }) + + it('sends no breakpoints when caching is off', async () => { + const payload = await sendRequest({ tools }) + + expect(payload.system).toEqual([{ type: 'text', text: 'Remain concise.' }]) + expect(payload.tools?.some((tool) => tool.cache_control)).toBe(false) + }) + + it('appends schema instructions as a block and caches only the last one', async () => { + const payload = await sendRequest({ + // Opus 4.1 lacks native structured outputs, so the schema is injected + // into the system prompt — the path that used to string-concatenate. + model: 'claude-opus-4-1', + promptCaching: true, + responseFormat: { name: 'answer', schema: { type: 'object', properties: {} } }, + }) + + const blocks = payload.system as Anthropic.Messages.TextBlockParam[] + expect(blocks).toHaveLength(2) + expect(blocks[0]).toEqual({ type: 'text', text: 'Remain concise.' }) + expect(blocks[1].text).toContain('answer') + expect(blocks[1].cache_control).toEqual({ type: 'ephemeral' }) + }) + + it('omits the system field entirely when there is no system text', async () => { + const payload = await sendRequest({ promptCaching: true, systemPrompt: undefined }) + + expect(payload.system).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/anthropic/core.streaming.test.ts b/apps/sim/providers/anthropic/core.streaming.test.ts new file mode 100644 index 00000000000..2424f53be66 --- /dev/null +++ b/apps/sim/providers/anthropic/core.streaming.test.ts @@ -0,0 +1,220 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeAnthropicProviderRequest } from '@/providers/anthropic/core' +import type { AgentStreamEvent } from '@/providers/stream-events' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +function message(content: unknown[], stopReason: string) { + return { + id: `msg-${stopReason}`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content, + stop_reason: stopReason, + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + } +} + +function stream(events: unknown[], finalMessage: ReturnType) { + return { + async *[Symbol.asyncIterator]() { + yield* events + }, + finalMessage: async () => finalMessage, + } +} + +async function collectEvents(result: StreamingExecution): Promise { + const events: AgentStreamEvent[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + +describe('executeAnthropicProviderRequest live tool streaming', () => { + it.each([ + ['anthropic', 'Anthropic'], + ['azure-anthropic', 'Azure Anthropic'], + ] as const)( + 'uses the live loop for %s streaming tool requests without a caller flag', + async (providerId, providerLabel) => { + const model = + providerId === 'azure-anthropic' ? 'azure-anthropic/claude-sonnet-4-5' : 'claude-sonnet-4-5' + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const toolMessage = message( + [{ type: 'tool_use', id: 'tool-1', name: 'lookup', input: {} }], + 'tool_use' + ) + const answerMessage = message([{ type: 'text', text: 'settled answer' }], 'end_turn') + const createStream = vi + .fn() + .mockReturnValueOnce( + stream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'tool-1', + name: 'lookup', + input: {}, + }, + }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_stop' }, + ], + toolMessage + ) + ) + .mockReturnValueOnce( + stream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'settled answer' }, + }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_stop' }, + ], + answerMessage + ) + ) + + const result = (await executeAnthropicProviderRequest( + { + model, + apiKey: 'test-key', + stream: true, + maxTokens: 1024, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId, + providerLabel, + ...(providerId === 'azure-anthropic' + ? { resolveWireModel: () => 'claude-sonnet-4-5' } + : {}), + createClient: () => ({ messages: { stream: createStream } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + )) as StreamingExecution + + const events = await collectEvents(result) + + expect(createStream).toHaveBeenCalledTimes(2) + expect(createStream.mock.calls[0][0].model).toBe('claude-sonnet-4-5') + expect(events).toContainEqual({ + type: 'text_delta', + text: 'settled answer', + turn: 'pending', + }) + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(result.execution.output.content).toBe('settled answer') + expect(result.execution.output.model).toBe(model) + expect( + result.execution.output.providerTiming?.timeSegments + ?.filter((segment) => segment.type === 'model') + .map((segment) => segment.provider) + ).toEqual([providerId, providerId]) + } + ) +}) + +/** + * Streaming is the common path, and breakpoints are placed on the shared + * payload before the loop is handed it. A regression that moved placement + * below the streaming branch would silently stop caching while the + * non-streaming payload tests still passed. + */ +describe('executeAnthropicProviderRequest streaming prompt caching', () => { + it('carries cache breakpoints into every turn of the live tool loop', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const createStream = vi + .fn() + .mockReturnValueOnce( + stream( + [{ type: 'message_stop' }], + message([{ type: 'tool_use', id: 'tool-1', name: 'lookup', input: {} }], 'tool_use') + ) + ) + .mockReturnValueOnce( + stream([{ type: 'message_stop' }], message([{ type: 'text', text: 'done' }], 'end_turn')) + ) + + const result = (await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + stream: true, + maxTokens: 1024, + promptCaching: true, + systemPrompt: 'Remain concise.', + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { stream: createStream } }) as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + } + )) as StreamingExecution + + await collectEvents(result) + + expect(createStream).toHaveBeenCalledTimes(2) + for (const call of createStream.mock.calls) { + const payload = call[0] + expect(payload.system).toEqual([ + { type: 'text', text: 'Remain concise.', cache_control: { type: 'ephemeral' } }, + ]) + expect(payload.tools.at(-1).cache_control).toEqual({ type: 'ephemeral' }) + } + }) +}) diff --git a/apps/sim/providers/anthropic/core.thinking.test.ts b/apps/sim/providers/anthropic/core.thinking.test.ts index 1213fcf8751..8fcad2e438d 100644 --- a/apps/sim/providers/anthropic/core.thinking.test.ts +++ b/apps/sim/providers/anthropic/core.thinking.test.ts @@ -37,10 +37,10 @@ describe('buildThinkingConfig', () => { } }) - it('never adds display for adaptive models that already stream full thinking', () => { + it('requests summarized display for adaptive models marked as summary-streamed', () => { for (const model of ['claude-opus-4-6', 'claude-sonnet-4-6']) { const config = buildThinkingConfig(model, 'high', true) - expect(config?.thinking).toEqual({ type: 'adaptive' }) + expect(config?.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) } }) diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index bdd690efe1d..88e5d8d71a6 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -3,19 +3,21 @@ import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { - BlockTokens, - IterationToolCall, - NormalizedBlockOutput, - StreamingExecution, -} from '@/executor/types' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { convertAnthropicRequestHistory } from '@/providers/anthropic/request-history' import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' import { checkForForcedToolUsage, createReadableStreamFromAnthropicStream, } from '@/providers/anthropic/utils' -import { buildAnthropicMessageContent } from '@/providers/attachments' import { getMaxOutputTokensForModel, getThinkingCapability, @@ -23,16 +25,12 @@ import { supportsTemperature, } from '@/providers/models' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError } from '@/providers/streaming-tool-loop-shared' import { adaptAnthropicToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' -import { - calculateCost, - prepareToolExecution, - prepareToolsWithUsageControl, - sumToolCosts, -} from '@/providers/utils' +import { prepareToolExecution, prepareToolsWithUsageControl } from '@/providers/utils' import { executeTool } from '@/tools' /** @@ -44,19 +42,53 @@ export interface AnthropicProviderConfig { /** Human-readable label for logging */ providerLabel: string /** Factory function to create the Anthropic client */ - createClient: (apiKey: string, useNativeStructuredOutputs: boolean) => Anthropic + createClient: (apiKey: string) => Anthropic + /** Resolves the provider-specific model or deployment name sent on the wire. */ + resolveWireModel?: (request: ProviderRequest) => string /** Logger instance */ logger: Logger } +type AnthropicPayload = Anthropic.Messages.MessageStreamParams + +/** Anthropic's only cache type; the default 5-minute TTL suits agent reuse. */ +const EPHEMERAL_CACHE: Anthropic.Messages.CacheControlEphemeral = { type: 'ephemeral' } + /** - * Custom payload type extending the SDK's base message creation params. - * Message params plus `output_format`: Sim's structured outputs ride the - * anthropic-beta header with a top-level `output_format` field, which the SDK - * does not model (it exposes the newer `output_config.format` shape instead). + * Builds the `system` field as text blocks. + * + * Always an array, never a bare string, so the shape does not depend on whether + * prompt caching is on and `cache_control` always has a block to attach to. + * Returns `undefined` when there is no system text so the field is omitted. */ -interface AnthropicPayload extends Anthropic.Messages.MessageStreamParams { - output_format?: { type: 'json_schema'; schema: Record } +function buildSystemBlocks( + texts: string[], + cacheLastBlock: boolean +): Anthropic.Messages.TextBlockParam[] | undefined { + const blocks: Anthropic.Messages.TextBlockParam[] = texts + .filter((text) => text.trim().length > 0) + .map((text) => ({ type: 'text', text })) + + if (blocks.length === 0) return undefined + + if (cacheLastBlock) { + blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], cache_control: EPHEMERAL_CACHE } + } + return blocks +} + +/** + * Marks the final tool definition, caching every tool ahead of it. + * + * Typed on `ToolUnion` to match the payload field: every member of that union, + * including the server-side tools, accepts `cache_control`. + */ +function withCacheBreakpointOnLast( + tools: Anthropic.Messages.ToolUnion[] +): Anthropic.Messages.ToolUnion[] { + const marked = [...tools] + marked[marked.length - 1] = { ...marked[marked.length - 1], cache_control: EPHEMERAL_CACHE } + return marked } /** @@ -224,61 +256,22 @@ export async function executeAnthropicProviderRequest( request.responseFormat && supportsNativeStructuredOutputs(modelId) ) - const anthropic = config.createClient(request.apiKey, useNativeStructuredOutputs) - - const messages: Anthropic.Messages.MessageParam[] = [] - let systemPrompt = request.systemPrompt || '' - - if (request.context) { - messages.push({ - role: 'user', - content: request.context, - }) - } - - if (request.messages) { - request.messages.forEach((msg) => { - if (msg.role === 'function') { - messages.push({ - role: 'user', - content: [ - { - type: 'tool_result', - tool_use_id: msg.name || '', - content: msg.content || undefined, - }, - ], - }) - } else if (msg.function_call) { - const toolUseId = `${msg.function_call.name}-${Date.now()}` - messages.push({ - role: 'assistant', - content: [ - { - type: 'tool_use', - id: toolUseId, - name: msg.function_call.name, - input: JSON.parse(msg.function_call.arguments), - }, - ], - }) - } else { - const content = buildAnthropicMessageContent(msg.content, msg.files, config.providerId) - messages.push({ - role: msg.role === 'assistant' ? 'assistant' : 'user', - // double-cast-allowed: shared attachment builder returns Anthropic-compatible content blocks but avoids importing SDK-only union types - content: content as unknown as Anthropic.Messages.ContentBlockParam[], - }) - } - }) - } + const anthropic = config.createClient(request.apiKey) + const wireModel = config.resolveWireModel?.(request) ?? modelId + const convertedHistory = convertAnthropicRequestHistory({ + messages: request.messages, + systemPrompt: request.systemPrompt, + context: request.context, + providerId, + }) + const messages = convertedHistory.messages + const systemPrompt = convertedHistory.systemPrompt if (messages.length === 0) { messages.push({ role: 'user', - content: [{ type: 'text', text: systemPrompt || 'Hello' }], + content: [{ type: 'text', text: 'Hello' }], }) - systemPrompt = '' } let anthropicTools: Anthropic.Messages.Tool[] | undefined = request.tools?.length @@ -289,44 +282,41 @@ export async function executeAnthropicProviderRequest( let preparedTools: ReturnType | null = null if (anthropicTools?.length) { - try { - preparedTools = prepareToolsWithUsageControl( - anthropicTools, - request.tools, - logger, - providerId - ) - const { tools: filteredTools, toolChoice: tc } = preparedTools - - if (filteredTools?.length) { - anthropicTools = filteredTools - - if (typeof tc === 'object' && tc !== null) { - if (tc.type === 'tool') { - toolChoice = tc - logger.info(`Using ${providerLabel} tool_choice format: force tool "${tc.name}"`) - } else { - toolChoice = 'auto' - logger.warn(`Received non-${providerLabel} tool_choice format, defaulting to auto`) - } - } else if (tc === 'auto' || tc === 'none') { - toolChoice = tc - logger.info(`Using tool_choice mode: ${tc}`) - } else { - toolChoice = 'auto' - logger.warn('Unexpected tool_choice format, defaulting to auto') - } + preparedTools = prepareToolsWithUsageControl(anthropicTools, request.tools, logger, providerId) + const { tools: filteredTools, toolChoice: preparedToolChoice } = preparedTools + anthropicTools = filteredTools?.length + ? (filteredTools as Anthropic.Messages.Tool[]) + : undefined + + if (anthropicTools?.length) { + if (preparedToolChoice === 'auto' || preparedToolChoice === 'none') { + toolChoice = preparedToolChoice + logger.info(`Using tool_choice mode: ${preparedToolChoice}`) + } else if ( + preparedToolChoice?.type === 'tool' && + typeof preparedToolChoice.name === 'string' && + preparedToolChoice.name.length > 0 + ) { + toolChoice = preparedToolChoice + logger.info( + `Using ${providerLabel} tool_choice format: force tool "${preparedToolChoice.name}"` + ) + } else { + throw new Error(`Invalid ${providerLabel} tool choice returned by tool preparation`) } - } catch (error) { - logger.error('Error in prepareToolsWithUsageControl:', { error }) - toolChoice = 'auto' } } + /** + * System text accumulates here and is turned into blocks once, below, after + * any schema instructions are appended. Keeping it as plain strings until + * then means only one place knows the wire shape. + */ + const systemTexts = systemPrompt ? [systemPrompt] : [] + const payload: AnthropicPayload = { - model: request.model, + model: wireModel, messages, - system: systemPrompt, max_tokens: Number.parseInt(String(request.maxTokens)) || getMaxOutputTokensForModel(request.model), ...(supportsTemperature(request.model) && { @@ -339,16 +329,16 @@ export async function executeAnthropicProviderRequest( if (useNativeStructuredOutputs) { const transformedSchema = transformJSONSchema(schema) - payload.output_format = { - type: 'json_schema', - schema: transformedSchema, + payload.output_config = { + ...payload.output_config, + format: { + type: 'json_schema', + schema: transformedSchema, + }, } logger.info(`Using native structured outputs for model: ${modelId}`) } else { - const schemaInstructions = generateSchemaInstructions(schema, request.responseFormat.name) - payload.system = payload.system - ? `${payload.system}\n\n${schemaInstructions}` - : schemaInstructions + systemTexts.push(generateSchemaInstructions(schema, request.responseFormat.name)) logger.info(`Using prompt-based structured outputs for model: ${modelId}`) } } @@ -364,7 +354,10 @@ export async function executeAnthropicProviderRequest( if (thinkingConfig) { payload.thinking = thinkingConfig.thinking if (thinkingConfig.outputConfig) { - payload.output_config = thinkingConfig.outputConfig + payload.output_config = { + ...payload.output_config, + ...thinkingConfig.outputConfig, + } } // Keep budget_tokens < max_tokens (see constants above) by shrinking the budget @@ -424,9 +417,21 @@ export async function executeAnthropicProviderRequest( } } - const shouldStreamToolCalls = request.streamToolCalls ?? false + /** + * Prompt-cache breakpoints go on the static prefix, once, after tools and + * system text are final. Anthropic hashes the prefix in tools → system → + * messages order, so marking the last tool and the last system block caches + * everything ahead of the conversation, which is the part that stays byte + * stable across turns. Both tool loops spread this payload, so placing them + * here covers the streaming and non-streaming paths alike. + */ + const cacheStaticPrefix = request.promptCaching === true + if (cacheStaticPrefix && payload.tools?.length) { + payload.tools = withCacheBreakpointOnLast(payload.tools) + } + payload.system = buildSystemBlocks(systemTexts, cacheStaticPrefix) - if (request.stream && shouldStreamToolCalls && anthropicTools && anthropicTools.length > 0) { + if (request.stream && anthropicTools && anthropicTools.length > 0) { logger.info(`Using streaming tool loop for ${providerLabel} request`) const providerStartTime = Date.now() @@ -456,6 +461,7 @@ export async function executeAnthropicProviderRequest( payload, request, messages, + providerId, logger, timeSegments, forcedTools, @@ -503,23 +509,22 @@ export async function executeAnthropicProviderRequest( createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, ({ content, usage, thinking }) => { + const tokens = buildAnthropicUsageTokens(usage) + const cost = buildAnthropicUsageCost(request.model, usage) output.content = content - output.tokens = { - input: usage.input_tokens, - output: usage.output_tokens, - total: usage.input_tokens + usage.output_tokens, - } - - const costResult = calculateCost(request.model, usage.input_tokens, usage.output_tokens) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - if (thinking) { - const segment = output.providerTiming?.timeSegments?.[0] - if (segment) { + output.tokens = tokens + output.cost = cost + + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.provider = providerId + segment.tokens = tokens + segment.cost = { + input: cost.input, + output: cost.output, + total: cost.total, + } + if (thinking) { segment.thinkingContent = thinking } } @@ -532,430 +537,6 @@ export async function executeAnthropicProviderRequest( return streamingResult } - if (request.stream && !shouldStreamToolCalls) { - logger.info( - `Using non-streaming mode for ${providerLabel} request (tool calls executed silently)` - ) - - const providerStartTime = Date.now() - const providerStartTimeISO = new Date(providerStartTime).toISOString() - - try { - const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] - - let currentResponse = await createMessage(anthropic, payload, request.abortSignal) - const firstResponseTime = Date.now() - initialCallTime - - let content = '' - - if (Array.isArray(currentResponse.content)) { - content = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - } - - const tokens = { - input: currentResponse.usage?.input_tokens || 0, - output: currentResponse.usage?.output_tokens || 0, - total: - (currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0), - } - - const toolCalls = [] - const toolResults: Record[] = [] - const currentMessages = [...messages] - let iterationCount = 0 - let hasUsedForcedTool = false - let modelTime = firstResponseTime - let toolsTime = 0 - - const timeSegments: TimeSegment[] = [ - { - type: 'model', - name: request.model, - startTime: initialCallTime, - endTime: initialCallTime + firstResponseTime, - duration: firstResponseTime, - }, - ] - - const firstCheckResult = checkForForcedToolUsage( - currentResponse, - originalToolChoice, - forcedTools, - usedForcedTools - ) - if (firstCheckResult) { - hasUsedForcedTool = firstCheckResult.hasUsedForcedTool - usedForcedTools = firstCheckResult.usedForcedTools - } - - try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - const textContent = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - - if (textContent) { - content = textContent - } - - const toolUses = currentResponse.content.filter((item) => item.type === 'tool_use') - - enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { - model: request.model, - }) - - if (!toolUses || toolUses.length === 0) { - break - } - - const toolsStartTime = Date.now() - - const toolExecutionPromises = toolUses.map(async (toolUse) => { - const toolCallStartTime = Date.now() - const toolName = toolUse.name - const toolArgs = toolUse.input as Record - - try { - const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null - - const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) - const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, - }) - const toolCallEndTime = Date.now() - - return { - toolUse, - toolName, - toolArgs, - toolParams, - result, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } catch (error) { - const toolCallEndTime = Date.now() - logger.error('Error processing tool call:', { error, toolName }) - - return { - toolUse, - toolName, - toolArgs, - toolParams: {}, - result: { - success: false, - output: undefined, - error: getErrorMessage(error, 'Tool execution failed'), - }, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - } - } - }) - - const executionResults = await Promise.allSettled(toolExecutionPromises) - - // Collect all tool_use and tool_result blocks for batching - const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] - const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - - const { - toolUse, - toolName, - toolArgs, - toolParams, - result, - startTime, - endTime, - duration, - } = settledResult.value - - timeSegments.push({ - type: 'tool', - name: toolName, - startTime: startTime, - endTime: endTime, - duration: duration, - toolCallId: toolUse.id, - }) - - let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output - } else { - resultContent = { - error: true, - message: result.error || 'Tool execution failed', - tool: toolName, - } - } - - toolCalls.push({ - name: toolName, - arguments: toolParams, - startTime: new Date(startTime).toISOString(), - endTime: new Date(endTime).toISOString(), - duration: duration, - result: resultContent, - success: result.success, - }) - - // Add to batched arrays using the ORIGINAL ID from Claude's response - toolUseBlocks.push({ - type: 'tool_use', - id: toolUse.id, - name: toolName, - input: toolArgs, - }) - - toolResultBlocks.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - content: JSON.stringify(resultContent), - }) - } - - // Per Anthropic docs: thinking blocks must be preserved in assistant messages - // during tool use to maintain reasoning continuity. - const thinkingBlocks = currentResponse.content.filter( - ( - item - ): item is - | Anthropic.Messages.ThinkingBlock - | Anthropic.Messages.RedactedThinkingBlock => - item.type === 'thinking' || item.type === 'redacted_thinking' - ) - - // Add ONE assistant message with thinking + tool_use blocks - if (toolUseBlocks.length > 0) { - currentMessages.push({ - role: 'assistant', - content: [ - ...thinkingBlocks, - ...toolUseBlocks, - ] as Anthropic.Messages.ContentBlockParam[], - }) - } - - // Add ONE user message with ALL tool_result blocks - if (toolResultBlocks.length > 0) { - currentMessages.push({ - role: 'user', - content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[], - }) - } - - const thisToolsTime = Date.now() - toolsStartTime - toolsTime += thisToolsTime - - const nextPayload: AnthropicPayload = { - ...payload, - messages: currentMessages, - } - - // Per Anthropic docs: forced tool_choice is incompatible with thinking. - // Only auto and none are supported when thinking is enabled. - const thinkingEnabled = !!payload.thinking - if ( - !thinkingEnabled && - typeof originalToolChoice === 'object' && - hasUsedForcedTool && - forcedTools.length > 0 - ) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - - if (remainingTools.length > 0) { - nextPayload.tool_choice = { - type: 'tool', - name: remainingTools[0], - } - logger.info(`Forcing next tool: ${remainingTools[0]}`) - } else { - nextPayload.tool_choice = undefined - logger.info('All forced tools have been used, removing tool_choice parameter') - } - } else if ( - !thinkingEnabled && - hasUsedForcedTool && - typeof originalToolChoice === 'object' - ) { - nextPayload.tool_choice = undefined - logger.info( - 'Removing tool_choice parameter for subsequent requests after forced tool was used' - ) - } - - const nextModelStartTime = Date.now() - - currentResponse = await createMessage(anthropic, nextPayload, request.abortSignal) - - const nextCheckResult = checkForForcedToolUsage( - currentResponse, - nextPayload.tool_choice, - forcedTools, - usedForcedTools - ) - if (nextCheckResult) { - hasUsedForcedTool = nextCheckResult.hasUsedForcedTool - usedForcedTools = nextCheckResult.usedForcedTools - } - - const nextModelEndTime = Date.now() - const thisModelTime = nextModelEndTime - nextModelStartTime - - timeSegments.push({ - type: 'model', - name: request.model, - startTime: nextModelStartTime, - endTime: nextModelEndTime, - duration: thisModelTime, - }) - - modelTime += thisModelTime - - if (currentResponse.usage) { - tokens.input += currentResponse.usage.input_tokens || 0 - tokens.output += currentResponse.usage.output_tokens || 0 - tokens.total += - (currentResponse.usage.input_tokens || 0) + (currentResponse.usage.output_tokens || 0) - } - - iterationCount++ - } - - if (iterationCount === MAX_TOOL_ITERATIONS) { - const trailingText = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { - model: request.model, - }) - } - } catch (error) { - logger.error(`Error in ${providerLabel} request:`, { error }) - throw error - } - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_use is never executed on this path. `tools` must stay - * (history contains tool_use blocks) but tool choice is pinned to none; - * with tools present and no choice, `auto` would let the model re-call. - */ - const streamingPayload = { - ...payload, - messages: currentMessages, - stream: true, - tool_choice: payload.tools?.length ? ({ type: 'none' } as const) : undefined, - } - - const streamResponse = await anthropic.messages.create( - streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - ({ content: streamContent, usage, thinking }) => { - if (!streamContent && content) { - logger.warn( - `${providerLabel} final stream produced no text; keeping tool-loop answer` - ) - } - output.content = streamContent || content - output.tokens = { - input: tokens.input + usage.input_tokens, - output: tokens.output + usage.output_tokens, - total: tokens.total + usage.input_tokens + usage.output_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.input_tokens, - usage.output_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - - if (thinking) { - const segments = output.providerTiming?.timeSegments - const lastModel = segments - ? [...segments].reverse().find((segment) => segment.type === 'model') - : undefined - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - - finalizeTiming() - } - ), - }) - - return streamingResult - } catch (error) { - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - logger.error(`Error in ${providerLabel} request:`, { - error, - duration: totalDuration, - }) - - throw new ProviderError(toError(error).message, { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - }) - } - } - const providerStartTime = Date.now() const providerStartTimeISO = new Date(providerStartTime).toISOString() @@ -977,23 +558,8 @@ export async function executeAnthropicProviderRequest( .join('\n') } - const tokens = { - input: currentResponse.usage?.input_tokens || 0, - output: currentResponse.usage?.output_tokens || 0, - total: - (currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0), - } - - const initialCost = calculateCost( - request.model, - currentResponse.usage?.input_tokens || 0, - currentResponse.usage?.output_tokens || 0 - ) - const cost = { - input: initialCost.input, - output: initialCost.output, - total: initialCost.total, - } + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, currentResponse.usage) const toolCalls = [] const toolResults: Record[] = [] @@ -1039,9 +605,17 @@ export async function executeAnthropicProviderRequest( enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { model: request.model, + providerId, }) - if (!toolUses || toolUses.length === 0) { + if (toolUses.length > 0 && currentResponse.stop_reason !== 'tool_use') { + throw new Error( + `${providerLabel} returned tool use with stop_reason ${ + currentResponse.stop_reason ?? 'missing' + }` + ) + } + if (toolUses.length === 0) { break } @@ -1050,17 +624,36 @@ export async function executeAnthropicProviderRequest( const toolExecutionPromises = toolUses.map(async (toolUse) => { const toolCallStartTime = Date.now() const toolName = toolUse.name - const toolArgs = toolUse.input as Record + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined // Preserve the original tool_use ID from Claude's response const toolUseId = toolUse.id try { + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolUseId, + toolName, + toolArgs, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { - skipPostProcess: true, signal: request.abortSignal, }) const toolCallEndTime = Date.now() @@ -1076,13 +669,16 @@ export async function executeAnthropicProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) return { toolUseId, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams: {}, result: { success: false, @@ -1096,15 +692,12 @@ export async function executeAnthropicProviderRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) // Collect all tool_use and tool_result blocks for batching - const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolUseId, toolName, @@ -1114,7 +707,7 @@ export async function executeAnthropicProviderRequest( startTime, endTime, duration, - } = settledResult.value + } = executionResult timeSegments.push({ type: 'tool', @@ -1126,9 +719,11 @@ export async function executeAnthropicProviderRequest( }) let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -1147,38 +742,32 @@ export async function executeAnthropicProviderRequest( success: result.success, }) - // Add to batched arrays using the ORIGINAL ID from Claude's response - toolUseBlocks.push({ - type: 'tool_use', - id: toolUseId, - name: toolName, - input: toolArgs, - }) - toolResultBlocks.push({ type: 'tool_result', tool_use_id: toolUseId, content: JSON.stringify(resultContent), + is_error: !result.success, }) } - // Per Anthropic docs: thinking blocks must be preserved in assistant messages - // during tool use to maintain reasoning continuity. - const thinkingBlocks = currentResponse.content.filter( + const assistantBlocks = currentResponse.content.filter( ( item - ): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => - item.type === 'thinking' || item.type === 'redacted_thinking' + ): item is + | Anthropic.Messages.TextBlock + | Anthropic.Messages.ThinkingBlock + | Anthropic.Messages.RedactedThinkingBlock + | Anthropic.Messages.ToolUseBlock => + item.type === 'text' || + item.type === 'thinking' || + item.type === 'redacted_thinking' || + item.type === 'tool_use' ) - // Add ONE assistant message with thinking + tool_use blocks - if (toolUseBlocks.length > 0) { + if (assistantBlocks.some((item) => item.type === 'tool_use')) { currentMessages.push({ role: 'assistant', - content: [ - ...thinkingBlocks, - ...toolUseBlocks, - ] as Anthropic.Messages.ContentBlockParam[], + content: assistantBlocks, }) } @@ -1258,21 +847,7 @@ export async function executeAnthropicProviderRequest( modelTime += thisModelTime - if (currentResponse.usage) { - tokens.input += currentResponse.usage.input_tokens || 0 - tokens.output += currentResponse.usage.output_tokens || 0 - tokens.total += - (currentResponse.usage.input_tokens || 0) + (currentResponse.usage.output_tokens || 0) - - const iterationCost = calculateCost( - request.model, - currentResponse.usage.input_tokens || 0, - currentResponse.usage.output_tokens || 0 - ) - cost.input += iterationCost.input - cost.output += iterationCost.output - cost.total += iterationCost.total - } + addAnthropicUsage(usage, currentResponse.usage) iterationCount++ } @@ -1284,6 +859,7 @@ export async function executeAnthropicProviderRequest( .join('\n') enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { model: request.model, + providerId, }) } } catch (error) { @@ -1294,96 +870,14 @@ export async function executeAnthropicProviderRequest( const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - - if (request.stream) { - logger.info(`Using streaming for final ${providerLabel} response after tool processing`) - - /** Same regeneration guard as the primary path: prose only, no re-calls. */ - const streamingPayload = { - ...payload, - messages: currentMessages, - stream: true, - tool_choice: payload.tools?.length ? ({ type: 'none' } as const) : undefined, - } - - const streamResponse = await anthropic.messages.create( - streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: cost.input, - output: cost.output, - toolCost: undefined as number | undefined, - total: cost.total, - }, - toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - ({ content: streamContent, usage, thinking }) => { - if (!streamContent && content) { - logger.warn( - `${providerLabel} final stream produced no text; keeping tool-loop answer` - ) - } - output.content = streamContent || content - output.tokens = { - input: tokens.input + usage.input_tokens, - output: tokens.output + usage.output_tokens, - total: tokens.total + usage.input_tokens + usage.output_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.input_tokens, - usage.output_tokens - ) - const tc2 = sumToolCosts(toolResults) - output.cost = { - input: cost.input + streamCost.input, - output: cost.output + streamCost.output, - toolCost: tc2 || undefined, - total: cost.total + streamCost.total + tc2, - } - - if (thinking) { - const segments = output.providerTiming?.timeSegments - const lastModel = segments - ? [...segments].reverse().find((segment) => segment.type === 'model') - : undefined - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - - finalizeTiming() - } - ), - }) - - return streamingResult - } + const tokens = buildAnthropicUsageTokens(usage) + const cost = buildAnthropicUsageCost(request.model, usage) return { content, model: request.model, tokens, + cost, toolCalls: toolCalls.length > 0 ? toolCalls.map((tc) => ({ @@ -1417,6 +911,10 @@ export async function executeAnthropicProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, @@ -1436,6 +934,7 @@ function enrichLastModelSegmentFromAnthropicResponse( textContent: string, extras?: { model?: string + providerId?: string ttft?: number errorType?: string errorMessage?: string @@ -1461,17 +960,12 @@ function enrichLastModelSegmentFromAnthropicResponse( : {}, })) - const segmentTokens = response.usage ? buildAnthropicSegmentTokens(response.usage) : undefined - + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, response.usage) + const segmentTokens = buildAnthropicUsageTokens(usage) let cost: { input: number; output: number; total: number } | undefined - if ( - extras?.model && - segmentTokens && - typeof segmentTokens.input === 'number' && - typeof segmentTokens.output === 'number' - ) { - const useCached = (segmentTokens.cacheRead ?? 0) > 0 - const full = calculateCost(extras.model, segmentTokens.input, segmentTokens.output, useCached) + if (extras?.model) { + const full = buildAnthropicUsageCost(extras.model, usage) cost = { input: full.input, output: full.output, total: full.total } } @@ -1482,29 +976,9 @@ function enrichLastModelSegmentFromAnthropicResponse( finishReason: response.stop_reason ?? undefined, tokens: segmentTokens, cost, - provider: 'anthropic', + provider: extras?.providerId, ttft: extras?.ttft, errorType: extras?.errorType, errorMessage: extras?.errorMessage, }) } - -/** - * Builds a segment token breakdown from Anthropic usage data, surfacing prompt - * cache reads/writes separately and producing a corrected `total` that includes - * cache_creation tokens (which Anthropic bills as input tokens but omits from - * `input_tokens`). - */ -function buildAnthropicSegmentTokens(usage: Anthropic.Messages.Message['usage']): BlockTokens { - const input = usage.input_tokens ?? 0 - const output = usage.output_tokens ?? 0 - const cacheRead = usage.cache_read_input_tokens ?? 0 - const cacheWrite = usage.cache_creation_input_tokens ?? 0 - return { - input, - output, - total: input + output + cacheRead + cacheWrite, - ...(cacheRead > 0 && { cacheRead }), - ...(cacheWrite > 0 && { cacheWrite }), - } -} diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 043ae4b0f09..80129eb3cfc 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -22,18 +22,9 @@ export const anthropicProvider: ProviderConfig = { return executeAnthropicProviderRequest(request, { providerId: 'anthropic', providerLabel: 'Anthropic', - createClient: (apiKey, useNativeStructuredOutputs) => { - const cacheKey = `anthropic::${apiKey}::${useNativeStructuredOutputs ? 'beta' : 'default'}` - return getCachedProviderClient( - cacheKey, - () => - new Anthropic({ - apiKey, - defaultHeaders: useNativeStructuredOutputs - ? { 'anthropic-beta': 'structured-outputs-2025-11-13' } - : undefined, - }) - ) + createClient: (apiKey) => { + const cacheKey = `anthropic::${apiKey}` + return getCachedProviderClient(cacheKey, () => new Anthropic({ apiKey })) }, logger, }) diff --git a/apps/sim/providers/anthropic/request-history.test.ts b/apps/sim/providers/anthropic/request-history.test.ts new file mode 100644 index 00000000000..9dfac540180 --- /dev/null +++ b/apps/sim/providers/anthropic/request-history.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { convertAnthropicRequestHistory } from '@/providers/anthropic/request-history' + +describe('convertAnthropicRequestHistory', () => { + it('merges system history into the top-level prompt and preserves ordinary messages', () => { + const result = convertAnthropicRequestHistory({ + systemPrompt: 'Base instructions', + providerId: 'anthropic', + messages: [ + { role: 'system', content: 'First historical instruction' }, + { role: 'user', content: 'Hello' }, + { role: 'system', content: 'Second historical instruction' }, + { role: 'assistant', content: 'Hi there' }, + ], + }) + + expect(result.systemPrompt).toBe( + 'Base instructions\n\nFirst historical instruction\n\nSecond historical instruction' + ) + expect(result.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] }, + ]) + }) + + it('preserves modern tool IDs, parsed arguments, assistant text, and matching tool results', () => { + const result = convertAnthropicRequestHistory({ + providerId: 'anthropic', + messages: [ + { + role: 'assistant', + content: 'I will check both.', + tool_calls: [ + { + id: 'call-weather', + type: 'function', + function: { name: 'weather', arguments: '{"city":"Paris"}' }, + }, + { + id: 'call-time', + type: 'function', + function: { name: 'time', arguments: '{"timezone":"UTC"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call-weather', content: '' }, + { role: 'tool', tool_call_id: 'call-time', content: '00:00' }, + ], + }) + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'I will check both.' }, + { + type: 'tool_use', + id: 'call-weather', + name: 'weather', + input: { city: 'Paris' }, + }, + { + type: 'tool_use', + id: 'call-time', + name: 'time', + input: { timezone: 'UTC' }, + }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call-weather', content: '' }, + { type: 'tool_result', tool_use_id: 'call-time', content: '00:00' }, + ], + }, + ]) + }) + + it('pairs legacy function calls and results with stable deterministic IDs', () => { + const options = { + providerId: 'anthropic', + messages: [ + { + role: 'assistant' as const, + content: 'Checking.', + function_call: { name: 'lookup', arguments: '{"id":0}' }, + }, + { role: 'function' as const, name: 'lookup', content: 'false' }, + ], + } + + const first = convertAnthropicRequestHistory(options) + const second = convertAnthropicRequestHistory(options) + const firstToolUse = first.messages[0].content[1] + const secondToolUse = second.messages[0].content[1] + + expect(firstToolUse).toMatchObject({ + type: 'tool_use', + name: 'lookup', + input: { id: 0 }, + }) + expect(secondToolUse).toEqual(firstToolUse) + expect(first.messages[1]).toEqual({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: (firstToolUse as { id: string }).id, + content: 'false', + }, + ], + }) + }) + + it.each([ + ['malformed JSON', '{"id":'], + ['non-object JSON', '[]'], + ])('rejects %s legacy function arguments', (_label, args) => { + expect(() => + convertAnthropicRequestHistory({ + providerId: 'anthropic', + messages: [ + { + role: 'assistant', + content: null, + function_call: { name: 'lookup', arguments: args }, + }, + { role: 'function', name: 'lookup', content: 'result' }, + ], + }) + ).toThrow(/tool "lookup"/) + }) +}) diff --git a/apps/sim/providers/anthropic/request-history.ts b/apps/sim/providers/anthropic/request-history.ts new file mode 100644 index 00000000000..6909bda8747 --- /dev/null +++ b/apps/sim/providers/anthropic/request-history.ts @@ -0,0 +1,162 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { buildAnthropicMessageContent } from '@/providers/attachments' +import { parseToolArguments } from '@/providers/streaming-tool-loop-shared' +import type { Message } from '@/providers/types' + +interface ConvertAnthropicRequestHistoryOptions { + messages?: Message[] + systemPrompt?: string + context?: string + providerId: string +} + +interface ConvertedAnthropicRequestHistory { + messages: Anthropic.Messages.MessageParam[] + systemPrompt: string +} + +interface PendingToolCall { + id: string + name: string +} + +/** + * Converts Sim's shared request history into Anthropic's message protocol. + */ +export function convertAnthropicRequestHistory({ + messages: sourceMessages = [], + systemPrompt, + context, + providerId, +}: ConvertAnthropicRequestHistoryOptions): ConvertedAnthropicRequestHistory { + const convertedMessages: Anthropic.Messages.MessageParam[] = [] + const systemParts = systemPrompt ? [systemPrompt] : [] + const pendingToolCalls = new Map() + let toolResultBlocks: Anthropic.Messages.ContentBlockParam[] | undefined + + if (context) { + convertedMessages.push({ + role: 'user', + content: [{ type: 'text', text: context }], + }) + } + + const assertNoPendingToolCalls = () => { + if (pendingToolCalls.size > 0) { + throw new Error( + `Anthropic request history is missing tool results for: ${[...pendingToolCalls.values()] + .map(({ name, id }) => `${name} (${id})`) + .join(', ')}` + ) + } + toolResultBlocks = undefined + } + + const registerToolCall = (toolCall: PendingToolCall) => { + if (!toolCall.id) { + throw new Error(`Anthropic tool call "${toolCall.name}" is missing an ID`) + } + if (pendingToolCalls.has(toolCall.id)) { + throw new Error(`Anthropic request history contains duplicate tool call ID "${toolCall.id}"`) + } + pendingToolCalls.set(toolCall.id, toolCall) + } + + const appendToolResult = (toolCallId: string | undefined, content: string | null) => { + if (!toolCallId || !pendingToolCalls.has(toolCallId)) { + throw new Error( + `Anthropic request history contains a tool result without a matching tool call${ + toolCallId ? `: ${toolCallId}` : '' + }` + ) + } + + const resultBlock: Anthropic.Messages.ToolResultBlockParam = { + type: 'tool_result', + tool_use_id: toolCallId, + ...(content !== null ? { content } : {}), + } + + if (!toolResultBlocks) { + toolResultBlocks = [resultBlock] + convertedMessages.push({ role: 'user', content: toolResultBlocks }) + } else { + toolResultBlocks.push(resultBlock) + } + + pendingToolCalls.delete(toolCallId) + } + + sourceMessages.forEach((message, messageIndex) => { + if (message.role === 'system') { + if (message.content) { + systemParts.push(message.content) + } + return + } + + if (message.role === 'tool') { + appendToolResult(message.tool_call_id, message.content) + return + } + + if (message.role === 'function') { + const matchingCall = [...pendingToolCalls.values()].find( + (toolCall) => toolCall.name === message.name + ) + appendToolResult(matchingCall?.id, message.content) + return + } + + assertNoPendingToolCalls() + + const content = buildAnthropicMessageContent(message.content, message.files, providerId) + if (message.role === 'assistant' && message.tool_calls?.length) { + const toolUseBlocks = message.tool_calls.map((toolCall) => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parseToolArguments(toolCall.function.arguments, toolCall.function.name), + } + registerToolCall({ id: block.id, name: block.name }) + return block + }) + convertedMessages.push({ + role: 'assistant', + content: [...content, ...toolUseBlocks], + }) + return + } + + if (message.role === 'assistant' && message.function_call) { + const toolUseId = `legacy-function-call-${messageIndex}` + const toolUseBlock: Anthropic.Messages.ToolUseBlockParam = { + type: 'tool_use', + id: toolUseId, + name: message.function_call.name, + input: parseToolArguments(message.function_call.arguments, message.function_call.name), + } + registerToolCall({ id: toolUseId, name: toolUseBlock.name }) + convertedMessages.push({ + role: 'assistant', + content: [...content, toolUseBlock], + }) + return + } + + if (content.length > 0) { + convertedMessages.push({ + role: message.role === 'assistant' ? 'assistant' : 'user', + content, + }) + } + }) + + assertNoPendingToolCalls() + + return { + messages: convertedMessages, + systemPrompt: systemParts.join('\n\n'), + } +} diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts index 311b1942cdb..c0f730af6c1 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -10,6 +10,7 @@ import { anthropicThinkingTextToolStreamEvents, } from '@/providers/__fixtures__/anthropic' import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' +import type { AnthropicUsageLike } from '@/providers/anthropic/usage' import type { AgentStreamEvent } from '@/providers/stream-events' import type { TimeSegment } from '@/providers/types' @@ -44,7 +45,7 @@ async function collectEvents( function makeFinalMessage(overrides: { content: unknown[] - usage?: { input_tokens: number; output_tokens: number } + usage?: AnthropicUsageLike stop_reason?: string | null }) { return { @@ -107,7 +108,12 @@ describe('createAnthropicStreamingToolLoopStream', () => { input: { city: 'San Francisco' }, }, ], - usage: { input_tokens: 42, output_tokens: 30 }, + usage: { + input_tokens: 42, + cache_read_input_tokens: 8, + cache_creation_input_tokens: 10, + output_tokens: 30, + }, stop_reason: 'tool_use', }) @@ -138,7 +144,15 @@ describe('createAnthropicStreamingToolLoopStream', () => { ] const finalTurnMessage = makeFinalMessage({ content: [{ type: 'text', text: 'It is 68°F in San Francisco.' }], - usage: { input_tokens: 100, output_tokens: 12 }, + usage: { + input_tokens: 100, + cache_creation_input_tokens: 5, + cache_creation: { + ephemeral_5m_input_tokens: 2, + ephemeral_1h_input_tokens: 3, + }, + output_tokens: 12, + }, stop_reason: 'end_turn', }) @@ -178,6 +192,7 @@ describe('createAnthropicStreamingToolLoopStream', () => { tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], } as any, messages: [{ role: 'user', content: 'Weather?' }], + providerId: 'anthropic', logger, timeSegments, onComplete, @@ -225,7 +240,9 @@ describe('createAnthropicStreamingToolLoopStream', () => { expect(onComplete.mock.calls[0][0].tokens).toEqual({ input: 142, output: 42, - total: 184, + total: 207, + cacheRead: 8, + cacheWrite: 15, }) expect(onComplete.mock.calls[0][0].content).toContain('68°F') expect(mockExecuteTool).toHaveBeenCalled() @@ -309,6 +326,7 @@ describe('createAnthropicStreamingToolLoopStream', () => { abortSignal: abortController.signal, } as any, messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', logger, timeSegments: [], onComplete: vi.fn(), @@ -338,4 +356,233 @@ describe('createAnthropicStreamingToolLoopStream', () => { status: 'cancelled', }) }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'message_start', + message: { usage: { input_tokens: 5, output_tokens: 0 } }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{}' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { output_tokens: 3 }, + }, + { type: 'message_stop' }, + ], + makeFinalMessage({ + content: [ + { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 5, output_tokens: 3 }, + }) + ) + ), + }, + } as any + const onComplete = vi.fn() + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ + tokens: expect.objectContaining({ input: 5, output: 3, total: 8 }), + }) + ) + }) + + it('finalizes truncated text when max_tokens is reached without a tool call', async () => { + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Partial answer' }, + }, + ], + makeFinalMessage({ + content: [{ type: 'text', text: 'Partial answer' }], + stop_reason: 'max_tokens', + }) + ) + ), + }, + } as any + const onComplete = vi.fn() + const timeSegments: TimeSegment[] = [] + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'x' }], + }, + request: { model: 'claude-sonnet-4-5' } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments, + onComplete, + }) + + await expect(collectEvents(stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Partial answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Partial answer', + tokens: expect.objectContaining({ input: 10, output: 20, total: 30 }), + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'max_tokens', + assistantContent: 'Partial answer', + }) + }) + + it('rejects a max_tokens turn containing a partial tool call', async () => { + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_partial', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"city":' }, + }, + ], + makeFinalMessage({ + content: [], + stop_reason: 'max_tokens', + }) + ) + ), + }, + } as any + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toMatchObject({ + message: 'Anthropic stream ended with stop_reason max_tokens', + }) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_partial', + name: 'get_weather', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_partial', + name: 'get_weather', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.ts b/apps/sim/providers/anthropic/streaming-tool-loop.ts index 6b604732c38..1a87ae4a3d8 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.ts @@ -12,27 +12,32 @@ import type Anthropic from '@anthropic-ai/sdk' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { BlockTokens, IterationToolCall } from '@/executor/types' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' import { checkForForcedToolUsage } from '@/providers/anthropic/utils' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { isAbortError, type StreamingToolLoopComplete, settleOpenTools, + terminateToolLoop, } from '@/providers/streaming-tool-loop-shared' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, TimeSegment } from '@/providers/types' -import { calculateCost, prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { prepareToolExecution, sumToolCosts } from '@/providers/utils' import { executeTool } from '@/tools' -/** - * Message params plus `output_format`, shared with `core.ts`: Sim's structured - * outputs ride the anthropic-beta header with a top-level `output_format` - * field, which the SDK does not model (it exposes `output_config.format`). - */ -export type AnthropicStreamingToolLoopPayload = Anthropic.Messages.MessageStreamParams & { - output_format?: { type: 'json_schema'; schema: Record } +export type AnthropicStreamingToolLoopPayload = Anthropic.Messages.MessageStreamParams + +type AnthropicStreamingToolLoopComplete = Omit & { + tokens: ReturnType } export interface CreateAnthropicStreamingToolLoopStreamOptions { @@ -40,33 +45,21 @@ export interface CreateAnthropicStreamingToolLoopStreamOptions { payload: AnthropicStreamingToolLoopPayload request: ProviderRequest messages: Anthropic.Messages.MessageParam[] + providerId: string logger: Logger /** Shared mutable segments; same array reference passed into createStreamingExecution. */ timeSegments: TimeSegment[] /** Forced tool names from prepareToolsWithUsageControl (may be empty). */ forcedTools?: string[] - onComplete: (result: StreamingToolLoopComplete) => void -} - -function buildSegmentTokens(usage: Anthropic.Messages.Usage): BlockTokens { - const input = usage.input_tokens ?? 0 - const output = usage.output_tokens ?? 0 - const cacheRead = usage.cache_read_input_tokens ?? 0 - const cacheWrite = usage.cache_creation_input_tokens ?? 0 - return { - input, - output, - total: input + output + cacheRead + cacheWrite, - ...(cacheRead > 0 && { cacheRead }), - ...(cacheWrite > 0 && { cacheWrite }), - } + onComplete: (result: AnthropicStreamingToolLoopComplete) => void } function enrichModelSegment( timeSegments: TimeSegment[], response: Anthropic.Messages.Message, textContent: string, - model: string + model: string, + providerId: string ): void { const thinkingBlocks = response.content.filter( (item): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => @@ -88,17 +81,10 @@ function enrichModelSegment( : {}, })) - const segmentTokens = response.usage ? buildSegmentTokens(response.usage) : undefined - let cost: { input: number; output: number; total: number } | undefined - if ( - segmentTokens && - typeof segmentTokens.input === 'number' && - typeof segmentTokens.output === 'number' - ) { - const useCached = (segmentTokens.cacheRead ?? 0) > 0 - const full = calculateCost(model, segmentTokens.input, segmentTokens.output, useCached) - cost = { input: full.input, output: full.output, total: full.total } - } + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, response.usage) + const segmentTokens = buildAnthropicUsageTokens(usage) + const segmentCost = buildAnthropicUsageCost(model, usage) enrichLastModelSegment(timeSegments, { assistantContent: textContent || undefined, @@ -106,8 +92,12 @@ function enrichModelSegment( toolCalls: toolCalls.length > 0 ? toolCalls : undefined, finishReason: response.stop_reason ?? undefined, tokens: segmentTokens, - cost, - provider: 'anthropic', + cost: { + input: segmentCost.input, + output: segmentCost.output, + total: segmentCost.total, + }, + provider: providerId, }) } @@ -117,8 +107,19 @@ function enrichModelSegment( export function createAnthropicStreamingToolLoopStream( options: CreateAnthropicStreamingToolLoopStreamOptions ): ReadableStream { - const { anthropic, payload, request, messages, logger, timeSegments, onComplete } = options + const { anthropic, payload, request, messages, providerId, logger, timeSegments, onComplete } = + options const forcedToolNames = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeMessageStream: { abort: () => void } | undefined + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } return new ReadableStream({ async start(controller) { @@ -134,17 +135,32 @@ export function createAnthropicStreamingToolLoopStream( let modelTime = 0 let toolsTime = 0 let firstResponseTime = 0 - const tokens = { input: 0, output: 0, total: 0 } + const usage = createAnthropicUsageAccumulator() const toolCalls: unknown[] = [] const toolResults: Record[] = [] /** Tools that received start but not yet end (abort settlement). */ const openToolStarts = new Map() - const streamOptions = request.abortSignal ? { signal: request.abortSignal } : undefined + const streamOptions = { signal: loopAbortController.signal } + const reportProgress = () => { + const tokens = buildAnthropicUsageTokens(usage) + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: buildAnthropicUsageCost(request.model, usage, toolCost), + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (request.abortSignal?.aborted) { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { const abortErr = new DOMException('Stream aborted', 'AbortError') settleOpenTools(controller, openToolStarts, 'cancelled') throw abortErr @@ -154,12 +170,17 @@ export function createAnthropicStreamingToolLoopStream( ...payload, messages: currentMessages, } + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS // Streaming tool loop always streams each turn; never pass stream:true twice. ;(turnPayload as { stream?: boolean }).stream = undefined + if (finalSynthesis) { + turnPayload.tool_choice = { type: 'none' } + } // Forced tool_choice vs thinking — same rules as silent loop. const thinkingEnabled = !!payload.thinking if ( + !finalSynthesis && !thinkingEnabled && typeof originalToolChoice === 'object' && hasUsedForcedTool && @@ -172,6 +193,7 @@ export function createAnthropicStreamingToolLoopStream( turnPayload.tool_choice = undefined } } else if ( + !finalSynthesis && !thinkingEnabled && hasUsedForcedTool && typeof originalToolChoice === 'object' @@ -181,21 +203,12 @@ export function createAnthropicStreamingToolLoopStream( const modelStart = Date.now() const messageStream = anthropic.messages.stream(turnPayload, streamOptions) + activeMessageStream = messageStream const textChunks: string[] = [] - let inputTokens = 0 - let outputTokens = 0 try { for await (const event of messageStream) { - if (event.type === 'message_start') { - inputTokens = event.message.usage?.input_tokens ?? 0 - continue - } - if (event.type === 'message_delta') { - outputTokens = event.usage?.output_tokens ?? outputTokens - continue - } if (event.type === 'content_block_start') { const block = event.content_block if (block.type === 'tool_use' && block.id && block.name) { @@ -225,6 +238,7 @@ export function createAnthropicStreamingToolLoopStream( } const finalMessage = await messageStream.finalMessage() + activeMessageStream = undefined const modelEnd = Date.now() const thisModelTime = modelEnd - modelStart modelTime += thisModelTime @@ -241,12 +255,7 @@ export function createAnthropicStreamingToolLoopStream( duration: thisModelTime, }) - // Prefer finalMessage.usage when present (includes cache fields). - const turnInput = finalMessage.usage?.input_tokens ?? inputTokens - const turnOutput = finalMessage.usage?.output_tokens ?? outputTokens - tokens.input += turnInput - tokens.output += turnOutput - tokens.total += turnInput + turnOutput + addAnthropicUsage(usage, finalMessage.usage) const textContent = finalMessage.content .filter((item): item is Anthropic.Messages.TextBlock => item.type === 'text') @@ -264,13 +273,28 @@ export function createAnthropicStreamingToolLoopStream( */ const toolsExecutable = finalMessage.stop_reason === 'tool_use' if (toolUses.length > 0 && !toolsExecutable) { - logger.warn('Skipping tool execution for incomplete turn', { - stopReason: finalMessage.stop_reason, - toolCount: toolUses.length, - }) settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Anthropic returned tool use with stop_reason ${finalMessage.stop_reason ?? 'missing'}` + ) + } + if (finalSynthesis && toolUses.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Anthropic returned tool use during final synthesis') } const executableToolUses = toolsExecutable ? toolUses : [] + const cappedTextTurn = + finalMessage.stop_reason === 'max_tokens' && openToolStarts.size === 0 + if ( + executableToolUses.length === 0 && + finalMessage.stop_reason !== 'end_turn' && + finalMessage.stop_reason !== 'stop_sequence' && + !cappedTextTurn + ) { + throw new Error( + `Anthropic stream ended with stop_reason ${finalMessage.stop_reason ?? 'missing'}` + ) + } const turnTag = executableToolUses.length > 0 ? 'intermediate' : 'final' // If the SDK assembled text but we somehow missed deltas, still emit it @@ -279,13 +303,9 @@ export function createAnthropicStreamingToolLoopStream( controller.enqueue({ type: 'text_delta', text: textContent, turn: 'pending' }) } controller.enqueue({ type: 'turn_end', turn: turnTag }) - if (textChunks.length > 0 || textContent) { - // Streamed deltas are the answer bytes; fall back to assembled text. - // Intermediate text is kept so a MAX_TOOL_ITERATIONS exit still has content. - content = textChunks.length > 0 ? textChunks.join('') : textContent - } + content = textChunks.length > 0 ? textChunks.join('') : textContent - enrichModelSegment(timeSegments, finalMessage, textContent, request.model) + enrichModelSegment(timeSegments, finalMessage, textContent, request.model, providerId) const forcedCheck = checkForForcedToolUsage( finalMessage, @@ -310,12 +330,15 @@ export function createAnthropicStreamingToolLoopStream( executableToolUses.map(async (toolUse) => { const toolCallStartTime = Date.now() const toolName = toolUse.name - const toolArgs = (toolUse.input ?? {}) as Record + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined try { - if (request.abortSignal?.aborted) { + if (loopAbortController.signal.aborted) { throw new DOMException('Stream aborted', 'AbortError') } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -350,7 +373,7 @@ export function createAnthropicStreamingToolLoopStream( request ) const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, + signal: loopAbortController.signal, }) const toolCallEndTime = Date.now() const value = { @@ -374,14 +397,32 @@ export function createAnthropicStreamingToolLoopStream( return value } catch (error) { const toolCallEndTime = Date.now() - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - if (!cancelled) { - logger.error('Error processing tool call:', { error, toolName }) + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'cancelled', + }) + throw error } + if (isAbortError(error)) { + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) const value = { toolUse, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams: {} as Record, result: { success: false as const, @@ -391,7 +432,7 @@ export function createAnthropicStreamingToolLoopStream( startTime: toolCallStartTime, endTime: toolCallEndTime, duration: toolCallEndTime - toolCallStartTime, - status: (cancelled ? 'cancelled' : 'error') as ToolCallEndStatus, + status: 'error' as ToolCallEndStatus, } openToolStarts.delete(toolUse.id) controller.enqueue({ @@ -405,7 +446,6 @@ export function createAnthropicStreamingToolLoopStream( }) ) - const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] for (const value of orderedResults) { @@ -430,9 +470,11 @@ export function createAnthropicStreamingToolLoopStream( }) let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output as Record) - resultContent = result.output + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -451,36 +493,32 @@ export function createAnthropicStreamingToolLoopStream( success: result.success, }) - toolUseBlocks.push({ - type: 'tool_use', - id: toolUse.id, - name: toolName, - input: toolArgs, - }) - toolResultBlocks.push({ type: 'tool_result', tool_use_id: toolUse.id, content: JSON.stringify(resultContent), + is_error: !result.success, }) } - const thinkingBlocks = finalMessage.content.filter( + const assistantBlocks = finalMessage.content.filter( ( item ): item is + | Anthropic.Messages.TextBlock | Anthropic.Messages.ThinkingBlock - | Anthropic.Messages.RedactedThinkingBlock => - item.type === 'thinking' || item.type === 'redacted_thinking' + | Anthropic.Messages.RedactedThinkingBlock + | Anthropic.Messages.ToolUseBlock => + item.type === 'text' || + item.type === 'thinking' || + item.type === 'redacted_thinking' || + item.type === 'tool_use' ) - if (toolUseBlocks.length > 0) { + if (assistantBlocks.some((item) => item.type === 'tool_use')) { currentMessages.push({ role: 'assistant', - content: [ - ...thinkingBlocks, - ...toolUseBlocks, - ] as Anthropic.Messages.ContentBlockParam[], + content: assistantBlocks, }) } if (toolResultBlocks.length > 0) { @@ -493,52 +531,49 @@ export function createAnthropicStreamingToolLoopStream( toolsTime += Date.now() - toolsStartTime iterationCount++ - if (request.abortSignal?.aborted) { + if (loopAbortController.signal.aborted) { settleOpenTools(controller, openToolStarts, 'cancelled') throw new DOMException('Stream aborted', 'AbortError') } } catch (error) { - settleOpenTools(controller, openToolStarts, isAbortError(error) ? 'cancelled' : 'error') + settleOpenTools( + controller, + openToolStarts, + loopAbortController.signal.aborted ? 'cancelled' : 'error' + ) throw error } } - /** - * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the - * answer channel would otherwise be empty. Flush the last turn's text - * as the final answer so legacy consumers still receive content. - */ - if (!sawFinalTurn && content) { - controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + if (!sawFinalTurn) { + throw new Error('Anthropic tool loop ended without a final response') } - const modelCost = calculateCost(request.model, tokens.input, tokens.output) - const toolCostTotal = sumToolCosts(toolResults) - const cost = { - input: modelCost.input, - output: modelCost.output, - total: modelCost.total + (toolCostTotal || 0), - ...(toolCostTotal ? { toolCost: toolCostTotal } : {}), - } - - onComplete({ - content, - tokens, - cost, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - modelTime, - toolsTime, - firstResponseTime, - iterations: modelCalls, - }) - + reportProgress() controller.close() } catch (error) { - const cancelled = isAbortError(error) - settleOpenTools(controller, openToolStarts, cancelled ? 'cancelled' : 'error') - controller.error(toError(error)) + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Anthropic streaming tool loop failed', { + error: toError(cause).message, + }), + }) + } finally { + activeMessageStream = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) } }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + activeMessageStream?.abort() + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, }) } diff --git a/apps/sim/providers/anthropic/usage.test.ts b/apps/sim/providers/anthropic/usage.test.ts new file mode 100644 index 00000000000..5ee1fd12c77 --- /dev/null +++ b/apps/sim/providers/anthropic/usage.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' + +const MODEL = 'claude-sonnet-4-5' + +describe('Anthropic usage aggregation', () => { + it('prices uncached input and output normally', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { input_tokens: 1_000_000, output_tokens: 1_000_000 }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 1_000_000, + output: 1_000_000, + total: 2_000_000, + cacheRead: 0, + cacheWrite: 0, + }) + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3, + output: 15, + total: 18, + }) + }) + + it('prices cache reads at cached-input rates without discounting uncached input', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 1_000_000, + cache_read_input_tokens: 1_000_000, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3.3, + output: 0, + total: 3.3, + }) + }) + + it('prices cache writes without details at the default five-minute tier', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3.75, + total: 3.75, + }) + }) + + it('prices one-hour cache writes at twice the normal input rate', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 1_000_000, + }, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 6, + total: 6, + }) + }) + + it('aggregates mixed uncached, read, five-minute, one-hour, and output usage', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 100_000, + cache_read_input_tokens: 200_000, + cache_creation_input_tokens: 300_000, + cache_creation: { + ephemeral_5m_input_tokens: 100_000, + ephemeral_1h_input_tokens: 200_000, + }, + output_tokens: 400_000, + }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 100_000, + output: 400_000, + total: 1_000_000, + cacheRead: 200_000, + cacheWrite: 300_000, + }) + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 1.935, + output: 6, + total: 7.935, + }) + }) + + it('accumulates each stream turn exactly once', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + output_tokens: 40, + }) + addAnthropicUsage(usage, { + input_tokens: 1, + cache_read_input_tokens: 2, + cache_creation_input_tokens: 3, + output_tokens: 4, + }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 11, + output: 44, + total: 110, + cacheRead: 22, + cacheWrite: 33, + }) + }) +}) diff --git a/apps/sim/providers/anthropic/usage.ts b/apps/sim/providers/anthropic/usage.ts new file mode 100644 index 00000000000..5f40f8b5408 --- /dev/null +++ b/apps/sim/providers/anthropic/usage.ts @@ -0,0 +1,145 @@ +import type { BlockTokens } from '@/executor/types' +import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy' +import type { ModelPricing } from '@/providers/types' + +export interface AnthropicUsageLike { + input_tokens?: number | null + output_tokens?: number | null + cache_read_input_tokens?: number | null + cache_creation_input_tokens?: number | null + cache_creation?: { + ephemeral_5m_input_tokens?: number | null + ephemeral_1h_input_tokens?: number | null + } | null +} + +export interface AnthropicUsageAccumulator { + input: number + output: number + cacheRead: number + cacheWriteFiveMinute: number + cacheWriteOneHour: number +} + +interface AnthropicUsageCost { + input: number + output: number + total: number + toolCost?: number + pricing: ModelPricing +} + +function tokenCount(value: number | null | undefined): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} + +function roundedCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Creates an empty accumulator for one Anthropic provider request. + */ +export function createAnthropicUsageAccumulator(): AnthropicUsageAccumulator { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWriteFiveMinute: 0, + cacheWriteOneHour: 0, + } +} + +/** + * Adds one Anthropic model response's usage without counting cache tokens as uncached input. + */ +export function addAnthropicUsage( + accumulator: AnthropicUsageAccumulator, + usage: AnthropicUsageLike | null | undefined +): void { + if (!usage) return + + accumulator.input += tokenCount(usage.input_tokens) + accumulator.output += tokenCount(usage.output_tokens) + accumulator.cacheRead += tokenCount(usage.cache_read_input_tokens) + + const cacheWriteTotal = tokenCount(usage.cache_creation_input_tokens) + if (!usage.cache_creation) { + accumulator.cacheWriteFiveMinute += cacheWriteTotal + return + } + + const fiveMinute = tokenCount(usage.cache_creation.ephemeral_5m_input_tokens) + const oneHour = tokenCount(usage.cache_creation.ephemeral_1h_input_tokens) + const detailedTotal = fiveMinute + oneHour + + accumulator.cacheWriteFiveMinute += fiveMinute + Math.max(0, cacheWriteTotal - detailedTotal) + accumulator.cacheWriteOneHour += oneHour +} + +/** + * Builds the block token shape, including cache reads and writes in the total. + */ +export function buildAnthropicUsageTokens( + accumulator: AnthropicUsageAccumulator +): Required> { + const cacheWrite = accumulator.cacheWriteFiveMinute + accumulator.cacheWriteOneHour + return { + input: accumulator.input, + output: accumulator.output, + total: accumulator.input + accumulator.output + accumulator.cacheRead + cacheWrite, + cacheRead: accumulator.cacheRead, + cacheWrite, + } +} + +/** 5-minute cache writes cost 1.25x the base input rate, 1-hour writes 2x. */ +const FIVE_MINUTE_WRITE_MULTIPLIER = 1.25 +const ONE_HOUR_WRITE_MULTIPLIER = 2 + +/** + * Builds the normalized usage for one Anthropic request. + * + * Anthropic reports `input_tokens` already excluding cache reads and writes + * (`total_input = cache_read + cache_creation + input_tokens`), so `input` maps + * across directly — unlike OpenAI and Gemini, whose cached counts are subsets + * of their prompt totals and must be subtracted. + */ +export function buildAnthropicModelUsage(accumulator: AnthropicUsageAccumulator): ModelUsage { + return { + input: accumulator.input, + output: accumulator.output, + cacheRead: accumulator.cacheRead, + cacheWrites: [ + { + tokens: accumulator.cacheWriteFiveMinute, + inputRateMultiplier: FIVE_MINUTE_WRITE_MULTIPLIER, + }, + { tokens: accumulator.cacheWriteOneHour, inputRateMultiplier: ONE_HOUR_WRITE_MULTIPLIER }, + ], + } +} + +/** + * Prices one Anthropic request, cache tiers included, through the shared + * pricing function. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function buildAnthropicUsageCost( + model: string, + accumulator: AnthropicUsageAccumulator, + toolCost = 0 +): AnthropicUsageCost { + const cost = priceModelUsage(model, buildAnthropicModelUsage(accumulator), LIST_PRICE_POLICY) + + return { + input: cost.input, + output: cost.output, + total: roundedCost(cost.total + toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: cost.pricing, + } +} diff --git a/apps/sim/providers/anthropic/utils.test.ts b/apps/sim/providers/anthropic/utils.test.ts index 7d2fb8d4e4e..6c284067bdd 100644 --- a/apps/sim/providers/anthropic/utils.test.ts +++ b/apps/sim/providers/anthropic/utils.test.ts @@ -60,7 +60,50 @@ describe('createReadableStreamFromAnthropicStream', () => { expect(onComplete.mock.calls[0][0]).toMatchObject({ content: anthropicThinkingTextToolExpectedText, thinking: anthropicThinkingTextToolExpectedThinking, - usage: { input_tokens: 42, output_tokens: expect.any(Number) }, + usage: { input: 42, output: expect.any(Number) }, + }) + }) + + it('captures stream cache usage once from cumulative message events', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield { + type: 'message_start', + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + cache_creation: { + ephemeral_5m_input_tokens: 10, + ephemeral_1h_input_tokens: 20, + }, + }, + }, + } + yield { + type: 'message_delta', + usage: { + input_tokens: 10, + output_tokens: 40, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + }, + } + })() as AsyncIterable, + onComplete + ) + + await collectEvents(stream) + + expect(onComplete.mock.calls[0][0].usage).toEqual({ + input: 10, + output: 40, + cacheRead: 20, + cacheWriteFiveMinute: 10, + cacheWriteOneHour: 20, }) }) diff --git a/apps/sim/providers/anthropic/utils.ts b/apps/sim/providers/anthropic/utils.ts index c9fcb89ac90..ad6ac2958f9 100644 --- a/apps/sim/providers/anthropic/utils.ts +++ b/apps/sim/providers/anthropic/utils.ts @@ -1,19 +1,19 @@ import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' import { createLogger } from '@sim/logger' -import { randomFloat } from '@sim/utils/random' +import { + type AnthropicUsageAccumulator, + type AnthropicUsageLike, + addAnthropicUsage, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' const logger = createLogger('AnthropicUtils') -export interface AnthropicStreamUsage { - input_tokens: number - output_tokens: number -} - export interface AnthropicStreamComplete { content: string - usage: AnthropicStreamUsage + usage: AnthropicUsageAccumulator /** Assembled thinking text for traces (redacted blocks become `[redacted]`). */ thinking: string } @@ -28,14 +28,16 @@ export function createReadableStreamFromAnthropicStream( anthropicStream: AsyncIterable, onComplete?: (result: AnthropicStreamComplete) => void ): ReadableStream { + let cancelled = false + let streamIterator: AsyncIterator | undefined + return new ReadableStream({ async start(controller) { try { let fullContent = '' const thinkingBlocks: string[] = [] let currentThinking = '' - let inputTokens = 0 - let outputTokens = 0 + let usageSnapshot: AnthropicUsageLike = {} const flushThinkingBlock = () => { if (currentThinking) { @@ -44,14 +46,27 @@ export function createReadableStreamFromAnthropicStream( } } - for await (const event of anthropicStream) { + streamIterator = anthropicStream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const event = next.value if (event.type === 'message_start') { - inputTokens = event.message.usage.input_tokens + usageSnapshot = event.message.usage continue } if (event.type === 'message_delta') { - outputTokens = event.usage.output_tokens + usageSnapshot = { + ...usageSnapshot, + input_tokens: event.usage.input_tokens ?? usageSnapshot.input_tokens, + output_tokens: event.usage.output_tokens ?? usageSnapshot.output_tokens, + cache_read_input_tokens: + event.usage.cache_read_input_tokens ?? usageSnapshot.cache_read_input_tokens, + cache_creation_input_tokens: + event.usage.cache_creation_input_tokens ?? + usageSnapshot.cache_creation_input_tokens, + } continue } @@ -89,29 +104,33 @@ export function createReadableStreamFromAnthropicStream( } } + if (cancelled) return flushThinkingBlock() if (onComplete) { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, usageSnapshot) onComplete({ content: fullContent, - usage: { input_tokens: inputTokens, output_tokens: outputTokens }, - // Match enrichLastModelSegmentFromAnthropicResponse: join blocks with blank lines. + usage, thinking: thinkingBlocks.filter(Boolean).join('\n\n'), }) } controller.close() } catch (err) { - controller.error(err) + if (!cancelled) { + controller.error(err) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } -export function generateToolUseId(toolName: string): string { - return `${toolName}-${Date.now()}-${randomFloat().toString(36).substring(2, 7)}` -} - export function checkForForcedToolUsage( response: any, toolChoice: any, diff --git a/apps/sim/providers/azure-anthropic/index.test.ts b/apps/sim/providers/azure-anthropic/index.test.ts index 0956aa0dac8..6ca390ca2b9 100644 --- a/apps/sim/providers/azure-anthropic/index.test.ts +++ b/apps/sim/providers/azure-anthropic/index.test.ts @@ -56,7 +56,7 @@ function request(overrides: Partial): ProviderRequest { /** Invokes the createClient factory handed to the Anthropic core and returns the SDK options it built. */ function buildClientOptions(): Record { const config = mockExecuteAnthropic.mock.calls[0][1] - config.createClient('k', false) + config.createClient('k') return anthropicArgs[0] } @@ -92,6 +92,18 @@ describe('azureAnthropicProvider — SSRF pinning', () => { expect(buildClientOptions()).not.toHaveProperty('fetch') }) + it('keeps the registry model in core and resolves a separate Azure wire model', async () => { + setEnv({ AZURE_ANTHROPIC_ENDPOINT: 'https://identity.services.ai.azure.com' }) + const providerRequest = request({}) + + await azureAnthropicProvider.executeRequest(providerRequest) + + const [forwardedRequest, config] = mockExecuteAnthropic.mock.calls[0] + expect(forwardedRequest.model).toBe('azure-anthropic/claude-3-5-sonnet') + expect(config.resolveWireModel(forwardedRequest)).toBe('claude-3-5-sonnet') + expect(buildClientOptions().defaultHeaders).not.toHaveProperty('anthropic-beta') + }) + it('throws and never builds a client when validation blocks the endpoint', async () => { mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) diff --git a/apps/sim/providers/azure-anthropic/index.ts b/apps/sim/providers/azure-anthropic/index.ts index 2f0498992a0..fe7881755db 100644 --- a/apps/sim/providers/azure-anthropic/index.ts +++ b/apps/sim/providers/azure-anthropic/index.ts @@ -52,53 +52,40 @@ export const azureAnthropicProvider: ProviderConfig = { throw new Error('API key is required for Azure Anthropic.') } - // Strip the azure-anthropic/ prefix from the model name if present - const modelName = request.model.replace(/^azure-anthropic\//, '') - - // Azure AI Foundry hosts Anthropic models at {endpoint}/anthropic - // The SDK appends /v1/messages automatically - const baseURL = `${azureEndpoint.replace(/\/$/, '')}/anthropic` + const normalizedEndpoint = azureEndpoint.replace(/\/$/, '') + const baseURL = normalizedEndpoint.endsWith('/anthropic') + ? normalizedEndpoint + : `${normalizedEndpoint}/anthropic` const anthropicVersion = request.azureApiVersion || env.AZURE_ANTHROPIC_API_VERSION || '2023-06-01' - return executeAnthropicProviderRequest( - { - ...request, - model: modelName, - apiKey, + return executeAnthropicProviderRequest(request, { + providerId: 'azure-anthropic', + providerLabel: 'Azure Anthropic', + resolveWireModel: ({ model }) => model.replace(/^azure-anthropic\//, ''), + createClient: (apiKey) => { + const cacheKey = [ + 'azure-anthropic', + apiKey, + baseURL, + anthropicVersion, + pinnedIP ?? 'no-pin', + ].join('::') + return getCachedProviderClient( + cacheKey, + () => + new Anthropic({ + baseURL, + apiKey, + ...(pinnedFetch ? { fetch: pinnedFetch } : {}), + defaultHeaders: { + 'anthropic-version': anthropicVersion, + }, + }) + ) }, - { - providerId: 'azure-anthropic', - providerLabel: 'Azure Anthropic', - createClient: (apiKey, useNativeStructuredOutputs) => { - const cacheKey = [ - 'azure-anthropic', - apiKey, - baseURL, - anthropicVersion, - pinnedIP ?? 'no-pin', - useNativeStructuredOutputs ? 'beta' : 'default', - ].join('::') - return getCachedProviderClient( - cacheKey, - () => - new Anthropic({ - baseURL, - apiKey, - ...(pinnedFetch ? { fetch: pinnedFetch } : {}), - defaultHeaders: { - 'api-key': apiKey, - 'anthropic-version': anthropicVersion, - ...(useNativeStructuredOutputs - ? { 'anthropic-beta': 'structured-outputs-2025-11-13' } - : {}), - }, - }) - ) - }, - logger, - } - ) + logger, + }) }, } diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index f58c72ddfd6..44310504746 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -3,7 +3,8 @@ */ import { resetEnvMock, setEnv } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProviderRequest } from '@/providers/types' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderRequest, ProviderToolConfig } from '@/providers/types' const { mockAzureOpenAI, @@ -15,6 +16,8 @@ const { sentinelFetch, mockIsChatCompletionsEndpoint, mockIsResponsesEndpoint, + mockPrepareTools, + mockExecuteTool, } = vi.hoisted(() => { const azureOpenAIArgs: Array> = [] const sentinelFetch = vi.fn() @@ -35,6 +38,8 @@ const { sentinelFetch, mockIsChatCompletionsEndpoint: vi.fn(() => false), mockIsResponsesEndpoint: vi.fn(() => false), + mockPrepareTools: vi.fn(), + mockExecuteTool: vi.fn(), } }) @@ -73,14 +78,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), - prepareToolsWithUsageControl: vi.fn(() => ({ - tools: [], - toolChoice: undefined, - forcedTools: [], - })), + prepareToolsWithUsageControl: mockPrepareTools, sumToolCosts: vi.fn(() => 0), })) -vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { azureOpenAIProvider } from '@/providers/azure-openai/index' @@ -88,6 +89,26 @@ function request(overrides: Partial): ProviderRequest { return { model: 'azure/gpt-4o', apiKey: 'k', messages: [], ...overrides } } +function makeTool(id: string): ProviderToolConfig { + return { + id, + name: id, + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + /** Config object passed to the Responses core on the Nth call. */ const responsesConfig = (call = 0) => mockExecuteResponses.mock.calls[call][1] @@ -101,6 +122,12 @@ describe('azureOpenAIProvider — SSRF pinning', () => { mockIsChatCompletionsEndpoint.mockReturnValue(false) mockIsResponsesEndpoint.mockReturnValue(false) mockExecuteResponses.mockResolvedValue({ content: 'ok' }) + mockPrepareTools.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + }) + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) describe('Responses API path', () => { @@ -188,5 +215,61 @@ describe('azureOpenAIProvider — SSRF pinning', () => { expect(mockCreatePinnedFetch).not.toHaveBeenCalled() expect(azureOpenAIArgs[0]).not.toHaveProperty('fetch') }) + + it('projects the settled tool-loop answer without a final streaming request', async () => { + mockIsChatCompletionsEndpoint.mockReturnValue(true) + mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) + mockPrepareTools.mockReturnValue({ + tools: [{ type: 'function', function: { name: 'lookup' } }], + toolChoice: 'auto', + forcedTools: [], + }) + mockChatCreate + .mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + }) + .mockResolvedValueOnce({ + choices: [{ message: { content: 'done', tool_calls: undefined } }], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }) + + const result = await azureOpenAIProvider.executeRequest( + request({ + azureEndpoint: 'https://rebind.attacker.tld/openai/deployments/gpt-4o/chat/completions', + stream: true, + tools: [makeTool('lookup')], + }) + ) + + expect(mockChatCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 6, output: 3, total: 9 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'done', turn: 'final' }]) + }) }) }) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 9280f8d0ee4..14c83fdd34c 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { AzureOpenAI } from 'openai' import type { ChatCompletion, @@ -27,7 +28,9 @@ import { } from '@/providers/azure-openai/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { executeResponsesProviderRequest } from '@/providers/openai/core' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -302,10 +305,25 @@ async function executeChatCompletionsRequest( const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -323,6 +341,9 @@ async function executeChatCompletionsRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -342,7 +363,7 @@ async function executeChatCompletionsRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -357,11 +378,9 @@ async function executeChatCompletionsRequest( })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -371,10 +390,12 @@ async function executeChatCompletionsRequest( duration: duration, }) - let resultContent: Record + let resultContent: unknown if (result.success) { - toolResults.push(result.output as Record) - resultContent = result.output as Record + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -472,28 +493,56 @@ async function executeChatCompletionsRequest( iterationCount++ } - if (request.stream) { - logger.info('Using streaming for final response after tool processing') - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - + if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path. + * The capped turn still requests tools, so make one tool-disabled call to + * synthesize an answer from the tool results already gathered. */ - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await azureOpenAI.chat.completions.create( - streamingParams, + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = (await azureOpenAI.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, request.abortSignal ? { signal: request.abortSignal } : undefined + )) as ChatCompletion + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'azure_openai' } ) + } - const streamingResult = createStreamingExecution({ + if (request.stream) { + logger.info('Projecting settled response after tool processing') + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -502,7 +551,7 @@ async function executeChatCompletionsRequest( modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -513,7 +562,8 @@ async function executeChatCompletionsRequest( initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -523,36 +573,12 @@ async function executeChatCompletionsRequest( } : undefined, streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAzureOpenAIStream(streamResponse, (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('Azure OpenAI final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - - finalizeTiming() - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -572,7 +598,7 @@ async function executeChatCompletionsRequest( modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -586,6 +612,10 @@ async function executeChatCompletionsRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index df296c6626e..d9e450ece2f 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/baseten/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,16 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = ( + assistant: { content?: string | null; reasoning_content?: string } = {} +) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning_content !== undefined + ? { reasoning_content: assistant.reasoning_content } + : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -224,15 +232,54 @@ describe('basetenProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Baseten assistant content and reasoning_content on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await basetenProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await basetenProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await basetenProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index a5f59d818bb..879efabd697 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -11,7 +12,10 @@ import { supportsNativeStructuredOutputs, } from '@/providers/baseten/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -263,10 +267,25 @@ export const basetenProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -284,6 +303,9 @@ export const basetenProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Baseten):', { error: toError(error).message, @@ -306,26 +328,21 @@ export const basetenProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -336,10 +353,12 @@ export const basetenProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -417,85 +436,61 @@ export const basetenProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'baseten' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'baseten', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'baseten' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -551,6 +546,45 @@ export const basetenProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -568,7 +602,7 @@ export const basetenProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -591,6 +625,10 @@ export const basetenProvider: ProviderConfig = { } logger.error('Error in Baseten request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index 3a9abceefbd..17d593d048c 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -22,6 +22,9 @@ vi.mock('@/providers/bedrock/utils', () => ({ checkForForcedToolUsage: vi.fn(), createReadableStreamFromBedrockStream: vi.fn(), generateToolUseId: vi.fn().mockReturnValue('tool-1'), + getBedrockStreamError: vi.fn().mockReturnValue(null), + // The mocked inference profile above is a Claude model, which supports it. + supportsToolResultStatus: vi.fn().mockReturnValue(true), })) vi.mock('@/providers/models', () => ({ @@ -31,11 +34,15 @@ vi.mock('@/providers/models', () => ({ INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('us.anthropic.claude-3-5-sonnet-20241022-v2:0'), + supportsNativeStructuredOutputs: vi.fn().mockReturnValue(false), })) vi.mock('@/providers/utils', () => ({ calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0, pricing: null }), - prepareToolExecution: vi.fn(), + prepareToolExecution: vi.fn((_tool, args) => ({ + toolParams: args, + executionParams: args, + })), prepareToolsWithUsageControl: vi.fn().mockReturnValue({ tools: [], toolChoice: 'auto', @@ -45,12 +52,14 @@ vi.mock('@/providers/utils', () => ({ })) vi.mock('@/tools', () => ({ - executeTool: vi.fn(), + executeTool: vi.fn().mockResolvedValue({ success: true, output: false }), })) -import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime' +import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime' +import type { StreamingExecution } from '@/executor/types' import { bedrockProvider } from '@/providers/bedrock/index' import { clearProviderClientCacheForTests } from '@/providers/client-cache' +import { prepareToolsWithUsageControl } from '@/providers/utils' describe('bedrockProvider credential handling', () => { beforeEach(() => { @@ -120,4 +129,188 @@ describe('bedrockProvider credential handling', () => { region: 'eu-west-1', }) }) + + it('uses the live loop for streaming tool requests without a caller flag', async () => { + vi.mocked(prepareToolsWithUsageControl).mockReturnValueOnce({ + tools: [ + { + name: 'lookup', + description: 'Lookup', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + mockSend + .mockResolvedValueOnce({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { + toolUse: { + toolUseId: 'tool-1', + name: 'lookup', + }, + }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{}' } }, + }, + } + yield { metadata: { usage: { inputTokens: 1, outputTokens: 1 } } } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + }) + .mockResolvedValueOnce({ + stream: (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'settled answer' }, + }, + } + yield { metadata: { usage: { inputTokens: 2, outputTokens: 2 } } } + yield { messageStop: { stopReason: 'end_turn' } } + })(), + }) + + const result = (await bedrockProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + })) as StreamingExecution + + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + + expect(mockSend).toHaveBeenCalledTimes(2) + expect(result.execution.output.content).toBe('settled answer') + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + }) + + it('keeps the explicit structured-output extraction call before settled projection', async () => { + vi.mocked(prepareToolsWithUsageControl).mockReturnValueOnce({ + tools: [ + { + name: 'lookup', + description: 'Lookup', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + mockSend + .mockResolvedValueOnce({ + output: { + message: { + content: [ + { + toolUse: { + toolUseId: 'tool-1', + name: 'lookup', + input: {}, + }, + }, + ], + }, + }, + stopReason: 'tool_use', + usage: { inputTokens: 1, outputTokens: 1 }, + }) + .mockResolvedValueOnce({ + output: { message: { content: [{ text: 'unformatted answer' }] } }, + stopReason: 'end_turn', + usage: { inputTokens: 2, outputTokens: 2 }, + }) + .mockResolvedValueOnce({ + output: { + message: { + content: [ + { + toolUse: { + toolUseId: 'structured-1', + name: 'structured_output', + input: { answer: 'formatted' }, + }, + }, + ], + }, + }, + stopReason: 'tool_use', + usage: { inputTokens: 3, outputTokens: 3 }, + }) + + const result = (await bedrockProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + }, + }, + })) as StreamingExecution + + expect(mockSend).toHaveBeenCalledTimes(3) + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + expect(vi.mocked(ConverseCommand).mock.calls[2][0]).toMatchObject({ + toolConfig: { + tools: [ + { + toolSpec: { + name: 'structured_output', + }, + }, + ], + toolChoice: { tool: { name: 'structured_output' } }, + }, + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { + type: 'text_delta', + text: '{\n "answer": "formatted"\n}', + turn: 'final', + }, + }) + }) }) diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 1609080585d..544d823209b 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -7,6 +7,7 @@ import { ConverseCommand, type ConverseResponse, ConverseStreamCommand, + type OutputConfig, type SystemContentBlock, type Tool, type ToolConfiguration, @@ -15,6 +16,7 @@ import { } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { buildBedrockMessageContent } from '@/providers/attachments' @@ -24,10 +26,17 @@ import { createReadableStreamFromBedrockStream, generateToolUseId, getBedrockInferenceProfileId, + supportsToolResultStatus, } from '@/providers/bedrock/utils' import { getCachedProviderClient } from '@/providers/client-cache' -import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { + getProviderDefaultModel, + getProviderModels, + supportsNativeStructuredOutputs, +} from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -182,7 +191,7 @@ export const bedrockProvider: ProviderConfig = { const toolUseBlock: ToolUseBlock = { toolUseId: msg.tool_calls?.[0]?.id || generateToolUseId(toolCall.name), name: toolCall.name, - input: JSON.parse(toolCall.arguments), + input: parseToolArguments(toolCall.arguments, toolCall.name) as ToolUseBlock['input'], } messages.push({ role: 'assistant' as ConversationRole, @@ -210,23 +219,38 @@ export const bedrockProvider: ProviderConfig = { } let structuredOutputTool: Tool | undefined + let outputConfig: OutputConfig | undefined const structuredOutputToolName = 'structured_output' if (request.responseFormat) { const schema = request.responseFormat.schema || request.responseFormat const schemaName = request.responseFormat.name || 'response' - structuredOutputTool = { - toolSpec: { - name: structuredOutputToolName, - description: `Output the response as structured JSON matching the ${schemaName} schema. You MUST call this tool to provide your final response.`, - inputSchema: { - json: schema, + if (supportsNativeStructuredOutputs(request.model) && !request.tools?.length) { + outputConfig = { + textFormat: { + type: 'json_schema', + structure: { + jsonSchema: { + name: schemaName, + schema: JSON.stringify(schema), + }, + }, }, - }, + } + logger.info(`Using native structured outputs: ${schemaName}`) + } else { + structuredOutputTool = { + toolSpec: { + name: structuredOutputToolName, + description: `Output the response as structured JSON matching the ${schemaName} schema. You MUST call this tool to provide your final response.`, + inputSchema: { + json: schema, + }, + }, + } + logger.info(`Using tool-based structured outputs: ${schemaName}`) } - - logger.info(`Using Tool Use approach for structured outputs: ${schemaName}`) } let bedrockTools: Tool[] | undefined @@ -248,52 +272,56 @@ export const bedrockProvider: ProviderConfig = { }, })) - try { - preparedTools = prepareToolsWithUsageControl( - bedrockTools.map((t) => ({ - name: t.toolSpec?.name || '', - description: t.toolSpec?.description || '', - input_schema: t.toolSpec?.inputSchema?.json, - })), - request.tools, - logger, - 'bedrock' - ) - - const { tools: filteredTools, toolChoice: tc } = preparedTools + preparedTools = prepareToolsWithUsageControl( + bedrockTools.map((t) => ({ + name: t.toolSpec?.name || '', + description: t.toolSpec?.description || '', + input_schema: t.toolSpec?.inputSchema?.json, + })), + request.tools, + logger, + 'bedrock' + ) - if (filteredTools?.length) { - bedrockTools = filteredTools.map((t: any) => ({ + const { tools: filteredTools, toolChoice: preparedToolChoice } = preparedTools + bedrockTools = filteredTools?.length + ? filteredTools.map((tool) => ({ toolSpec: { - name: t.name, - description: t.description, - inputSchema: { json: t.input_schema }, + name: tool.name, + description: tool.description, + inputSchema: { json: tool.input_schema }, }, })) + : undefined - if (typeof tc === 'object' && tc !== null) { - if (tc.type === 'tool' && tc.name) { - toolChoice = { tool: { name: tc.name } } - logger.info(`Using Bedrock tool_choice format: force tool "${tc.name}"`) - } else if (tc.type === 'function' && tc.function?.name) { - toolChoice = { tool: { name: tc.function.name } } - logger.info(`Using Bedrock tool_choice format: force tool "${tc.function.name}"`) - } else if (tc.type === 'any') { - toolChoice = { any: {} } - logger.info('Using Bedrock tool_choice format: any tool') - } else { - toolChoice = { auto: {} } - } - } else if (tc === 'none') { - toolChoice = undefined - bedrockTools = undefined - } else { - toolChoice = { auto: {} } - } + if (bedrockTools?.length) { + if (preparedToolChoice === 'auto') { + toolChoice = { auto: {} } + } else if (preparedToolChoice === 'none') { + toolChoice = undefined + bedrockTools = undefined + } else if ( + preparedToolChoice?.type === 'tool' && + typeof preparedToolChoice.name === 'string' && + preparedToolChoice.name.length > 0 + ) { + toolChoice = { tool: { name: preparedToolChoice.name } } + logger.info(`Using Bedrock tool_choice format: force tool "${preparedToolChoice.name}"`) + } else if ( + preparedToolChoice?.type === 'function' && + typeof preparedToolChoice.function?.name === 'string' && + preparedToolChoice.function.name.length > 0 + ) { + toolChoice = { tool: { name: preparedToolChoice.function.name } } + logger.info( + `Using Bedrock tool_choice format: force tool "${preparedToolChoice.function.name}"` + ) + } else if (preparedToolChoice?.type === 'any') { + toolChoice = { any: {} } + logger.info('Using Bedrock tool_choice format: any tool') + } else { + throw new Error('Invalid Bedrock tool choice returned by tool preparation') } - } catch (error) { - logger.error('Error in prepareToolsWithUsageControl:', { error }) - toolChoice = { auto: {} } } } else if (structuredOutputTool) { bedrockTools = [structuredOutputTool] @@ -353,9 +381,9 @@ export const bedrockProvider: ProviderConfig = { * Bedrock rides a final forced `structured_output` tool call that only the * silent loop performs — so those requests fall back to the silent path. */ - const shouldStreamToolCalls = (request.streamToolCalls ?? false) && !request.responseFormat + const liveToolLoopSupported = !request.responseFormat - if (request.stream && shouldStreamToolCalls && bedrockTools && bedrockTools.length > 0) { + if (request.stream && liveToolLoopSupported && bedrockTools && bedrockTools.length > 0) { logger.info('Using streaming tool loop for Bedrock request') const providerStartTime = Date.now() @@ -420,6 +448,7 @@ export const bedrockProvider: ProviderConfig = { messages, system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, inferenceConfig, + outputConfig, }) const streamResponse = await client.send( @@ -478,6 +507,7 @@ export const bedrockProvider: ProviderConfig = { messages, system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, inferenceConfig, + outputConfig, toolConfig, }) @@ -488,7 +518,6 @@ export const bedrockProvider: ProviderConfig = { const firstResponseTime = Date.now() - initialCallTime let content = '' - let hasExtractedStructuredOutput = false if (currentResponse.output?.message?.content) { const structuredOutputCall = currentResponse.output.message.content.find( (block): block is ContentBlock & { toolUse: ToolUseBlock } => @@ -497,7 +526,6 @@ export const bedrockProvider: ProviderConfig = { if (structuredOutputCall && structuredOutputTool) { content = JSON.stringify(structuredOutputCall.toolUse.input, null, 2) - hasExtractedStructuredOutput = true logger.info('Extracted structured output from tool call') } else { const textBlocks = currentResponse.output.message.content.filter( @@ -581,7 +609,12 @@ export const bedrockProvider: ProviderConfig = { ) const currentToolUses = toolUseContentBlocks.map((block) => block.toolUse) - if (!currentToolUses || currentToolUses.length === 0) { + if (currentToolUses.length > 0 && currentResponse.stopReason !== 'tool_use') { + throw new Error( + `Bedrock returned tool use with stop reason ${currentResponse.stopReason ?? 'missing'}` + ) + } + if (currentToolUses.length === 0) { break } @@ -590,12 +623,35 @@ export const bedrockProvider: ProviderConfig = { const toolExecutionPromises = currentToolUses.map(async (toolUse: ToolUseBlock) => { const toolCallStartTime = Date.now() const toolName = toolUse.name || '' - const toolArgs = (toolUse.input as Record) || {} + const toolArgs = + toolUse.input && typeof toolUse.input === 'object' && !Array.isArray(toolUse.input) + ? (toolUse.input as Record) + : undefined const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) try { + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolUseId, + toolName, + toolArgs, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -606,7 +662,7 @@ export const bedrockProvider: ProviderConfig = { return { toolUseId, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams, result, startTime: toolCallStartTime, @@ -614,6 +670,9 @@ export const bedrockProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -634,15 +693,13 @@ export const bedrockProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) - const assistantContent: ContentBlock[] = currentToolUses.map((toolUse: ToolUseBlock) => ({ - toolUse: { - toolUseId: toolUse.toolUseId, - name: toolUse.name, - input: toolUse.input, - }, - })) + // Bedrock rejects a blank text block on replay even when it produced + // one itself, so drop whitespace-only text while echoing the rest. + const assistantContent: ContentBlock[] = ( + currentResponse.output?.message?.content ?? [] + ).filter((block) => !('text' in block) || Boolean(block.text?.trim())) currentMessages.push({ role: 'assistant' as ConversationRole, content: assistantContent, @@ -650,9 +707,7 @@ export const bedrockProvider: ProviderConfig = { const toolResultContent: ContentBlock[] = [] - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolUseId, toolName, @@ -662,7 +717,7 @@ export const bedrockProvider: ProviderConfig = { startTime, endTime, duration, - } = settledResult.value + } = executionResult timeSegments.push({ type: 'tool', @@ -672,10 +727,12 @@ export const bedrockProvider: ProviderConfig = { duration, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -697,6 +754,9 @@ export const bedrockProvider: ProviderConfig = { const toolResultBlock: ToolResultBlock = { toolUseId, content: [{ text: JSON.stringify(resultContent) }], + ...(supportsToolResultStatus(bedrockModelId) + ? { status: result.success ? 'success' : 'error' } + : {}), } toolResultContent.push({ toolResult: toolResultBlock }) } @@ -841,7 +901,6 @@ export const bedrockProvider: ProviderConfig = { if (structuredOutputCall) { content = JSON.stringify(structuredOutputCall.toolUse.input, null, 2) - hasExtractedStructuredOutput = true logger.info('Extracted structured output from forced tool call') } else { logger.warn('Structured output tool was forced but no tool call found in response') @@ -869,54 +928,9 @@ export const bedrockProvider: ProviderConfig = { const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - if (request.stream && !shouldStreamToolCalls && !hasExtractedStructuredOutput) { - logger.info('Using streaming for final Bedrock response after tool processing') - - const messagesHaveToolContent = currentMessages.some((msg) => - msg.content?.some( - (block) => - ('toolUse' in block && block.toolUse) || ('toolResult' in block && block.toolResult) - ) - ) - - const streamToolConfig: ToolConfiguration | undefined = - messagesHaveToolContent && request.tools?.length - ? { - tools: request.tools.map((tool) => ({ - toolSpec: { - name: tool.id, - description: tool.description, - inputSchema: { - json: { - type: 'object', - properties: tool.parameters.properties, - required: tool.parameters.required, - }, - }, - }, - })), - toolChoice: { auto: {} }, - } - : undefined - - const streamCommand = new ConverseStreamCommand({ - modelId: bedrockModelId, - messages: currentMessages, - system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, - inferenceConfig, - toolConfig: streamToolConfig, - }) - - const streamResponse = await client.send( - streamCommand, - request.abortSignal ? { abortSignal: request.abortSignal } : undefined - ) - - if (!streamResponse.stream) { - throw new Error('No stream returned from Bedrock') - } - - const bedrockStream = streamResponse.stream + if (request.stream && !liveToolLoopSupported) { + logger.info('Projecting settled Bedrock response after tool processing') + const toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, providerStartTime, @@ -926,49 +940,25 @@ export const bedrockProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, initialCost: { input: cost.input, output: cost.output, - toolCost: undefined as number | undefined, - total: cost.total, + toolCost: toolCost || undefined, + total: cost.total + toolCost, }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromBedrockStream(bedrockStream, (streamContent, usage) => { - /** - * Bedrock's ToolChoice has no `none`, and toolConfig is required - * when history carries toolUse blocks — the regeneration can - * re-call a tool that is never executed on this path. Keep the - * tool loop's settled answer when the stream ends without text. - */ - if (!streamContent && content) { - logger.warn('Bedrock final stream produced no text; keeping tool-loop answer') - } - output.content = streamContent || content - output.tokens = { - input: tokens.input + usage.inputTokens, - output: tokens.output + usage.outputTokens, - total: tokens.total + usage.inputTokens + usage.outputTokens, - } - - const streamCost = calculateCost(request.model, usage.inputTokens, usage.outputTokens) - const tc = sumToolCosts(toolResults) - output.cost = { - input: cost.input + streamCost.input, - output: cost.output + streamCost.output, - toolCost: tc || undefined, - total: cost.total + streamCost.total + tc, - } - - finalizeTiming() - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -1003,7 +993,7 @@ export const bedrockProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, } @@ -1017,6 +1007,10 @@ export const bedrockProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts index 40c66c772e6..cf18f0e334c 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import type { AgentStreamEvent } from '@/providers/stream-events' @@ -18,11 +18,12 @@ async function collectEvents( return events } +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + vi.mock('@/tools', () => ({ - executeTool: vi.fn(async () => ({ - success: true, - output: { ok: true }, - })), + executeTool: mockExecuteTool, })) vi.mock('@/providers/utils', () => ({ @@ -41,6 +42,14 @@ vi.mock('@/providers/utils', () => ({ })) describe('createBedrockStreamingToolLoopStream', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ + success: true, + output: { ok: true }, + }) + }) + it('emits tool_call_start/end and final text; no invented thinking', async () => { const turns = [ (async function* () { @@ -140,4 +149,199 @@ describe('createBedrockStreamingToolLoopStream', () => { }) ) }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":"https://example.com"}' } }, + }, + } + yield { metadata: { usage: { inputTokens: 11, outputTokens: 4 } } } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + })), + } + const onComplete = vi.fn() + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'call it' }] }], + inferenceConfig: { temperature: 0.7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 11, output: 4, total: 15 } }) + ) + }) + + it('finalizes truncated text when max_tokens is reached without a tool call', async () => { + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'Truncated answer' }, + }, + } + yield { metadata: { usage: { inputTokens: 13, outputTokens: 7 } } } + yield { messageStop: { stopReason: 'max_tokens' } } + })(), + })), + } + const onComplete = vi.fn() + const timeSegments: Array<{ type: string }> = [] + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + } as any, + messages: [{ role: 'user', content: [{ text: 'answer' }] }], + inferenceConfig: { temperature: 0.7, maxTokens: 7 }, + bedrockTools: [], + toolChoice: undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: timeSegments as any, + onComplete, + }) + + await expect(collectEvents(stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Truncated answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Truncated answer', + tokens: { input: 13, output: 7, total: 20 }, + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'max_tokens', + assistantContent: 'Truncated answer', + }) + }) + + it('rejects a max_tokens turn containing a partial tool call', async () => { + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_partial', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":' } }, + }, + } + yield { metadata: { usage: { inputTokens: 13, outputTokens: 7 } } } + yield { messageStop: { stopReason: 'max_tokens' } } + })(), + })), + } + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'answer' }] }], + inferenceConfig: { temperature: 0.7, maxTokens: 7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toMatchObject({ + message: 'Bedrock returned tool use with stop reason max_tokens', + }) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'tooluse_partial', + name: 'http_request', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'tooluse_partial', + name: 'http_request', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts index 5d9c9cff2ee..daae801472c 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -21,13 +21,20 @@ import { } from '@aws-sdk/client-bedrock-runtime' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { MAX_TOOL_ITERATIONS } from '@/providers' -import { checkForForcedToolUsage, generateToolUseId } from '@/providers/bedrock/utils' +import { + checkForForcedToolUsage, + generateToolUseId, + getBedrockStreamError, + supportsToolResultStatus, +} from '@/providers/bedrock/utils' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { isAbortError, type StreamingToolLoopComplete, settleOpenTools, + terminateToolLoop, } from '@/providers/streaming-tool-loop-shared' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, TimeSegment } from '@/providers/types' @@ -61,11 +68,12 @@ function parseToolInput(inputJson: string): Record { if (!inputJson.trim()) return {} try { const parsed = JSON.parse(inputJson) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch { - return {} + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Tool input must be a JSON object') + } + return parsed as Record + } catch (error) { + throw new Error(`Invalid Bedrock tool input: ${getErrorMessage(error)}`, { cause: error }) } } @@ -88,6 +96,9 @@ async function drainBedrockTurn( let stopReason: string | undefined for await (const event of stream) { + const streamError = getBedrockStreamError(event) + if (streamError) throw streamError + if (event.contentBlockStart) { currentIndex = event.contentBlockStart.contentBlockIndex const start = event.contentBlockStart.start @@ -163,6 +174,15 @@ export function createBedrockStreamingToolLoopStream( } = options const forcedTools = options.forcedTools ?? [] const originalToolChoice = options.toolChoice + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } return new ReadableStream({ async start(controller) { @@ -186,16 +206,44 @@ export function createBedrockStreamingToolLoopStream( const toolCalls: unknown[] = [] const toolResults: Record[] = [] const openToolStarts = new Map() + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (request.abortSignal?.aborted) { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { settleOpenTools(controller, openToolStarts, 'cancelled') throw new DOMException('Stream aborted', 'AbortError') } + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + /** + * Bedrock's ToolChoice has no `none`, and `toolConfig` is required + * once the history carries toolUse/toolResult blocks — dropping it to + * force a text-only turn makes Bedrock reject the request outright. + * Keep the tools and relax to `auto`; the `finalSynthesis` guard below + * rejects a tool call the model makes anyway. + */ const toolConfig: ToolConfiguration | undefined = bedrockTools.length - ? { tools: bedrockTools, toolChoice } + ? { tools: bedrockTools, toolChoice: finalSynthesis ? { auto: {} } : toolChoice } : undefined const modelStart = Date.now() @@ -207,10 +255,9 @@ export function createBedrockStreamingToolLoopStream( toolConfig, }) - const streamResponse = await client.send( - command, - request.abortSignal ? { abortSignal: request.abortSignal } : undefined - ) + const streamResponse = await client.send(command, { + abortSignal: loopAbortController.signal, + }) if (!streamResponse.stream) { throw new Error('No stream returned from Bedrock') } @@ -249,19 +296,31 @@ export function createBedrockStreamingToolLoopStream( */ const toolsExecutable = drained.stopReason === 'tool_use' if (drained.toolUses.length > 0 && !toolsExecutable) { - logger.warn('Skipping tool execution for incomplete turn', { - stopReason: drained.stopReason, - toolCount: drained.toolUses.length, - }) settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Bedrock returned tool use with stop reason ${drained.stopReason ?? 'missing'}` + ) + } + if (finalSynthesis && drained.toolUses.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Bedrock returned tool use during final synthesis') } const executableToolUses = toolsExecutable ? drained.toolUses : [] + const cappedTextTurn = drained.stopReason === 'max_tokens' && openToolStarts.size === 0 + if ( + executableToolUses.length === 0 && + drained.stopReason !== 'end_turn' && + drained.stopReason !== 'stop_sequence' && + !cappedTextTurn + ) { + throw new Error( + `Bedrock stream ended with stop reason ${drained.stopReason ?? 'missing'}` + ) + } const turnTag = executableToolUses.length > 0 ? 'intermediate' : 'final' controller.enqueue({ type: 'turn_end', turn: turnTag }) - if (drained.text) { - content = drained.text - } + content = drained.text const assembledToolUses = executableToolUses.map((t) => ({ toolUseId: t.toolUseId, @@ -314,13 +373,16 @@ export function createBedrockStreamingToolLoopStream( assembledToolUses.map(async (toolUse) => { const toolCallStartTime = Date.now() const toolName = toolUse.name || '' - const toolArgs: Record = toolUse.input + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) try { - if (request.abortSignal?.aborted) { + if (loopAbortController.signal.aborted) { throw new DOMException('Stream aborted', 'AbortError') } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -356,7 +418,7 @@ export function createBedrockStreamingToolLoopStream( request ) const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, + signal: loopAbortController.signal, }) const toolCallEndTime = Date.now() const status: ToolCallEndStatus = result.success ? 'success' : 'error' @@ -381,11 +443,29 @@ export function createBedrockStreamingToolLoopStream( } } catch (error) { const toolCallEndTime = Date.now() - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - if (!cancelled) { - logger.error('Error processing tool call:', { error, toolName }) + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'cancelled', + }) + throw error } - const status: ToolCallEndStatus = cancelled ? 'cancelled' : 'error' + if (isAbortError(error)) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) + const status: ToolCallEndStatus = 'error' openToolStarts.delete(toolUseId) controller.enqueue({ type: 'tool_call_end', @@ -397,7 +477,7 @@ export function createBedrockStreamingToolLoopStream( toolUse, toolUseId, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams: {} as Record, result: { success: false as const, @@ -415,13 +495,18 @@ export function createBedrockStreamingToolLoopStream( toolsTime += Date.now() - toolsStartTime - const assistantContent: ContentBlock[] = assembledToolUses.map((toolUse) => ({ - toolUse: { - toolUseId: toolUse.toolUseId, - name: toolUse.name, - input: toolUse.input, - }, - })) + const assistantContent: ContentBlock[] = [ + // Bedrock rejects a blank text block, and a model can emit only + // whitespace before a tool call. + ...(drained.text.trim() ? [{ text: drained.text }] : []), + ...assembledToolUses.map((toolUse) => ({ + toolUse: { + toolUseId: toolUse.toolUseId, + name: toolUse.name, + input: toolUse.input, + }, + })), + ] currentMessages.push({ role: 'assistant' as ConversationRole, content: assistantContent, @@ -441,9 +526,11 @@ export function createBedrockStreamingToolLoopStream( }) let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output as Record) - resultContent = result.output + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -465,6 +552,9 @@ export function createBedrockStreamingToolLoopStream( const toolResultBlock: ToolResultBlock = { toolUseId, content: [{ text: JSON.stringify(resultContent) }], + ...(supportsToolResultStatus(modelId) + ? { status: result.success ? 'success' : 'error' } + : {}), } toolResultContent.push({ toolResult: toolResultBlock }) } @@ -491,45 +581,33 @@ export function createBedrockStreamingToolLoopStream( iterationCount += 1 } - /** - * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the - * answer channel would otherwise be empty. Flush the last turn's text - * as the final answer so legacy consumers still receive content. - */ - if (!sawFinalTurn && content) { - controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + if (!sawFinalTurn) { + throw new Error('Bedrock tool loop ended without a final response') } - const toolCost = sumToolCosts(toolResults) - onComplete({ - content, - tokens, - cost: { - input: costInput, - output: costOutput, - toolCost: toolCost || undefined, - total: costTotal + toolCost, - pricing: latestPricing, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - modelTime, - toolsTime, - firstResponseTime, - iterations: modelCalls, - }) + reportProgress() controller.close() } catch (error) { - if (isAbortError(error) || request.abortSignal?.aborted) { - settleOpenTools(controller, openToolStarts, 'cancelled') - } else { - settleOpenTools(controller, openToolStarts, 'error') - logger.error('Bedrock streaming tool loop failed', { - error: toError(error).message, - }) - } - controller.error(error) + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Bedrock streaming tool loop failed', { + error: toError(cause).message, + }), + }) + } finally { + request.abortSignal?.removeEventListener('abort', abortFromRequest) } }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, }) } diff --git a/apps/sim/providers/bedrock/utils.stream.test.ts b/apps/sim/providers/bedrock/utils.stream.test.ts index d9407b80018..b6b3c6b3d41 100644 --- a/apps/sim/providers/bedrock/utils.stream.test.ts +++ b/apps/sim/providers/bedrock/utils.stream.test.ts @@ -46,4 +46,16 @@ describe('createReadableStreamFromBedrockStream', () => { expect(events.some((e) => e.type === 'tool_call_start')).toBe(false) expect(onComplete).toHaveBeenCalledWith('Done', { inputTokens: 2, outputTokens: 3 }) }) + + it('surfaces Bedrock event-stream exceptions', async () => { + const stream = createReadableStreamFromBedrockStream( + (async function* () { + yield { + modelStreamErrorException: { message: 'Model stream failed' }, + } as any + })() + ) + + await expect(collectEvents(stream)).rejects.toThrow('Model stream failed') + }) }) diff --git a/apps/sim/providers/bedrock/utils.test.ts b/apps/sim/providers/bedrock/utils.test.ts index a667d614122..421e46bcff4 100644 --- a/apps/sim/providers/bedrock/utils.test.ts +++ b/apps/sim/providers/bedrock/utils.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getBedrockInferenceProfileId } from '@/providers/bedrock/utils' +import { getBedrockInferenceProfileId, supportsToolResultStatus } from '@/providers/bedrock/utils' describe('getBedrockInferenceProfileId', () => { it.concurrent('prefixes geo inference profile for models that require it', () => { @@ -44,3 +44,22 @@ describe('getBedrockInferenceProfileId', () => { ).toBe('amazon.titan-text-premier-v1:0') }) }) + +describe('supportsToolResultStatus', () => { + it.concurrent('accepts Claude and Nova through every ID form', () => { + expect(supportsToolResultStatus('bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0')).toBe(true) + expect(supportsToolResultStatus('us.anthropic.claude-opus-4-5-20251101-v1:0')).toBe(true) + expect(supportsToolResultStatus('anthropic.claude-haiku-4-5-20251001-v1:0')).toBe(true) + expect(supportsToolResultStatus('global.amazon.nova-2-lite-v1:0')).toBe(true) + expect(supportsToolResultStatus('bedrock/amazon.nova-micro-v1:0')).toBe(true) + }) + + it.concurrent('rejects every family that returns a ValidationException for it', () => { + expect(supportsToolResultStatus('us.meta.llama4-scout-17b-instruct-v1:0')).toBe(false) + expect(supportsToolResultStatus('bedrock/meta.llama3-3-70b-instruct-v1:0')).toBe(false) + expect(supportsToolResultStatus('mistral.mistral-large-2407-v1:0')).toBe(false) + expect(supportsToolResultStatus('cohere.command-r-plus-v1:0')).toBe(false) + // Titan shares the amazon vendor prefix but is not Nova. + expect(supportsToolResultStatus('bedrock/amazon.titan-text-premier-v1:0')).toBe(false) + }) +}) diff --git a/apps/sim/providers/bedrock/utils.ts b/apps/sim/providers/bedrock/utils.ts index 7cdd6b6c125..a8e837b1142 100644 --- a/apps/sim/providers/bedrock/utils.ts +++ b/apps/sim/providers/bedrock/utils.ts @@ -1,5 +1,6 @@ import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { randomFloat } from '@sim/utils/random' import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' @@ -11,6 +12,22 @@ export interface BedrockStreamUsage { outputTokens: number } +/** + * Converts an AWS event-stream exception member into an Error. + */ +export function getBedrockStreamError(event: ConverseStreamOutput): Error | undefined { + const exception = + event.internalServerException ?? + event.modelStreamErrorException ?? + event.validationException ?? + event.throttlingException ?? + event.serviceUnavailableException + if (!exception) return undefined + return new Error(exception.message || getErrorMessage(exception, 'Bedrock stream error'), { + cause: exception, + }) +} + /** * Bedrock ConverseStream → agent-events-v1 for the legacy (non-tool-loop) * streaming path. Text deltas only: tools on this path are never executed, so @@ -25,11 +42,19 @@ export function createReadableStreamFromBedrockStream( let fullContent = '' let inputTokens = 0 let outputTokens = 0 + let cancelled = false + let streamIterator: AsyncIterator | undefined return new ReadableStream({ async start(controller) { try { - for await (const event of bedrockStream) { + streamIterator = bedrockStream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const event = next.value + const streamError = getBedrockStreamError(event) + if (streamError) throw streamError if (event.contentBlockDelta?.delta?.text) { const text = event.contentBlockDelta.delta.text fullContent += text @@ -40,15 +65,22 @@ export function createReadableStreamFromBedrockStream( } } + if (cancelled) return if (onComplete) { onComplete(fullContent, { inputTokens, outputTokens }) } controller.close() } catch (err) { - controller.error(err) + if (!cancelled) { + controller.error(err) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } @@ -108,6 +140,32 @@ const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([ 'cohere.command-r-plus-v1:0', ]) +/** Cross-region inference profile prefixes Bedrock prepends to a base model ID. */ +const GEO_PROFILE_PREFIX_PATTERN = /^(us-gov|us|eu|apac|au|ca|jp|global)\./ + +/** + * Strips Sim's `bedrock/` namespace and any cross-region inference prefix, + * leaving the bare `.` ID that capability checks key off. + */ +function getBedrockBaseModelId(modelId: string): string { + const withoutNamespace = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId + return withoutNamespace.replace(GEO_PROFILE_PREFIX_PATTERN, '') +} + +/** + * Whether the model accepts `status` on a `toolResult` content block. + * + * Only Amazon Nova and Anthropic Claude 3/4 support it; Llama, Mistral, Cohere, + * and Titan reject the whole request with + * `ValidationException: This model doesn't support the status field.` + * + * Source: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html + */ +export function supportsToolResultStatus(modelId: string): boolean { + const baseModelId = getBedrockBaseModelId(modelId) + return baseModelId.startsWith('anthropic.') || baseModelId.startsWith('amazon.nova') +} + /** * Converts a model ID to the Bedrock inference profile format. * AWS Bedrock requires inference profile IDs (e.g., us.anthropic.claude-...) @@ -121,7 +179,7 @@ const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([ export function getBedrockInferenceProfileId(modelId: string, region: string): string { const baseModelId = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId - if (/^(us-gov|us|eu|apac|au|ca|jp|global)\./.test(baseModelId)) { + if (GEO_PROFILE_PREFIX_PATTERN.test(baseModelId)) { return baseModelId } diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index ca9c35a0e80..aaf192a8fe8 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -1,13 +1,17 @@ import { Cerebras } from '@cerebras/cerebras_cloud_sdk' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import type { CerebrasResponse } from '@/providers/cerebras/types' import { createReadableStreamFromCerebrasStream } from '@/providers/cerebras/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -231,9 +235,24 @@ export const cerebrasProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -251,6 +270,9 @@ export const cerebrasProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Cerebras):', { error: toError(error).message, @@ -273,25 +295,21 @@ export const cerebrasProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: filteredToolCalls.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: filteredToolCalls, + reasoningFields: ['reasoning'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', name: toolName, @@ -300,10 +318,12 @@ export const cerebrasProvider: ProviderConfig = { duration: duration, toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -358,7 +378,7 @@ export const cerebrasProvider: ProviderConfig = { } finalPayload.tool_choice = 'none' - const finalResponse = (await client.chat.completions.create( + currentResponse = (await client.chat.completions.create( finalPayload, request.abortSignal ? { signal: request.abortSignal } : undefined )) as CerebrasResponse @@ -376,22 +396,23 @@ export const cerebrasProvider: ProviderConfig = { modelTime += thisModelTime - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content } - if (finalResponse.usage) { - tokens.input += finalResponse.usage.prompt_tokens || 0 - tokens.output += finalResponse.usage.completion_tokens || 0 - tokens.total += finalResponse.usage.total_tokens || 0 + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 } enrichLastModelSegmentFromChatCompletions( timeSegments, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'cerebras' } ) + iterationCount++ break } @@ -429,16 +450,56 @@ export const cerebrasProvider: ProviderConfig = { } } - if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + if (iterationCount === MAX_TOOL_ITERATIONS && cappedToolCalls?.length) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + cappedToolCalls, + { model: request.model, provider: 'cerebras' } + ) + + const finalModelStartTime = Date.now() + currentResponse = (await client.chat.completions.create( + { + ...payload, + messages: currentMessages, + tool_choice: 'none', + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + )) as CerebrasResponse + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'cerebras' } ) + iterationCount++ } } catch (error) { logger.error('Error in Cerebras tool processing:', { error }) + throw error } const providerEndTime = Date.now() @@ -446,25 +507,8 @@ export const cerebrasProvider: ProviderConfig = { const totalDuration = providerEndTime - providerStartTime if (request.stream) { - logger.info('Using streaming for final Cerebras response after tool processing') - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path. - */ - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - } - - const streamResponse: any = await client.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, @@ -486,8 +530,8 @@ export const cerebrasProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -498,31 +542,11 @@ export const cerebrasProvider: ProviderConfig = { : undefined, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromCerebrasStream(streamResponse, (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('Cerebras final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -555,6 +579,10 @@ export const cerebrasProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/cost-policy.test.ts b/apps/sim/providers/cost-policy.test.ts new file mode 100644 index 00000000000..5d7ebd9869e --- /dev/null +++ b/apps/sim/providers/cost-policy.test.ts @@ -0,0 +1,293 @@ +/** + * @vitest-environment node + */ +import { envFlagsMockFns, resetEnvFlagsMock } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import type { NormalizedBlockOutput } from '@/executor/types' +import { + applyModelCostPolicy, + applySegmentCostPolicy, + calculateBillableModelCost, + installStreamingCostPolicy, + LIST_PRICE_POLICY, + priceModelUsage, + resolveModelCostPolicy, + resolveProxiedModelCost, + withoutToolCost, +} from '@/providers/cost-policy' +import { calculateCost } from '@/providers/utils' + +/** A model Sim hosts, so its usage is billable when no BYOK key is present. */ +const HOSTED_MODEL = 'claude-opus-4-6' +/** A model reached only with a caller-supplied key, so Sim never bills it. */ +const SELF_KEYED_MODEL = 'llama-3.3-70b-versatile' + +describe('resolveModelCostPolicy', () => { + afterEach(resetEnvFlagsMock) + + it('bills hosted models at the configured multiplier', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + expect(resolveModelCostPolicy(HOSTED_MODEL)).toEqual({ billable: true, multiplier: 2 }) + }) + + it('does not bill when the workspace supplied its own key', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + expect(resolveModelCostPolicy(HOSTED_MODEL, true)).toEqual({ billable: false, multiplier: 0 }) + }) + + it('does not bill models Sim does not host', () => { + expect(resolveModelCostPolicy(SELF_KEYED_MODEL)).toEqual({ billable: false, multiplier: 0 }) + }) +}) + +describe('applyModelCostPolicy', () => { + it('scales model cost by the multiplier and leaves tool cost untouched', () => { + const projected = applyModelCostPolicy( + { input: 1, output: 2, total: 3.5, toolCost: 0.5 }, + { billable: true, multiplier: 2 } + ) + + expect(projected).toMatchObject({ input: 2, output: 4, total: 6.5, toolCost: 0.5 }) + }) + + it('returns the cost unchanged when the multiplier is 1', () => { + const cost = { input: 1, output: 2, total: 3 } + expect(applyModelCostPolicy(cost, { billable: true, multiplier: 1 })).toBe(cost) + }) + + it('zeroes model cost but preserves tool cost when not billable', () => { + const projected = applyModelCostPolicy( + { input: 1, output: 2, total: 3.25, toolCost: 0.25 }, + { billable: false, multiplier: 0 } + ) + + expect(projected).toMatchObject({ input: 0, output: 0, total: 0.25, toolCost: 0.25 }) + }) +}) + +describe('priceModelUsage', () => { + /** Claude Sonnet 5: $2/MTok input, $0.20/MTok cached, $10/MTok output. */ + const PRICED_MODEL = 'claude-sonnet-5' + const PER_TOKEN_INPUT = 2 / 1_000_000 + + it('prices cache reads at the cached rate, not the base rate', () => { + const cached = priceModelUsage( + PRICED_MODEL, + { input: 0, output: 0, cacheRead: 100_000 }, + LIST_PRICE_POLICY + ) + const uncached = priceModelUsage(PRICED_MODEL, { input: 100_000, output: 0 }, LIST_PRICE_POLICY) + + expect(cached.input).toBeCloseTo(uncached.input / 10, 8) + }) + + it('prices each write bucket at its own premium over the base input rate', () => { + const cost = priceModelUsage( + PRICED_MODEL, + { + input: 0, + output: 0, + cacheWrites: [ + { tokens: 100_000, inputRateMultiplier: 1.25 }, + { tokens: 100_000, inputRateMultiplier: 2 }, + ], + }, + LIST_PRICE_POLICY + ) + + expect(cost.input).toBeCloseTo(100_000 * PER_TOKEN_INPUT * 3.25, 8) + }) + + it('matches the plain uncached calculation when there is no cache activity', () => { + const priced = priceModelUsage(PRICED_MODEL, { input: 1000, output: 500 }, LIST_PRICE_POLICY) + const direct = calculateCost(PRICED_MODEL, 1000, 500) + + expect(priced).toMatchObject({ + input: direct.input, + output: direct.output, + total: direct.total, + }) + }) + + it('applies the policy multiplier to every bucket', () => { + const usage = { + input: 1000, + output: 500, + cacheRead: 2000, + cacheWrites: [{ tokens: 1000, inputRateMultiplier: 1.25 }], + } + + const single = priceModelUsage(PRICED_MODEL, usage, { billable: true, multiplier: 1 }) + const tripled = priceModelUsage(PRICED_MODEL, usage, { billable: true, multiplier: 3 }) + + expect(tripled.total).toBeCloseTo(single.total * 3, 8) + }) + + it('charges nothing when the policy is not billable', () => { + const cost = priceModelUsage( + PRICED_MODEL, + { input: 1000, output: 500, cacheRead: 5000 }, + { billable: false, multiplier: 0 } + ) + + expect(cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) + + /** + * The regression this guards: Anthropic reports `input_tokens` already + * excluding cache tokens while OpenAI and Gemini report a cached subset of + * the prompt total. A normalization mistake in either adapter shows up as + * two different prices for one real token split. + */ + it('prices the same real token split identically across opposite wire shapes', () => { + const anthropicShaped = priceModelUsage( + PRICED_MODEL, + { input: 1000, output: 500, cacheRead: 4000 }, + LIST_PRICE_POLICY + ) + + const openAIPromptTotal = 5000 + const openAICached = 4000 + const openAIShaped = priceModelUsage( + PRICED_MODEL, + { + input: openAIPromptTotal - openAICached, + output: 500, + cacheRead: openAICached, + }, + LIST_PRICE_POLICY + ) + + expect(openAIShaped).toEqual(anthropicShaped) + }) +}) + +describe('withoutToolCost', () => { + it('removes a provider-folded tool cost and rebases the total', () => { + expect(withoutToolCost({ input: 1, output: 2, total: 3.5, toolCost: 0.5 })).toEqual({ + input: 1, + output: 2, + total: 3, + }) + }) + + it('leaves a model-only cost untouched', () => { + const cost = { input: 1, output: 2, total: 3 } + expect(withoutToolCost(cost)).toBe(cost) + }) +}) + +describe('calculateBillableModelCost', () => { + afterEach(resetEnvFlagsMock) + + it('matches the streaming projection for the same tokens', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(3) + + const nonStreaming = calculateBillableModelCost(HOSTED_MODEL, 1000, 500) + const unscaled = calculateBillableModelCost(HOSTED_MODEL, 1000, 500) + // The streaming path projects a provider cost computed without the + // multiplier, so remove it before comparing against the direct calculation. + const providerCost = { + input: unscaled.input / 3, + output: unscaled.output / 3, + total: unscaled.total / 3, + } + const streaming = applyModelCostPolicy(providerCost, resolveModelCostPolicy(HOSTED_MODEL)) + + expect(streaming.total).toBeCloseTo(nonStreaming.total, 8) + }) + + it('returns a zero cost carrying pricing for models Sim does not host', () => { + const cost = calculateBillableModelCost(SELF_KEYED_MODEL, 1000, 500) + expect(cost).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(cost.pricing).toBeDefined() + }) +}) + +describe('installStreamingCostPolicy', () => { + afterEach(resetEnvFlagsMock) + + it('applies the multiplier to cost written after the stream starts', () => { + const output = { cost: { input: 0, output: 0, total: 0 } } as NormalizedBlockOutput + installStreamingCostPolicy(output, { billable: true, multiplier: 2 }) + + output.cost = { input: 1, output: 2, total: 3 } + + expect(output.cost).toMatchObject({ input: 2, output: 4, total: 6 }) + }) + + it('keeps tool cost from a settled stream that wrote its cost before the policy was installed', () => { + const output = { + cost: { input: 1, output: 2, total: 3.75, toolCost: 0.75 }, + } as NormalizedBlockOutput + + installStreamingCostPolicy(output, { billable: false, multiplier: 0 }) + + expect(output.cost).toMatchObject({ input: 0, output: 0, total: 0.75, toolCost: 0.75 }) + }) + + it('zeroes model cost written by a provider for a model Sim does not host', () => { + const output = { cost: { input: 0, output: 0, total: 0 } } as NormalizedBlockOutput + installStreamingCostPolicy(output, resolveModelCostPolicy(SELF_KEYED_MODEL)) + + output.cost = { input: 0.5, output: 1.5, total: 2 } + + expect(output.cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) +}) + +describe('applySegmentCostPolicy', () => { + it('zeroes model segments only when the call is not billable', () => { + const segments = [ + { type: 'model', cost: { input: 1, output: 2, total: 3 } }, + { type: 'tool', cost: { total: 0.01 } }, + ] + + applySegmentCostPolicy(segments, { billable: true, multiplier: 1 }) + expect(segments[0].cost).toMatchObject({ total: 3 }) + + applySegmentCostPolicy(segments, { billable: false, multiplier: 0 }) + expect(segments[0].cost).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(segments[1].cost).toMatchObject({ total: 0.01 }) + }) +}) + +describe('resolveProxiedModelCost', () => { + it('passes through the proxy cost without recomputing it', () => { + const pricing = { input: 5, output: 25, updatedAt: '2026-04-01' } + expect( + resolveProxiedModelCost({ input: 1, output: 2, total: 3, toolCost: 0.5, pricing }) + ).toEqual({ + input: 1, + output: 2, + total: 3, + toolCost: 0.5, + pricing, + }) + }) + + it('treats a missing or malformed cost as no billable usage', () => { + expect(resolveProxiedModelCost(undefined)).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(resolveProxiedModelCost(0.003)).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(resolveProxiedModelCost({ input: Number.NaN, output: 1, total: 1 })).toMatchObject({ + input: 0, + output: 1, + total: 1, + }) + expect( + resolveProxiedModelCost({ input: 1, output: 1, total: Number.POSITIVE_INFINITY }) + ).toMatchObject({ total: 0 }) + expect(resolveProxiedModelCost({ input: 1, output: 1, total: '2' })).toMatchObject({ total: 0 }) + }) + + it('never lets a corrupt negative figure credit the run', () => { + expect(resolveProxiedModelCost({ input: -5, output: -1, total: -6 })).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) + expect(resolveProxiedModelCost({ input: 1, output: 1, total: 2, toolCost: -3 })).toMatchObject({ + toolCost: 0, + }) + }) +}) diff --git a/apps/sim/providers/cost-policy.ts b/apps/sim/providers/cost-policy.ts new file mode 100644 index 00000000000..d89b978debd --- /dev/null +++ b/apps/sim/providers/cost-policy.ts @@ -0,0 +1,303 @@ +import { getCostMultiplier } from '@/lib/core/config/env-flags' +import type { NormalizedBlockOutput } from '@/executor/types' +import type { ModelPricing } from '@/providers/types' +import { calculateCost, shouldBillModelUsage } from '@/providers/utils' + +/** + * Single source of truth for whether a model response is charged to the caller + * and at what margin. + * + * Two concerns are deliberately separated: + * - Providers own token → USD *pricing*; they alone know cache tiers, reasoning + * tokens, and per-turn accumulation. + * - This module owns billing *policy*: whether Sim supplied the credentials at + * all, and the production margin multiplier. + * + * Every surface that turns tokens into a charge — the streaming and + * non-streaming provider paths, Router, Evaluator, and Pi — resolves policy + * here, so a model costs the same no matter which path produced it. + */ + +/** Cost shape written onto block output. Mirrors `BlockCost`. */ +export interface ModelCost { + input: number + output: number + total: number + toolCost?: number + pricing?: ModelPricing +} + +/** Cost that always carries pricing, as `ProviderResponse['cost']` requires. */ +export type PricedModelCost = ModelCost & { pricing: ModelPricing } + +export interface ModelCostPolicy { + /** True when Sim supplied the credentials and must charge for the tokens. */ + billable: boolean + /** Margin applied to billable model cost; `0` when the call is not billable. */ + multiplier: number +} + +/** + * Prices at the vendor's list rate with no margin. + * + * Provider adapters use this: they price tokens the vendor charged, and the + * central layer applies the billability gate and margin afterwards (via + * {@link applyModelCostPolicy} or {@link installStreamingCostPolicy}). A + * provider applying the policy itself would double-count the multiplier. + */ +export const LIST_PRICE_POLICY: ModelCostPolicy = Object.freeze({ + billable: true, + multiplier: 1, +}) + +/** Pricing echoed on a zero cost so consumers can tell "not billed" from "unpriced". */ +const NOT_BILLED_PRICING: ModelPricing = Object.freeze({ + input: 0, + output: 0, + updatedAt: new Date(0).toISOString(), +}) + +function roundCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Resolves the billing policy for a model response. + * + * A response is billable only when the model is one Sim hosts (so Sim's own key + * paid the provider) and the workspace did not supply its own key. + */ +export function resolveModelCostPolicy(model: string, isBYOK = false): ModelCostPolicy { + const billable = !isBYOK && shouldBillModelUsage(model) + return { billable, multiplier: billable ? getCostMultiplier() : 0 } +} + +/** Zero model cost, preserving any tool cost already charged at the tool's own rate. */ +export function notBilledCost(toolCost = 0): PricedModelCost { + return { + input: 0, + output: 0, + total: roundCost(toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: NOT_BILLED_PRICING, + } +} + +/** + * Projects a model cost through the billing policy. + * + * Tool cost passes through untouched — it is already billed at the tool's own + * rate by the tool layer and must not pick up the model margin. + */ +export function applyModelCostPolicy( + cost: ModelCost | undefined, + policy: ModelCostPolicy +): ModelCost { + const toolCost = cost?.toolCost ?? 0 + + if (!cost || !policy.billable) { + return notBilledCost(toolCost) + } + + if (policy.multiplier === 1) { + return cost + } + + const modelTotal = cost.total - toolCost + + return { + ...cost, + input: roundCost(cost.input * policy.multiplier), + output: roundCost(cost.output * policy.multiplier), + total: roundCost(modelTotal * policy.multiplier + toolCost), + } +} + +/** A cache write bucket and its premium over the model's base input rate. */ +export interface CacheWriteUsage { + tokens: number + /** Anthropic: 1.25 (5m) or 2 (1h). OpenAI: 1.25 on GPT-5.6+, free before it. */ + inputRateMultiplier: number +} + +/** + * Normalized token usage for one model call. + * + * Providers report cache usage in incompatible shapes — Anthropic's + * `input_tokens` already excludes cache tokens, while OpenAI's `cached_tokens` + * and Gemini's `cachedContentTokenCount` are subsets of their prompt totals. + * Each provider adapter resolves that difference before building this; nothing + * downstream branches on provider again. + */ +export interface ModelUsage { + /** Tokens billed at the base input rate. Always EXCLUDES cache reads/writes. */ + input: number + output: number + /** Tokens served from the provider's cache, billed at the cachedInput rate. */ + cacheRead?: number + /** Tokens written to the cache, each bucket at its own premium. */ + cacheWrites?: CacheWriteUsage[] +} + +/** + * The single cache-aware pricing function. + * + * Every surface that turns tokens into a charge routes through here, so a cache + * hit is priced identically whether it came from Anthropic, OpenAI, Gemini, or + * an OpenAI-compatible vendor. Callers must not pre-apply the policy multiplier + * or the cached rate themselves. + * + * Token counts are validated by the provider adapter that builds the + * {@link ModelUsage}, which is the only layer that knows the vendor's shape and + * can enforce that cache buckets are a subset of the prompt total. This function + * re-validates nothing. + */ +export function priceModelUsage( + model: string, + usage: ModelUsage, + policy: ModelCostPolicy +): PricedModelCost { + if (!policy.billable) { + return notBilledCost() + } + + const multiplier = policy.multiplier + const base = calculateCost(model, usage.input, usage.output, false, multiplier, multiplier) + + const cacheRead = usage.cacheRead ?? 0 + const read = cacheRead > 0 ? calculateCost(model, cacheRead, 0, true, multiplier, 0) : undefined + + let writeInputCost = 0 + for (const write of usage.cacheWrites ?? []) { + if (write.tokens <= 0) continue + writeInputCost += calculateCost( + model, + write.tokens, + 0, + false, + multiplier * write.inputRateMultiplier, + 0 + ).input + } + + const input = roundCost(base.input + (read?.input ?? 0) + writeInputCost) + const output = base.output + + return { + input, + output, + total: roundCost(input + output), + pricing: base.pricing, + } +} + +/** + * Prices tokens for a model and applies the billing policy in one step. Use + * wherever a caller holds raw token counts and needs the charge Sim records. + * + * Cache-free by design: a caller that has cache usage also knows its tiers, so + * it builds a {@link ModelUsage} and calls {@link priceModelUsage} directly. + */ +export function calculateBillableModelCost( + model: string, + promptTokens = 0, + completionTokens = 0, + options: { isBYOK?: boolean } = {} +): PricedModelCost { + return priceModelUsage( + model, + { input: promptTokens, output: completionTokens }, + resolveModelCostPolicy(model, options.isBYOK) + ) +} + +/** + * Drops a tool cost a provider folded into its own total. + * + * `executeProviderRequest` re-derives tool cost from `response.toolResults` and + * adds it after the policy is applied, so a provider that already accounted for + * it would otherwise have it counted twice. + */ +export function withoutToolCost(cost: ModelCost): ModelCost { + if (cost.toolCost === undefined) return cost + + const { toolCost: _toolCost, ...model } = cost + return { ...model, total: roundCost(model.input + model.output) } +} + +/** Rejects NaN, Infinity, and negatives — a negative would credit the run. */ +function billableAmount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} + +/** + * Reads the cost off a JSON response returned by the `/api/providers` proxy. + * + * The proxy already resolved key provenance and applied the policy, so its cost + * is authoritative and must never be recomputed from token counts — the caller + * cannot see whether a workspace BYOK key paid for the call. A response with no + * usable cost carried no billable usage. + */ +export function resolveProxiedModelCost(cost: unknown): ModelCost { + if (!cost || typeof cost !== 'object') { + return notBilledCost() + } + + const { input, output, total, toolCost, pricing } = cost as Partial + return { + input: billableAmount(input), + output: billableAmount(output), + total: billableAmount(total), + ...(toolCost === undefined ? {} : { toolCost: billableAmount(toolCost) }), + // Kept so downstream cost estimators can tell a priced zero from an + // unpriced block and leave the charge alone. + ...(pricing ? { pricing } : {}), + } +} + +/** + * Applies the billing policy to a streaming block output. + * + * Streaming providers write their final cost from inside the stream drain, long + * after `executeRequest` returns, so the policy is installed as an accessor + * rather than applied to a value. A cost already present — providers that + * settle their tool loop before returning — becomes the initial value, so its + * tool cost survives. + */ +export function installStreamingCostPolicy( + output: NormalizedBlockOutput, + policy: ModelCostPolicy +): void { + let raw = output.cost as ModelCost | undefined + + Object.defineProperty(output, 'cost', { + get: () => applyModelCostPolicy(raw, policy), + set: (value: ModelCost | undefined) => { + raw = value + }, + configurable: true, + enumerable: true, + }) +} + +/** + * Zeroes per-segment model cost on already-populated time segments when the + * call is not billable. + * + * Segment costs come from the trace enrichers, which price tokens without + * knowing key provenance. Block-level cost stays authoritative for billing — + * `calculateCostSummary` skips model children of a span that has its own cost — + * so this only keeps the displayed breakdown from contradicting it. + */ +export function applySegmentCostPolicy( + segments: Array<{ type?: string; cost?: { input?: number; output?: number; total?: number } }>, + policy: ModelCostPolicy +): void { + if (policy.billable) return + + for (const segment of segments) { + if (segment.type === 'model' && segment.cost) { + segment.cost = { input: 0, output: 0, total: 0 } + } + } +} diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts index 1ccaa61b72d..ff5cf829824 100644 --- a/apps/sim/providers/deepseek/index.test.ts +++ b/apps/sim/providers/deepseek/index.test.ts @@ -2,10 +2,13 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import type { ProviderRequest } from '@/providers/types' -const { mockCreate } = vi.hoisted(() => ({ +const { mockCreate, mockExecuteTool, mockPrepareToolsWithUsageControl } = vi.hoisted(() => ({ mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + mockPrepareToolsWithUsageControl: vi.fn(), })) vi.mock('openai', () => ({ @@ -46,17 +49,12 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), - prepareToolsWithUsageControl: vi.fn(() => ({ - tools: [], - toolChoice: undefined, - forcedTools: [], - hasFilteredTools: false, - })), + prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, sumToolCosts: vi.fn(() => 0), trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) -vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { deepseekProvider } from '@/providers/deepseek/index' @@ -72,6 +70,14 @@ function request(overrides: Partial = {}): ProviderRequest { describe('deepseekProvider thinking payload', () => { beforeEach(() => { mockCreate.mockReset() + mockExecuteTool.mockReset() + mockPrepareToolsWithUsageControl.mockReset() + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }) mockCreate.mockResolvedValue({ choices: [{ message: { content: 'ok', tool_calls: [] } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, @@ -96,4 +102,47 @@ describe('deepseekProvider thinking payload', () => { const payload = mockCreate.mock.calls[0][0] expect(payload.thinking).toBeUndefined() }) + + it('selects the live tool loop without a caller flag', async () => { + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [ + { + type: 'function', + function: { name: 'lookup', description: 'Lookup', parameters: {} }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + vi.mocked(createOpenAICompatStreamingToolLoopStream).mockReturnValue( + new ReadableStream() as never + ) + + const result = (await deepseekProvider.executeRequest( + request({ + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }) + )) as unknown as { + createStream: (handles: { + output: { content?: string } + finalizeTiming: () => void + }) => ReadableStream + } + + const output: { content?: string } = {} + result.createStream({ output, finalizeTiming: vi.fn() }) + + expect(createOpenAICompatStreamingToolLoopStream).toHaveBeenCalledTimes(1) + expect(mockCreate).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 198fbf2d2c5..93bf998a270 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' @@ -8,6 +9,7 @@ import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/uti import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -21,7 +23,6 @@ import { calculateCost, prepareToolExecution, prepareToolsWithUsageControl, - sumToolCosts, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -49,7 +50,7 @@ export const deepseekProvider: ProviderConfig = { try { const deepseek = new OpenAI({ apiKey: request.apiKey, - baseURL: 'https://api.deepseek.com/v1', + baseURL: 'https://api.deepseek.com', }) const allMessages = [] @@ -90,11 +91,23 @@ export const deepseekProvider: ProviderConfig = { * on reasoner). The API default is enabled, so 'none' must explicitly send * `disabled`; unset sends nothing to preserve the legacy request shape. */ + const usesThinkingMode = + request.thinkingLevel !== undefined + ? request.thinkingLevel !== 'none' + : request.model !== 'deepseek-chat' if (request.thinkingLevel && request.thinkingLevel !== 'none') { payload.thinking = { type: 'enabled' } } else if (request.thinkingLevel === 'none') { payload.thinking = { type: 'disabled' } } + if (request.reasoningEffort && !['auto', 'none'].includes(request.reasoningEffort)) { + payload.reasoning_effort = + request.reasoningEffort === 'xhigh' + ? 'max' + : request.reasoningEffort === 'low' || request.reasoningEffort === 'medium' + ? 'high' + : request.reasoningEffort + } let preparedTools: ReturnType | null = null @@ -102,30 +115,32 @@ export const deepseekProvider: ProviderConfig = { preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'deepseek') const { tools: filteredTools, toolChoice } = preparedTools - if (filteredTools?.length && toolChoice) { + if (filteredTools?.length) { payload.tools = filteredTools - payload.tool_choice = toolChoice + if (toolChoice && !usesThinkingMode) { + payload.tool_choice = toolChoice + } logger.info('Deepseek request configuration:', { toolCount: filteredTools.length, toolChoice: - typeof toolChoice === 'string' - ? toolChoice - : toolChoice.type === 'function' - ? `force:${toolChoice.function.name}` - : toolChoice.type === 'tool' - ? `force:${toolChoice.name}` - : toolChoice.type === 'any' - ? `force:${toolChoice.any?.name || 'unknown'}` - : 'unknown', + !toolChoice || usesThinkingMode + ? 'provider-default' + : typeof toolChoice === 'string' + ? toolChoice + : toolChoice.type === 'function' + ? `force:${toolChoice.function.name}` + : toolChoice.type === 'tool' + ? `force:${toolChoice.name}` + : toolChoice.type === 'any' + ? `force:${toolChoice.any?.name || 'unknown'}` + : 'unknown', model: request.model, }) } } - const shouldStreamToolCalls = request.streamToolCalls ?? false - - if (request.stream && shouldStreamToolCalls && payload.tools?.length) { + if (request.stream && payload.tools?.length) { logger.info('Using streaming tool loop for DeepSeek request') const timeSegments: TimeSegment[] = [] @@ -156,7 +171,14 @@ export const deepseekProvider: ProviderConfig = { // double-cast-allowed: formatMessagesForProvider returns loosely-typed provider messages that are wire-compatible with the OpenAI chat.completions message params the shared loop expects formattedMessages as unknown as OpenAI.Chat.Completions.ChatCompletionMessageParam[], createStream: async (params, options) => - deepseek.chat.completions.create({ ...params, stream: true }, options), + deepseek.chat.completions.create( + { + ...params, + stream: true, + stream_options: { include_usage: true }, + }, + options + ), logger, timeSegments, forcedTools, @@ -184,13 +206,14 @@ export const deepseekProvider: ProviderConfig = { }) } - if (request.stream && (!tools || tools.length === 0)) { + if (request.stream && !payload.tools?.length) { logger.info('Using streaming response for DeepSeek request (no tools)') const streamResponse = await deepseek.chat.completions.create( { ...payload, stream: true, + stream_options: { include_usage: true }, }, request.abortSignal ? { signal: request.abortSignal } : undefined ) @@ -204,7 +227,7 @@ export const deepseekProvider: ProviderConfig = { initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => + createStream: ({ output, finalizeTiming }) => createReadableStreamFromDeepseekStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, @@ -233,6 +256,7 @@ export const deepseekProvider: ProviderConfig = { segment.thinkingContent = thinking } } + finalizeTiming() } ), }) @@ -324,10 +348,25 @@ export const deepseekProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -345,6 +384,9 @@ export const deepseekProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -364,12 +406,12 @@ export const deepseekProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) const assistantMessage = currentResponse.choices[0]?.message const assistantHistory: { role: string - content: string | null + content: string tool_calls: Array<{ id: string type: string @@ -378,7 +420,7 @@ export const deepseekProvider: ProviderConfig = { reasoning_content?: string } = { role: 'assistant', - content: null, + content: assistantMessage?.content ?? '', tool_calls: toolCallsInResponse.map((tc) => ({ id: tc.id, type: 'function', @@ -403,11 +445,9 @@ export const deepseekProvider: ProviderConfig = { } currentMessages.push(assistantHistory) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -418,10 +458,12 @@ export const deepseekProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -535,107 +577,13 @@ export const deepseekProvider: ProviderConfig = { } } catch (error) { logger.error('Error in Deepseek request:', { error }) + throw error } const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - if (request.stream) { - logger.info('Using streaming for final DeepSeek response after tool processing') - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path; with `auto` a - * model can re-call and end the stream with no text. - */ - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - } - - const streamResponse = await deepseek.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromDeepseekStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (streamedContent, usage, thinking) => { - if (!streamedContent && content) { - logger.warn('DeepSeek final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - - if (thinking) { - const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - } - ), - }) - - return streamingResult - } - return { content, model: request.model, @@ -663,6 +611,9 @@ export const deepseekProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index 8c38a5b7303..f20d8568630 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/fireworks/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,16 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = ( + assistant: { content?: string | null; reasoning_content?: string } = {} +) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning_content !== undefined + ? { reasoning_content: assistant.reasoning_content } + : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -218,15 +226,54 @@ describe('fireworksProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Fireworks assistant content and reasoning_content on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await fireworksProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await fireworksProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await fireworksProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index f30a58768bb..0ec540a8c2b 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -11,7 +12,10 @@ import { supportsNativeStructuredOutputs, } from '@/providers/fireworks/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -263,10 +267,25 @@ export const fireworksProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -284,6 +303,9 @@ export const fireworksProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Fireworks):', { error: toError(error).message, @@ -306,26 +328,21 @@ export const fireworksProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -336,10 +353,12 @@ export const fireworksProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -417,85 +436,61 @@ export const fireworksProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'fireworks' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'fireworks', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'fireworks' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -551,6 +546,45 @@ export const fireworksProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -568,7 +602,7 @@ export const fireworksProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -591,6 +625,10 @@ export const fireworksProvider: ProviderConfig = { } logger.error('Error in Fireworks request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/gemini/core.cost.test.ts b/apps/sim/providers/gemini/core.cost.test.ts new file mode 100644 index 00000000000..96d0c660300 --- /dev/null +++ b/apps/sim/providers/gemini/core.cost.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeGeminiRequest } from '@/providers/gemini/core' +import type { ProviderResponse } from '@/providers/types' +import { calculateCost } from '@/providers/utils' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers', () => ({ + MAX_TOOL_ITERATIONS: 5, +})) + +/** input 0.30/M, cachedInput 0.03/M, output 2.50/M. */ +const MODEL = 'gemini-2.5-flash' + +function textTurn(usageMetadata: Record) { + return { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata, + } +} + +function toolTurn(usageMetadata: Record) { + return { + candidates: [ + { + content: { role: 'model', parts: [{ functionCall: { name: 'lookup', args: {} } }] }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata, + } +} + +async function run(generateContent: ReturnType, withTools = false) { + return (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream: vi.fn() } } as never, + model: MODEL, + providerType: 'google', + request: { + model: MODEL, + apiKey: 'test-key', + messages: [{ role: 'user', content: 'Look this up' }], + ...(withTools + ? { + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } + : {}), + }, + })) as ProviderResponse +} + +describe('Gemini block cost with implicit prompt caching', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + }) + + it('bills cached prompt tokens at the discounted rate, not the full input rate', async () => { + const generateContent = vi.fn().mockResolvedValue( + textTurn({ + promptTokenCount: 100_000, + cachedContentTokenCount: 80_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ) + + const response = await run(generateContent) + + expect(response.tokens).toEqual({ + input: 20_000, + output: 1_000, + cacheRead: 80_000, + total: 101_000, + }) + expect(response.cost?.input).toBeCloseTo(0.0084, 10) + expect(response.cost?.output).toBeCloseTo(0.0025, 10) + expect(response.cost?.total).toBeCloseTo(0.0109, 10) + + const cacheBlind = calculateCost(MODEL, 100_000, 1_000) + expect(cacheBlind.total).toBeCloseTo(0.0325, 10) + expect(response.cost?.total).toBeLessThan(cacheBlind.total) + }) + + it('accumulates the cached and uncached buckets separately across tool-loop turns', async () => { + const generateContent = vi + .fn() + .mockResolvedValueOnce( + toolTurn({ + promptTokenCount: 10_000, + cachedContentTokenCount: 6_000, + candidatesTokenCount: 100, + totalTokenCount: 10_100, + }) + ) + .mockResolvedValueOnce( + textTurn({ + promptTokenCount: 12_000, + cachedContentTokenCount: 9_000, + candidatesTokenCount: 200, + totalTokenCount: 12_200, + }) + ) + + const response = await run(generateContent, true) + + expect(generateContent).toHaveBeenCalledTimes(2) + expect(response.tokens).toEqual({ + input: 7_000, + output: 300, + cacheRead: 15_000, + total: 22_300, + }) + expect(response.cost?.input).toBeCloseTo(0.00255, 10) + expect(response.cost?.output).toBeCloseTo(0.00075, 10) + expect(response.cost?.total).toBeCloseTo(0.0033, 10) + }) + + it('matches plain calculateCost when the response reports no cache hit', async () => { + const generateContent = vi.fn().mockResolvedValue( + textTurn({ + promptTokenCount: 100_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ) + + const response = await run(generateContent) + + expect(response.tokens).toEqual({ + input: 100_000, + output: 1_000, + cacheRead: 0, + total: 101_000, + }) + expect(response.cost).toEqual(calculateCost(MODEL, 100_000, 1_000)) + }) +}) diff --git a/apps/sim/providers/gemini/core.streaming.test.ts b/apps/sim/providers/gemini/core.streaming.test.ts new file mode 100644 index 00000000000..c2af71ee05a --- /dev/null +++ b/apps/sim/providers/gemini/core.streaming.test.ts @@ -0,0 +1,324 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeGeminiRequest } from '@/providers/gemini/core' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers', () => ({ + MAX_TOOL_ITERATIONS: 1, +})) + +describe('executeGeminiRequest settled stream projection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps the required Gemini 2 schema extraction but does not regenerate for streaming', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const generateContent = vi + .fn() + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: {} } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: 'unformatted answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: '{"value":"formatted"}' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 3, + candidatesTokenCount: 3, + totalTokenCount: 6, + }, + }) + const generateContentStream = vi.fn() + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream } } as never, + model: 'gemini-2.5-flash', + providerType: 'google', + request: { + model: 'gemini-2.5-flash', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + }, + })) as StreamingExecution + + expect(generateContent).toHaveBeenCalledTimes(3) + expect(generateContentStream).not.toHaveBeenCalled() + expect(generateContent.mock.calls[2][0].config).toMatchObject({ + tools: undefined, + toolConfig: undefined, + responseMimeType: 'application/json', + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: '{"value":"formatted"}', turn: 'final' }, + }) + expect(result.execution.output.content).toBe('{"value":"formatted"}') + }) + + it('runs one schema synthesis after the tool-batch cap without executing over-cap calls', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const responseSchema = { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + } + const generateContent = vi + .fn() + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: { batch: 1 } } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: { batch: 1 } }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: { batch: 2 } } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: { batch: 2 } }], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: '{"value":"capped"}' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 3, + candidatesTokenCount: 3, + totalTokenCount: 6, + }, + }) + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream: vi.fn() } } as never, + model: 'gemini-2.5-flash', + providerType: 'google', + request: { + model: 'gemini-2.5-flash', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Keep looking this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { + type: 'object', + properties: { batch: { type: 'number' } }, + required: ['batch'], + }, + }, + ], + responseFormat: { + name: 'answer', + schema: responseSchema, + }, + }, + })) as StreamingExecution + + expect(generateContent).toHaveBeenCalledTimes(3) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect(mockExecuteTool).toHaveBeenCalledWith( + 'lookup', + expect.objectContaining({ batch: 1 }), + expect.any(Object) + ) + expect(generateContent.mock.calls[2][0].config).toMatchObject({ + tools: undefined, + toolConfig: undefined, + responseMimeType: 'application/json', + responseSchema, + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: '{"value":"capped"}', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + expect(result.execution.output.content).toBe('{"value":"capped"}') + expect(result.execution.output.tokens).toEqual({ + input: 6, + output: 6, + cacheRead: 0, + total: 12, + }) + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + }) + + it.each(['google', 'vertex'] as const)( + 'uses the live loop for %s streaming tool requests without a caller flag', + async (providerType) => { + mockExecuteTool.mockResolvedValue({ success: true, output: false }) + + const toolTurn = { + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: {} } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + } + const answerTurn = { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'live answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + } + const generateContent = vi.fn() + const generateContentStream = vi + .fn() + .mockResolvedValueOnce( + (async function* () { + yield toolTurn + })() + ) + .mockResolvedValueOnce( + (async function* () { + yield answerTurn + })() + ) + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream } } as never, + model: 'gemini-3-flash-preview', + providerType, + request: { + model: 'gemini-3-flash-preview', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + })) as StreamingExecution + + const events: unknown[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + + expect(generateContent).not.toHaveBeenCalled() + expect(generateContentStream).toHaveBeenCalledTimes(2) + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(result.execution.output.content).toBe('live answer') + } + ) +}) diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index e634b6d0768..2a9177af5f2 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -13,9 +13,11 @@ import { } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' +import { priceGeminiTokens, splitGeminiTokens, splitGeminiUsage } from '@/providers/gemini/usage' import { checkForForcedToolUsage, cleanSchemaForGemini, @@ -29,7 +31,9 @@ import { mapToThinkingLevel, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError } from '@/providers/streaming-tool-loop-shared' import { ensureToolCallId } from '@/providers/tool-call-id' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { @@ -39,7 +43,6 @@ import type { TimeSegment, } from '@/providers/types' import { - calculateCost, isDeepResearchModel, isGemini3Model, prepareToolExecution, @@ -60,20 +63,12 @@ function createInitialState( model: string, toolConfig: ToolConfig | undefined ): ExecutionState { - const initialCost = calculateCost( - model, - initialUsage.promptTokenCount, - initialUsage.candidatesTokenCount - ) + const split = splitGeminiUsage(initialUsage) return { contents, - tokens: { - input: initialUsage.promptTokenCount, - output: initialUsage.candidatesTokenCount, - total: initialUsage.totalTokenCount, - }, - cost: initialCost, + tokens: { ...split, total: initialUsage.totalTokenCount }, + cost: priceGeminiTokens(model, split), toolCalls: [], toolResults: [], iterationCount: 0, @@ -114,7 +109,7 @@ async function executeToolCallsBatch( const toolCallStartTime = Date.now() const functionCall = part.functionCall! const toolName = functionCall.name ?? '' - const args = (functionCall.args ?? {}) as Record + const args = isRecordLike(functionCall.args) ? functionCall.args : undefined const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -133,6 +128,10 @@ async function executeToolCallsBatch( } try { + if (!args) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const { toolParams, executionParams } = prepareToolExecution(tool, args, request) const result = await executeTool(toolName, executionParams, { signal: request.abortSignal, @@ -157,6 +156,10 @@ async function executeToolCallsBatch( duration, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + const toolCallEndTime = Date.now() logger.error('Error processing function call:', { error: toError(error).message, @@ -166,7 +169,7 @@ async function executeToolCallsBatch( success: false, part, toolName, - args, + args: args ?? {}, resultContent: { error: true, message: getErrorMessage(error, 'Tool execution failed'), @@ -221,8 +224,8 @@ async function executeToolCallsBatch( result: r.resultContent, }) - if (r.success && r.result?.output) { - newToolResults.push(r.result.output as Record) + if (r.success && isRecordLike(r.result?.output)) { + newToolResults.push(r.result.output) } newTimeSegments.push({ @@ -272,14 +275,16 @@ function updateStateWithResponse( endTime: number ): ExecutionState { const usage = convertUsageMetadata(response.usageMetadata) - const cost = calculateCost(model, usage.promptTokenCount, usage.candidatesTokenCount) + const split = splitGeminiUsage(usage) + const cost = priceGeminiTokens(model, split) const duration = endTime - startTime return { ...state, tokens: { - input: state.tokens.input + usage.promptTokenCount, - output: state.tokens.output + usage.candidatesTokenCount, + input: state.tokens.input + split.input, + output: state.tokens.output + split.output, + cacheRead: state.tokens.cacheRead + split.cacheRead, total: state.tokens.total + usage.totalTokenCount, }, cost: { @@ -299,7 +304,6 @@ function updateStateWithResponse( duration, }, ], - iterationCount: state.iterationCount + 1, } } @@ -355,7 +359,7 @@ function createStreamingResult( output: { content: '', model: '', - tokens: state?.tokens ?? { input: 0, output: 0, total: 0 }, + tokens: state?.tokens ?? { input: 0, output: 0, cacheRead: 0, total: 0 }, toolCalls: state?.toolCalls.length ? { list: state.toolCalls, count: state.toolCalls.length } : undefined, @@ -367,7 +371,9 @@ function createStreamingResult( modelTime: state?.modelTime ?? firstResponseTime, toolsTime: state?.toolsTime ?? 0, firstResponseTime, - iterations: (state?.iterationCount ?? 0) + 1, + iterations: state + ? state.timeSegments.filter((segment) => segment.type === 'model').length + : 1, timeSegments: state?.timeSegments ?? [ { type: 'model', @@ -488,22 +494,30 @@ function extractTextFromInteractionOutputs(outputs: Interactions.Interaction['ou return textParts.join('\n\n') } +/** Token usage for one deep research interaction. */ +interface DeepResearchUsage { + inputTokens: number + outputTokens: number + reasoningTokens: number + cachedTokens: number + totalTokens: number +} + /** * Extracts token usage from an Interaction's Usage object. * The Interactions API provides total_input_tokens, total_output_tokens, total_tokens, - * and total_reasoning_tokens (for thinking models). + * total_cached_tokens, and total_reasoning_tokens (for thinking models). * * Also handles the raw API field name total_thought_tokens which the SDK may * map to total_reasoning_tokens. + * + * The Interactions API supports implicit caching, and `total_cached_tokens` is a + * subset of `total_input_tokens` there just as `cachedContentTokenCount` is of + * `promptTokenCount` on generateContent. */ -function extractInteractionUsage(usage: Interactions.Usage | undefined): { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number -} { +function extractInteractionUsage(usage: Interactions.Usage | undefined): DeepResearchUsage { if (!usage) { - return { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 } + return { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cachedTokens: 0, totalTokens: 0 } } const usageLogger = createLogger('DeepResearchUsage') @@ -515,9 +529,10 @@ function extractInteractionUsage(usage: Interactions.Usage | undefined): { usage.total_reasoning_tokens ?? ((usage as Record).total_thought_tokens as number) ?? 0 + const cachedTokens = usage.total_cached_tokens ?? 0 const totalTokens = usage.total_tokens ?? inputTokens + outputTokens - return { inputTokens, outputTokens, reasoningTokens, totalTokens } + return { inputTokens, outputTokens, reasoningTokens, cachedTokens, totalTokens } } /** @@ -526,25 +541,22 @@ function extractInteractionUsage(usage: Interactions.Usage | undefined): { function buildDeepResearchResponse( content: string, model: string, - usage: { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number - }, + usage: DeepResearchUsage, providerStartTime: number, providerStartTimeISO: string, interactionId?: string ): ProviderResponse { const providerEndTime = Date.now() const duration = providerEndTime - providerStartTime + const split = splitGeminiTokens(usage.inputTokens, usage.outputTokens, usage.cachedTokens) return { content, model, tokens: { - input: usage.inputTokens, - output: usage.outputTokens, + input: split.input, + output: split.output, + cacheRead: split.cacheRead, total: usage.totalTokens, }, timing: { @@ -565,7 +577,7 @@ function buildDeepResearchResponse( }, ], }, - cost: calculateCost(model, usage.inputTokens, usage.outputTokens), + cost: priceGeminiTokens(model, split), interactionId, } } @@ -584,20 +596,17 @@ function buildDeepResearchResponse( */ function createDeepResearchStream( stream: AsyncIterable, - onComplete?: ( - content: string, - usage: { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number - }, - interactionId?: string - ) => void + onComplete?: (content: string, usage: DeepResearchUsage, interactionId?: string) => void ): ReadableStream { const streamLogger = createLogger('DeepResearchStream') let fullContent = '' - let completionUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 } + let completionUsage: DeepResearchUsage = { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cachedTokens: 0, + totalTokens: 0, + } let completedInteractionId: string | undefined return new ReadableStream({ @@ -769,16 +778,12 @@ export async function executeDeepResearchRequest( const stream = createDeepResearchStream( streamResponse, (content, usage, streamInteractionId) => { + const split = splitGeminiTokens(usage.inputTokens, usage.outputTokens, usage.cachedTokens) + streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.inputTokens, - output: usage.outputTokens, - total: usage.totalTokens, - } + streamingResult.execution.output.tokens = { ...split, total: usage.totalTokens } streamingResult.execution.output.interactionId = streamInteractionId - - const cost = calculateCost(model, usage.inputTokens, usage.outputTokens) - streamingResult.execution.output.cost = cost + streamingResult.execution.output.cost = priceGeminiTokens(model, split) const streamEndTime = Date.now() if (streamingResult.execution.output.providerTiming) { @@ -1031,13 +1036,14 @@ export async function executeGeminiRequest( * no calls and skip the schema. Gemini 3 carries responseJsonSchema * alongside tools, so its live loop keeps structured output. */ - const responseFormatNeedsFinalPass = Boolean(request.responseFormat) && !isGemini3Model(model) - const shouldStreamToolCalls = - (request.streamToolCalls ?? false) && !responseFormatNeedsFinalPass - const shouldStream = request.stream && !tools?.length + const hasActiveTools = Boolean(geminiConfig.tools?.length) + const responseFormatNeedsFinalPass = + Boolean(request.responseFormat) && hasActiveTools && !isGemini3Model(model) + const liveToolLoopSupported = !responseFormatNeedsFinalPass + const shouldStream = request.stream && !hasActiveTools // Live streaming tool loop - if (request.stream && shouldStreamToolCalls && tools?.length) { + if (request.stream && liveToolLoopSupported && hasActiveTools) { logger.info('Using streaming tool loop for Gemini request') const timeSegments: TimeSegment[] = [] @@ -1109,19 +1115,11 @@ export async function executeGeminiRequest( const stream = createReadableStreamFromGeminiStream( streamGenerator, (content: string, usage: GeminiUsage, thinking?: string) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.promptTokenCount, - output: usage.candidatesTokenCount, - total: usage.totalTokenCount, - } + const split = splitGeminiUsage(usage) - const costResult = calculateCost( - model, - usage.promptTokenCount, - usage.candidatesTokenCount - ) - streamingResult.execution.output.cost = costResult + streamingResult.execution.output.content = content + streamingResult.execution.output.tokens = { ...split, total: usage.totalTokenCount } + streamingResult.execution.output.cost = priceGeminiTokens(model, split) if (thinking) { const segment = streamingResult.execution.output.providerTiming?.timeSegments?.[0] @@ -1175,6 +1173,64 @@ export async function executeGeminiRequest( let currentResponse = response let content = '' + const generateFinalSynthesis = async ( + currentState: ExecutionState, + baseConfig: GenerateContentConfig + ): Promise<{ state: ExecutionState; response: GenerateContentResponse }> => { + const finalConfig: GenerateContentConfig = { + ...baseConfig, + tools: undefined, + toolConfig: undefined, + } + if (request.responseFormat && !isGemini3Model(model)) { + finalConfig.responseMimeType = 'application/json' + finalConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema + } + + const finalStartTime = Date.now() + const finalResponse = await ai.models.generateContent({ + model, + contents: currentState.contents, + config: finalConfig, + }) + const finalState = updateStateWithResponse( + currentState, + finalResponse, + model, + finalStartTime, + Date.now() + ) + enrichLastModelSegmentFromGeminiResponse(finalState.timeSegments, finalResponse, { + model, + }) + return { state: finalState, response: finalResponse } + } + const createSettledStreamingResult = ( + currentState: ExecutionState, + settledAnswer: string + ): StreamingExecution => { + const toolCost = sumToolCosts(currentState.toolResults) + const streamingResult = createStreamingResult( + providerStartTime, + providerStartTimeISO, + firstResponseTime, + initialCallTime, + currentState + ) + streamingResult.execution.output.model = model + streamingResult.execution.output.content = settledAnswer + streamingResult.execution.output.cost = { + ...currentState.cost, + toolCost: toolCost || undefined, + total: currentState.cost.total + toolCost, + } + + return { + ...streamingResult, + stream: createSettledAgentEventStream(settledAnswer), + streamFormat: 'agent-events-v1', + } + } // Tool execution loop const functionCalls = response.functionCalls @@ -1182,7 +1238,7 @@ export async function executeGeminiRequest( const functionNames = functionCalls.map((fc) => fc.name).join(', ') logger.info(`Received ${functionCalls.length} function call(s) from Gemini: ${functionNames}`) - while (state.iterationCount < MAX_TOOL_ITERATIONS) { + while (true) { // Extract ALL function call parts from the response (Gemini can return multiple) const functionCallParts = extractAllFunctionCallParts(currentResponse.candidates?.[0]) if (functionCallParts.length === 0) { @@ -1190,6 +1246,27 @@ export async function executeGeminiRequest( break } + if (state.iterationCount >= MAX_TOOL_ITERATIONS) { + logger.info('Gemini tool-batch cap reached; generating a tool-disabled final response') + const finalConfig = buildNextConfig( + geminiConfig, + state, + forcedTools, + request, + logger, + model + ) + const finalSynthesis = await generateFinalSynthesis(state, finalConfig) + state = finalSynthesis.state + currentResponse = finalSynthesis.response + content = extractTextContent(finalSynthesis.response.candidates?.[0]) + + if (request.stream) { + return createSettledStreamingResult(state, content) + } + break + } + const callNames = functionCallParts.map((p) => p.functionCall?.name ?? 'unknown').join(', ') logger.info( `Processing ${functionCallParts.length} function call(s): ${callNames} (iteration ${state.iterationCount + 1})` @@ -1211,121 +1288,7 @@ export async function executeGeminiRequest( state = { ...updatedState, iterationCount: updatedState.iterationCount + 1 } const nextConfig = buildNextConfig(geminiConfig, state, forcedTools, request, logger, model) - // Stream final response if requested - if (request.stream) { - const checkResponse = await ai.models.generateContent({ - model, - contents: state.contents, - config: nextConfig, - }) - state = updateStateWithResponse(state, checkResponse, model, Date.now() - 100, Date.now()) - enrichLastModelSegmentFromGeminiResponse(state.timeSegments, checkResponse, { - model, - }) - - if (checkResponse.functionCalls?.length) { - currentResponse = checkResponse - continue - } - - logger.info('No more function calls, streaming final response') - - if (request.responseFormat) { - nextConfig.tools = undefined - nextConfig.toolConfig = undefined - if (!isGemini3Model(model)) { - nextConfig.responseMimeType = 'application/json' - nextConfig.responseSchema = cleanSchemaForGemini( - request.responseFormat.schema - ) as Schema - } - } else if (nextConfig.tools) { - /** - * The regeneration exists purely to stream the settled answer as - * prose — streamed function calls are never executed. With AUTO the - * model can re-decide to call a tool here, ending the stream with a - * dead functionCall and an empty answer. - */ - nextConfig.toolConfig = { - functionCallingConfig: { mode: FunctionCallingConfigMode.NONE }, - } - } - - /** Settled answer from the check response — kept if the stream ends without text. */ - const checkAnswer = extractTextContent(checkResponse.candidates?.[0]) - - // Capture accumulated cost before streaming - const accumulatedCost = { - input: state.cost.input, - output: state.cost.output, - total: state.cost.total, - } - const accumulatedTokens = { ...state.tokens } - - const streamGenerator = await ai.models.generateContentStream({ - model, - contents: state.contents, - config: nextConfig, - }) - - const streamingResult = createStreamingResult( - providerStartTime, - providerStartTimeISO, - firstResponseTime, - initialCallTime, - state - ) - streamingResult.execution.output.model = model - - const stream = createReadableStreamFromGeminiStream( - streamGenerator, - (streamContent: string, usage: GeminiUsage, thinking?: string) => { - if (!streamContent && checkAnswer) { - logger.warn('Gemini final stream produced no text; keeping tool-loop answer') - } - streamingResult.execution.output.content = streamContent || checkAnswer - streamingResult.execution.output.tokens = { - input: accumulatedTokens.input + usage.promptTokenCount, - output: accumulatedTokens.output + usage.candidatesTokenCount, - total: accumulatedTokens.total + usage.totalTokenCount, - } - - const streamCost = calculateCost( - model, - usage.promptTokenCount, - usage.candidatesTokenCount - ) - const tc = sumToolCosts(state.toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - pricing: streamCost.pricing, - } - - if (thinking) { - const segments = streamingResult.execution.output.providerTiming?.timeSegments - const lastModel = segments - ? [...segments].reverse().find((s) => s.type === 'model') - : undefined - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = new Date().toISOString() - streamingResult.execution.output.providerTiming.duration = - Date.now() - providerStartTime - } - } - ) - - return { ...streamingResult, stream, streamFormat: 'agent-events-v1' as const } - } - - // Non-streaming: get next response + /** Resolve the final turn, then project its settled answer when streaming was requested. */ const nextModelStartTime = Date.now() const nextResponse = await ai.models.generateContent({ model, @@ -1337,6 +1300,22 @@ export async function executeGeminiRequest( model, }) currentResponse = nextResponse + + if ( + request.stream && + extractAllFunctionCallParts(nextResponse.candidates?.[0]).length === 0 + ) { + let settledResponse = nextResponse + if (responseFormatNeedsFinalPass) { + logger.info('Generating final schema-configured Gemini response') + const finalSynthesis = await generateFinalSynthesis(state, nextConfig) + state = finalSynthesis.state + settledResponse = finalSynthesis.response + } + + const settledAnswer = extractTextContent(settledResponse.candidates?.[0]) + return createSettledStreamingResult(state, settledAnswer) + } } if (!content) { @@ -1361,7 +1340,7 @@ export async function executeGeminiRequest( modelTime: state.modelTime, toolsTime: state.toolsTime, firstResponseTime, - iterations: state.iterationCount + 1, + iterations: state.timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: state.timeSegments, }, cost: state.cost, @@ -1375,6 +1354,10 @@ export async function executeGeminiRequest( stack: error instanceof Error ? error.stack : undefined, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + const enhancedError = toError(error) Object.assign(enhancedError, { timing: { @@ -1423,7 +1406,7 @@ function enrichLastModelSegmentFromGeminiResponse( })) const usage = convertUsageMetadata(response.usageMetadata) - const cachedContentTokens = response.usageMetadata?.cachedContentTokenCount ?? 0 + const split = splitGeminiUsage(usage) const thoughtsTokens = response.usageMetadata?.thoughtsTokenCount ?? 0 let cost: { input: number; output: number; total: number } | undefined @@ -1433,12 +1416,7 @@ function enrichLastModelSegmentFromGeminiResponse( typeof usage.promptTokenCount === 'number' && typeof usage.candidatesTokenCount === 'number' ) { - const full = calculateCost( - extras.model, - usage.promptTokenCount, - usage.candidatesTokenCount, - cachedContentTokens > 0 - ) + const full = priceGeminiTokens(extras.model, split) cost = { input: full.input, output: full.output, total: full.total } } @@ -1452,7 +1430,7 @@ function enrichLastModelSegmentFromGeminiResponse( input: usage.promptTokenCount, output: usage.candidatesTokenCount, total: usage.totalTokenCount, - ...(cachedContentTokens > 0 && { cacheRead: cachedContentTokens }), + ...(split.cacheRead > 0 && { cacheRead: split.cacheRead }), ...(thoughtsTokens > 0 && { reasoning: thoughtsTokens }), } : undefined, diff --git a/apps/sim/providers/gemini/streaming-tool-loop.test.ts b/apps/sim/providers/gemini/streaming-tool-loop.test.ts index 5b2e34244ab..e9b4a020f18 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.test.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' import type { AgentStreamEvent } from '@/providers/stream-events' import { resetLocalToolIdCounterForTests } from '@/providers/tool-call-id' @@ -19,11 +19,12 @@ async function collectEvents( return events } +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + vi.mock('@/tools', () => ({ - executeTool: vi.fn(async () => ({ - success: true, - output: { ok: true, url: 'https://httpbin.org/get' }, - })), + executeTool: mockExecuteTool, })) vi.mock('@/providers/utils', () => ({ @@ -39,10 +40,19 @@ vi.mock('@/providers/utils', () => ({ })), sumToolCosts: vi.fn(() => 0), isGemini3Model: vi.fn(() => false), + shouldBillModelUsage: vi.fn(() => true), trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), })) describe('createGeminiStreamingToolLoopStream', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ + success: true, + output: { ok: true, url: 'https://httpbin.org/get' }, + }) + }) + it('emits thinking, tool lifecycle, then final answer; allocates local tool ids', async () => { resetLocalToolIdCounterForTests() @@ -165,4 +175,209 @@ describe('createGeminiStreamingToolLoopStream', () => { }) ) }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const ai = { + models: { + generateContentStream: vi.fn(async () => + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'http_request', + args: { url: 'https://httpbin.org/get' }, + }, + }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + } as any + })() + ), + }, + } + const onComplete = vi.fn() + const stream = createGeminiStreamingToolLoopStream({ + ai: ai as any, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 10, output: 5, cacheRead: 0, total: 15 } }) + ) + }) + + it('overrides the turn signal and aborts the active SDK call on consumer cancellation', async () => { + const baseAbortController = new AbortController() + const requestAbortController = new AbortController() + let capturedSignal: AbortSignal | undefined + let resolveCallStarted: (() => void) | undefined + const callStarted = new Promise((resolve) => { + resolveCallStarted = resolve + }) + const generateContentStream = vi.fn( + async ({ config }: { config: { abortSignal?: AbortSignal } }) => { + capturedSignal = config.abortSignal + resolveCallStarted?.() + return await new Promise((_, reject) => { + config.abortSignal?.addEventListener( + 'abort', + () => reject(new DOMException('SDK request aborted', 'AbortError')), + { once: true } + ) + }) + } + ) + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: { abortSignal: baseAbortController.signal }, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + abortSignal: requestAbortController.signal, + } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete: vi.fn(), + }) + const reader = stream.getReader() + const pendingRead = reader.read() + + await callStarted + expect(capturedSignal).toBeDefined() + expect(capturedSignal).not.toBe(baseAbortController.signal) + expect(capturedSignal).not.toBe(requestAbortController.signal) + expect(capturedSignal?.aborted).toBe(false) + + await reader.cancel('consumer cancelled') + + expect(capturedSignal?.aborted).toBe(true) + expect(capturedSignal?.reason).toBe('consumer cancelled') + await expect(pendingRead).resolves.toEqual({ done: true, value: undefined }) + }) + + it('accepts terminal MAX_TOKENS text when no function call is pending', async () => { + const generateContentStream = vi.fn(async () => + (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'Complete answer at the token limit' }] }, + finishReason: 'MAX_TOKENS', + }, + ], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 6, + totalTokenCount: 10, + }, + } as any + })() + ) + const onComplete = vi.fn() + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'answer fully' }] }], + request: { model: 'gemini-2.5-flash' } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: 'Complete answer at the token limit', + iterations: 1, + }) + ) + }) + + it.each([ + { + label: 'complete', + parts: [ + { text: 'Partial answer' }, + { functionCall: { name: 'http_request', args: { url: 'https://httpbin.org/get' } } }, + ], + }, + { + label: 'partial', + parts: [{ text: 'Partial answer' }, { functionCall: { args: {} } }], + }, + ])('rejects a $label function call on a MAX_TOKENS turn', async ({ parts }) => { + const generateContentStream = vi.fn(async () => + (async function* () { + yield { + candidates: [{ content: { parts }, finishReason: 'MAX_TOKENS' }], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 6, + totalTokenCount: 10, + }, + } as any + })() + ) + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'answer fully' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete: vi.fn(), + }) + + await expect(collectEvents(stream)).rejects.toThrow( + 'Gemini stream ended with finish reason MAX_TOKENS' + ) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts index 2af1501ef33..f3270a5471e 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -24,6 +24,7 @@ import { } from '@google/genai' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { IterationToolCall } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { @@ -37,18 +38,19 @@ import { isAbortError, type StreamingToolLoopComplete, settleOpenTools, + terminateToolLoop, } from '@/providers/streaming-tool-loop-shared' import { ensureToolCallId } from '@/providers/tool-call-id' import { enrichLastModelSegment } from '@/providers/trace-enrichment' -import type { ProviderRequest, TimeSegment } from '@/providers/types' -import { - calculateCost, - isGemini3Model, - prepareToolExecution, - sumToolCosts, -} from '@/providers/utils' +import type { ModelPricing, ProviderRequest, TimeSegment } from '@/providers/types' +import { isGemini3Model, prepareToolExecution, sumToolCosts } from '@/providers/utils' import { executeTool } from '@/tools' import type { GeminiUsage } from './types' +import { priceGeminiTokens, splitGeminiUsage } from './usage' + +type GeminiStreamingToolLoopComplete = Omit & { + tokens: { input: number; output: number; cacheRead: number; total: number } +} export interface CreateGeminiStreamingToolLoopStreamOptions { ai: GoogleGenAI @@ -60,7 +62,7 @@ export interface CreateGeminiStreamingToolLoopStreamOptions { timeSegments: TimeSegment[] forcedTools?: string[] toolConfig?: ToolConfig - onComplete: (result: StreamingToolLoopComplete) => void + onComplete: (result: GeminiStreamingToolLoopComplete) => void } /** @@ -109,70 +111,99 @@ function buildNextConfig( async function drainGeminiTurn( stream: AsyncGenerator, controller: ReadableStreamDefaultController, - openTools: Map + openTools: Map, + onIteratorChange: ( + iterator: AsyncIterator | undefined + ) => void = () => {} ): Promise<{ text: string thinking: string functionCalls: StreamedFunctionCall[] + hasFunctionCallPart: boolean usage: GeminiUsage finishReason?: string }> { let text = '' let thinking = '' const functionCalls: StreamedFunctionCall[] = [] + let hasFunctionCallPart = false const seenKeys = new Set() - let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } + let usage: GeminiUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + cachedContentTokenCount: 0, + totalTokenCount: 0, + } let finishReason: string | undefined - for await (const chunk of stream) { - if (chunk.usageMetadata) { - usage = convertUsageMetadata(chunk.usageMetadata) - } - - const candidate = chunk.candidates?.[0] - if (candidate?.finishReason) { - finishReason = String(candidate.finishReason) - } + const iterator = stream[Symbol.asyncIterator]() + onIteratorChange(iterator) + try { + while (true) { + const next = await iterator.next() + if (next.done) break + const chunk = next.value + if (chunk.promptFeedback?.blockReason) { + throw new Error( + `Gemini prompt blocked: ${chunk.promptFeedback.blockReason}${ + chunk.promptFeedback.blockReasonMessage + ? ` (${chunk.promptFeedback.blockReasonMessage})` + : '' + }` + ) + } + if (chunk.usageMetadata) { + usage = convertUsageMetadata(chunk.usageMetadata) + } - const parts = candidate?.content?.parts - if (!Array.isArray(parts)) { - const fallback = chunk.text - if (fallback) { - text += fallback - controller.enqueue({ type: 'text_delta', text: fallback, turn: 'pending' }) + const candidate = chunk.candidates?.[0] + if (candidate?.finishReason) { + finishReason = String(candidate.finishReason) } - continue - } - for (const part of parts) { - if (part.functionCall) { - const localId = ensureToolCallId(part.functionCall.id, 'gemini') - const name = part.functionCall.name ?? '' - if (!seenKeys.has(localId) && name) { - seenKeys.add(localId) - functionCalls.push({ part, localId }) - if (!openTools.has(localId)) { - openTools.set(localId, name) - controller.enqueue({ type: 'tool_call_start', id: localId, name }) - } + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + const fallback = chunk.text + if (fallback) { + text += fallback + controller.enqueue({ type: 'text_delta', text: fallback, turn: 'pending' }) } continue } - if (!part.text) continue - if (part.thought === true) { - thinking += part.text - controller.enqueue({ type: 'thinking_delta', text: part.text }) - } else { - text += part.text - // Live pending text: sinks render it now; the pump projects it to the - // answer only when this turn's turn_end says 'final'. - controller.enqueue({ type: 'text_delta', text: part.text, turn: 'pending' }) + for (const part of parts) { + if (part.functionCall) { + hasFunctionCallPart = true + const localId = ensureToolCallId(part.functionCall.id, 'gemini') + const name = part.functionCall.name ?? '' + if (!seenKeys.has(localId) && name) { + seenKeys.add(localId) + functionCalls.push({ part, localId }) + if (!openTools.has(localId)) { + openTools.set(localId, name) + controller.enqueue({ type: 'tool_call_start', id: localId, name }) + } + } + continue + } + + if (!part.text) continue + if (part.thought === true) { + thinking += part.text + controller.enqueue({ type: 'thinking_delta', text: part.text }) + } else { + text += part.text + // Live pending text: sinks render it now; the pump projects it to the + // answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: part.text, turn: 'pending' }) + } } } + } finally { + onIteratorChange(undefined) } - return { text, thinking, functionCalls, usage, finishReason } + return { text, thinking, functionCalls, hasFunctionCallPart, usage, finishReason } } /** @@ -192,345 +223,429 @@ export function createGeminiStreamingToolLoopStream( onComplete, } = options const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeStreamIterator: AsyncIterator | undefined + let consumerCancelled = false - return new ReadableStream({ - async start(controller) { - let contents = [...initialContents] - let currentToolConfig = options.toolConfig - let usedForcedTools: string[] = [] - - let content = '' - let iterationCount = 0 - let modelCalls = 0 - let sawFinalTurn = false - let modelTime = 0 - let toolsTime = 0 - let firstResponseTime = 0 - const tokens = { input: 0, output: 0, total: 0 } - let costInput = 0 - let costOutput = 0 - let costTotal = 0 - let latestPricing: ReturnType['pricing'] | undefined - const toolCalls: unknown[] = [] - const toolResults: Record[] = [] - const openToolStarts = new Map() - - try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (request.abortSignal?.aborted) { - settleOpenTools(controller, openToolStarts, 'cancelled') - throw new DOMException('Stream aborted', 'AbortError') - } + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } - const turnConfig = buildNextConfig( - baseConfig, - currentToolConfig, - usedForcedTools, - forcedTools, - request, - logger, - model - ) - - const modelStart = Date.now() - const streamGenerator = await ai.models.generateContentStream({ - model, - contents, - config: turnConfig, + return new ReadableStream({ + start(controller) { + void (async () => { + let contents = [...initialContents] + let currentToolConfig = options.toolConfig + let usedForcedTools: string[] = [] + + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, cacheRead: 0, total: 0 } + let costInput = 0 + let costOutput = 0 + let costTotal = 0 + let latestPricing: ModelPricing | undefined + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, }) + } - const drained = await drainGeminiTurn(streamGenerator, controller, openToolStarts) - const modelEnd = Date.now() - const thisModelTime = modelEnd - modelStart - modelTime += thisModelTime - modelCalls++ - if (iterationCount === 0) { - firstResponseTime = thisModelTime - } + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + if (!consumerCancelled) { + settleOpenTools(controller, openToolStarts, 'cancelled') + } + throw new DOMException('Stream aborted', 'AbortError') + } - timeSegments.push({ - type: 'model', - name: model, - startTime: modelStart, - endTime: modelEnd, - duration: thisModelTime, - }) + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + const turnConfig = finalSynthesis + ? { ...baseConfig, tools: undefined, toolConfig: undefined } + : buildNextConfig( + baseConfig, + currentToolConfig, + usedForcedTools, + forcedTools, + request, + logger, + model + ) + + const modelStart = Date.now() + const streamGenerator = await ai.models.generateContentStream({ + model, + contents, + config: { + ...turnConfig, + abortSignal: loopAbortController.signal, + }, + }) + + const drained = await drainGeminiTurn( + streamGenerator, + controller, + openToolStarts, + (iterator) => { + activeStreamIterator = iterator + } + ) + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } - tokens.input += drained.usage.promptTokenCount - tokens.output += drained.usage.candidatesTokenCount - tokens.total += drained.usage.totalTokenCount - - const turnCost = calculateCost( - model, - drained.usage.promptTokenCount, - drained.usage.candidatesTokenCount - ) - costInput += turnCost.input - costOutput += turnCost.output - costTotal += turnCost.total - latestPricing = turnCost.pricing - - const turnTag = drained.functionCalls.length > 0 ? 'intermediate' : 'final' - controller.enqueue({ type: 'turn_end', turn: turnTag }) - if (drained.text) { + timeSegments.push({ + type: 'model', + name: model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + const split = splitGeminiUsage(drained.usage) + tokens.input += split.input + tokens.output += split.output + tokens.cacheRead += split.cacheRead + tokens.total += drained.usage.totalTokenCount + + const turnCost = priceGeminiTokens(model, split) + costInput += turnCost.input + costOutput += turnCost.output + costTotal += turnCost.total + latestPricing = turnCost.pricing + + const isCompleteTurn = + drained.finishReason === 'STOP' || + (drained.finishReason === 'MAX_TOKENS' && + Boolean(drained.text) && + !drained.hasFunctionCallPart) + if (!isCompleteTurn) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Gemini stream ended with finish reason ${drained.finishReason ?? 'missing'}` + ) + } + if (finalSynthesis && drained.functionCalls.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Gemini returned function calls during final synthesis') + } + + const turnTag = drained.functionCalls.length > 0 ? 'intermediate' : 'final' + controller.enqueue({ type: 'turn_end', turn: turnTag }) content = drained.text - } - const toolCallsForEnrich: IterationToolCall[] = drained.functionCalls - .filter((fc) => Boolean(fc.part.functionCall)) - .map((fc) => ({ - id: fc.localId, - name: fc.part.functionCall?.name ?? '', - arguments: (fc.part.functionCall?.args ?? {}) as Record, - })) + const toolCallsForEnrich: IterationToolCall[] = drained.functionCalls + .filter((fc) => Boolean(fc.part.functionCall)) + .map((fc) => ({ + id: fc.localId, + name: fc.part.functionCall?.name ?? '', + arguments: (fc.part.functionCall?.args ?? {}) as Record, + })) + + enrichLastModelSegment(timeSegments, { + assistantContent: drained.text || undefined, + thinkingContent: drained.thinking || undefined, + toolCalls: toolCallsForEnrich.length > 0 ? toolCallsForEnrich : undefined, + finishReason: drained.finishReason, + tokens: { + input: drained.usage.promptTokenCount, + output: drained.usage.candidatesTokenCount, + total: drained.usage.totalTokenCount, + ...(split.cacheRead > 0 && { cacheRead: split.cacheRead }), + }, + cost: { + input: turnCost.input, + output: turnCost.output, + total: turnCost.total, + }, + provider: 'google', + }) - enrichLastModelSegment(timeSegments, { - assistantContent: drained.text || undefined, - thinkingContent: drained.thinking || undefined, - toolCalls: toolCallsForEnrich.length > 0 ? toolCallsForEnrich : undefined, - finishReason: drained.finishReason, - tokens: { - input: drained.usage.promptTokenCount, - output: drained.usage.candidatesTokenCount, - total: drained.usage.totalTokenCount, - }, - cost: { - input: turnCost.input, - output: turnCost.output, - total: turnCost.total, - }, - provider: 'google', - }) + const forcedCheck = checkForForcedToolUsage( + drained.functionCalls + .map((fc) => fc.part.functionCall) + .filter((fc): fc is NonNullable => Boolean(fc)), + currentToolConfig, + forcedTools, + usedForcedTools + ) + if (forcedCheck) { + usedForcedTools = forcedCheck.usedForcedTools + currentToolConfig = forcedCheck.nextToolConfig + } - const forcedCheck = checkForForcedToolUsage( - drained.functionCalls - .map((fc) => fc.part.functionCall) - .filter((fc): fc is NonNullable => Boolean(fc)), - currentToolConfig, - forcedTools, - usedForcedTools - ) - if (forcedCheck) { - usedForcedTools = forcedCheck.usedForcedTools - currentToolConfig = forcedCheck.nextToolConfig - } + if (drained.functionCalls.length === 0) { + sawFinalTurn = true + break + } - if (drained.functionCalls.length === 0) { - sawFinalTurn = true - break - } + const toolsStartTime = Date.now() - const toolsStartTime = Date.now() + const orderedResults = await Promise.all( + drained.functionCalls.map(async ({ part, localId }) => { + const functionCall = part.functionCall! + const toolCallId = localId + const toolName = functionCall.name ?? '' + const toolArgs = isRecordLike(functionCall.args) ? functionCall.args : undefined + const toolCallStartTime = Date.now() - const orderedResults = await Promise.all( - drained.functionCalls.map(async ({ part, localId }) => { - const functionCall = part.functionCall! - const toolCallId = localId - const toolName = functionCall.name ?? '' - const toolArgs = (functionCall.args ?? {}) as Record - const toolCallStartTime = Date.now() + try { + if (loopAbortController.signal.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } - try { - if (request.abortSignal?.aborted) { - throw new DOMException('Stream aborted', 'AbortError') - } + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + part, + toolCallId, + toolName, + toolArgs, + toolParams: {} as Record, + resultContent: { + error: true, + message: `Tool ${toolName} not found`, + tool: toolName, + }, + result: undefined as + | { success: boolean; output?: unknown; error?: string } + | undefined, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + success: false, + } + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'error', + }) + return value + } - const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) { - const value = { + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: loopAbortController.signal, + }) + const toolCallEndTime = Date.now() + const resultContent: Record = result.success + ? ensureStructResponse(result.output) + : { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + const status: ToolCallEndStatus = result.success ? 'success' : 'error' + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status, + }) + return { part, toolCallId, toolName, toolArgs, - toolParams: {} as Record, - resultContent: { - error: true, - message: `Tool ${toolName} not found`, - tool: toolName, - }, - result: undefined as - | { success: boolean; output?: unknown; error?: string } - | undefined, + toolParams, + resultContent, + result, startTime: toolCallStartTime, - endTime: Date.now(), - duration: Date.now() - toolCallStartTime, - status: 'error' as ToolCallEndStatus, - success: false, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: result.success, + } + } catch (error) { + const toolCallEndTime = Date.now() + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolCallId) + if (!consumerCancelled) { + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'cancelled', + }) + } + throw error } + if (isAbortError(error)) { + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing function call:', { + error: toError(error).message, + functionName: toolName, + }) + const status: ToolCallEndStatus = 'error' openToolStarts.delete(toolCallId) controller.enqueue({ type: 'tool_call_end', id: toolCallId, name: toolName, - status: 'error', + status, }) - return value - } - - const { toolParams, executionParams } = prepareToolExecution( - tool, - toolArgs, - request - ) - const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, - }) - const toolCallEndTime = Date.now() - const resultContent: Record = result.success - ? ensureStructResponse(result.output) - : { + return { + part, + toolCallId, + toolName, + toolArgs: toolArgs ?? {}, + toolParams: {} as Record, + resultContent: { error: true, - message: result.error || 'Tool execution failed', + message: getErrorMessage(error, 'Tool execution failed'), tool: toolName, - } - const status: ToolCallEndStatus = result.success ? 'success' : 'error' - openToolStarts.delete(toolCallId) - controller.enqueue({ - type: 'tool_call_end', - id: toolCallId, - name: toolName, - status, - }) - return { - part, - toolCallId, - toolName, - toolArgs, - toolParams, - resultContent, - result, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - status, - success: result.success, - } - } catch (error) { - const toolCallEndTime = Date.now() - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - if (!cancelled) { - logger.error('Error processing function call:', { - error: toError(error).message, - functionName: toolName, - }) - } - const status: ToolCallEndStatus = cancelled ? 'cancelled' : 'error' - openToolStarts.delete(toolCallId) - controller.enqueue({ - type: 'tool_call_end', - id: toolCallId, - name: toolName, - status, - }) - return { - part, - toolCallId, - toolName, - toolArgs, - toolParams: {} as Record, - resultContent: { - error: true, - message: getErrorMessage(error, 'Tool execution failed'), - tool: toolName, - }, - result: undefined, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - status, - success: false, + }, + result: undefined, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: false, + } } + }) + ) + + toolsTime += Date.now() - toolsStartTime + + /** + * Echo the model's functionCall parts verbatim (signatures and any + * model-provided ids must round-trip untouched). A functionResponse + * id is attached only when the model itself provided one. + */ + const modelParts: Part[] = orderedResults.map((r) => r.part) + const userParts: Part[] = orderedResults.map((r) => ({ + functionResponse: { + name: r.toolName, + response: r.resultContent, + ...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}), + }, + })) + + contents = [ + ...contents, + { role: 'model', parts: modelParts }, + { role: 'user', parts: userParts }, + ] + + for (const r of orderedResults) { + toolCalls.push({ + name: r.toolName, + arguments: r.toolParams, + startTime: new Date(r.startTime).toISOString(), + endTime: new Date(r.endTime).toISOString(), + duration: r.duration, + result: r.resultContent, + success: r.success, + }) + if ( + r.success && + r.result?.output && + typeof r.result.output === 'object' && + !Array.isArray(r.result.output) + ) { + toolResults.push(r.result.output as Record) } - }) - ) - - toolsTime += Date.now() - toolsStartTime - - /** - * Echo the model's functionCall parts verbatim (signatures and any - * model-provided ids must round-trip untouched). A functionResponse - * id is attached only when the model itself provided one. - */ - const modelParts: Part[] = orderedResults.map((r) => r.part) - const userParts: Part[] = orderedResults.map((r) => ({ - functionResponse: { - name: r.toolName, - response: r.resultContent, - ...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}), - }, - })) - - contents = [ - ...contents, - { role: 'model', parts: modelParts }, - { role: 'user', parts: userParts }, - ] - - for (const r of orderedResults) { - toolCalls.push({ - name: r.toolName, - arguments: r.toolParams, - startTime: new Date(r.startTime).toISOString(), - endTime: new Date(r.endTime).toISOString(), - duration: r.duration, - result: r.resultContent, - success: r.success, - }) - if (r.success && r.result?.output) { - toolResults.push(r.result.output as Record) + timeSegments.push({ + type: 'tool', + name: r.toolName, + startTime: r.startTime, + endTime: r.endTime, + duration: r.duration, + toolCallId: r.toolCallId, + }) } - timeSegments.push({ - type: 'tool', - name: r.toolName, - startTime: r.startTime, - endTime: r.endTime, - duration: r.duration, - toolCallId: r.toolCallId, - }) - } - iterationCount += 1 - } + iterationCount += 1 + } - /** - * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the - * answer channel would otherwise be empty. Flush the last turn's text - * as the final answer so legacy consumers still receive content. - */ - if (!sawFinalTurn && content) { - controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) - } + if (!sawFinalTurn) { + throw new Error('Gemini tool loop ended without a final response') + } - const toolCost = sumToolCosts(toolResults) - onComplete({ - content, - tokens, - cost: { - input: costInput, - output: costOutput, - toolCost: toolCost || undefined, - total: costTotal + toolCost, - pricing: latestPricing, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - modelTime, - toolsTime, - firstResponseTime, - iterations: modelCalls, - }) - controller.close() - } catch (error) { - if (isAbortError(error) || request.abortSignal?.aborted) { - settleOpenTools(controller, openToolStarts, 'cancelled') - } else { - settleOpenTools(controller, openToolStarts, 'error') - logger.error('Gemini streaming tool loop failed', { - error: toError(error).message, + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Gemini streaming tool loop failed', { + error: toError(cause).message, + }), }) + } finally { + activeStreamIterator = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) } - controller.error(error) - } + })().catch((error) => { + // `start` cannot be async (the loop must not block the first pull), so + // a throw escaping the IIFE would surface as an unhandled rejection. + logger.error('Unhandled failure in Gemini streaming tool loop', { + error: toError(error).message, + }) + }) + }, + async cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + await activeStreamIterator?.return?.() + request.abortSignal?.removeEventListener('abort', abortFromRequest) }, }) } diff --git a/apps/sim/providers/gemini/types.ts b/apps/sim/providers/gemini/types.ts index e74b69fe44a..b605e835a78 100644 --- a/apps/sim/providers/gemini/types.ts +++ b/apps/sim/providers/gemini/types.ts @@ -2,11 +2,17 @@ import type { Content, ToolConfig } from '@google/genai' import type { FunctionCallResponse, ModelPricing, TimeSegment } from '@/providers/types' /** - * Usage metadata from Gemini responses + * Usage metadata from Gemini responses. + * + * `cachedContentTokenCount` is a SUBSET of `promptTokenCount`, not a sibling of + * it — Gemini counts implicitly cached prompt tokens inside the prompt total and + * only discounts their rate. Anything pricing this usage must subtract it out + * before charging the base input rate. */ export interface GeminiUsage { promptTokenCount: number candidatesTokenCount: number + cachedContentTokenCount: number totalTokenCount: number } @@ -23,7 +29,8 @@ interface ParsedFunctionCall { */ export interface ExecutionState { contents: Content[] - tokens: { input: number; output: number; total: number } + /** `input` excludes `cacheRead`; `total` counts both, plus output. */ + tokens: { input: number; output: number; cacheRead: number; total: number } cost: { input: number; output: number; total: number; pricing: ModelPricing } toolCalls: FunctionCallResponse[] toolResults: Record[] diff --git a/apps/sim/providers/gemini/usage.test.ts b/apps/sim/providers/gemini/usage.test.ts new file mode 100644 index 00000000000..bcaf8644035 --- /dev/null +++ b/apps/sim/providers/gemini/usage.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { priceGeminiTokens, splitGeminiTokens, splitGeminiUsage } from '@/providers/gemini/usage' +import { calculateCost } from '@/providers/utils' + +/** input 0.30/M, cachedInput 0.03/M, output 2.50/M. */ +const MODEL = 'gemini-2.5-flash' + +describe('splitGeminiTokens', () => { + it('subtracts the cached subset out of the prompt total', () => { + expect(splitGeminiTokens(100_000, 1_000, 80_000)).toEqual({ + input: 20_000, + output: 1_000, + cacheRead: 80_000, + }) + }) + + it('leaves the prompt total intact when nothing was cached', () => { + expect(splitGeminiTokens(100_000, 1_000, 0)).toEqual({ + input: 100_000, + output: 1_000, + cacheRead: 0, + }) + }) + + it('never bills more tokens than the prompt contained when cached is over-reported', () => { + // Cached is a subset of the prompt total, so it can never exceed it. + // Leaving it unclamped would bill 4,000 cached tokens on a 1,000-token prompt. + expect(splitGeminiTokens(1_000, 10, 4_000)).toEqual({ + input: 0, + output: 10, + cacheRead: 1_000, + }) + expect(splitGeminiTokens(1_000, 10, -5)).toEqual({ + input: 1_000, + output: 10, + cacheRead: 0, + }) + }) +}) + +describe('splitGeminiUsage', () => { + it('reads the cached subset off the generateContent usage shape', () => { + expect( + splitGeminiUsage({ + promptTokenCount: 50_000, + candidatesTokenCount: 500, + cachedContentTokenCount: 30_000, + totalTokenCount: 50_500, + }) + ).toEqual({ input: 20_000, output: 500, cacheRead: 30_000 }) + }) +}) + +describe('priceGeminiTokens', () => { + it('charges cached tokens at the discounted rate instead of the base input rate', () => { + const cost = priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 80_000)) + + expect(cost.input).toBeCloseTo(20_000 * 0.0000003 + 80_000 * 0.00000003, 10) + expect(cost.output).toBeCloseTo(1_000 * 0.0000025, 10) + expect(cost.total).toBeCloseTo(0.0109, 10) + }) + + it('costs strictly less than pricing the whole prompt total at the base rate', () => { + const cacheBlind = calculateCost(MODEL, 100_000, 1_000) + const cacheAware = priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 80_000)) + + expect(cacheBlind.total).toBeCloseTo(0.0325, 10) + expect(cacheAware.total).toBeLessThan(cacheBlind.total) + }) + + it('matches plain calculateCost exactly when there is no cache hit', () => { + expect(priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 0))).toEqual( + calculateCost(MODEL, 100_000, 1_000) + ) + }) +}) diff --git a/apps/sim/providers/gemini/usage.ts b/apps/sim/providers/gemini/usage.ts new file mode 100644 index 00000000000..f79f11d2ec9 --- /dev/null +++ b/apps/sim/providers/gemini/usage.ts @@ -0,0 +1,62 @@ +import { LIST_PRICE_POLICY, type PricedModelCost, priceModelUsage } from '@/providers/cost-policy' +import type { GeminiUsage } from '@/providers/gemini/types' + +/** + * One Gemini response's tokens split into the buckets that price differently. + * `input` is the uncached remainder, so `input + cacheRead` is the prompt total + * Gemini reported. + */ +export interface GeminiTokenSplit { + input: number + output: number + cacheRead: number +} + +/** + * Splits a cache-inclusive Gemini prompt total into the uncached remainder and + * the cache read. + * + * Gemini's implicit context cache (on by default from 2.5 onward) reports cached + * tokens as a subset of the prompt total — `cachedContentTokenCount` inside + * `promptTokenCount` on generateContent, `total_cached_tokens` inside + * `total_input_tokens` on the Interactions API. Charging the prompt total at the + * base input rate therefore bills cache hits at full price; the subtraction is + * what routes them to the model's discounted `cachedInput` rate instead. + */ +export function splitGeminiTokens( + promptTokens: number, + candidatesTokens: number, + cachedTokens: number +): GeminiTokenSplit { + const prompt = Math.max(0, promptTokens) + // Clamped to the prompt total: a payload reporting more cached tokens than it + // processed would otherwise bill more input than the request contained. + const cacheRead = Math.min(Math.max(0, cachedTokens), prompt) + + return { + input: prompt - cacheRead, + output: candidatesTokens, + cacheRead, + } +} + +/** {@link splitGeminiTokens} for the generateContent usage shape. */ +export function splitGeminiUsage(usage: GeminiUsage): GeminiTokenSplit { + return splitGeminiTokens( + usage.promptTokenCount, + usage.candidatesTokenCount, + usage.cachedContentTokenCount + ) +} + +/** + * Prices a split through the shared cache-aware pricing function. With no cache + * hit this matches pricing the whole prompt total at the base input rate. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function priceGeminiTokens(model: string, split: GeminiTokenSplit): PricedModelCost { + return priceModelUsage(model, split, LIST_PRICE_POLICY) +} diff --git a/apps/sim/providers/google/utils.stream.test.ts b/apps/sim/providers/google/utils.stream.test.ts index 2e3a79e7ecb..1b2dfaa1c79 100644 --- a/apps/sim/providers/google/utils.stream.test.ts +++ b/apps/sim/providers/google/utils.stream.test.ts @@ -74,4 +74,21 @@ describe('createReadableStreamFromGeminiStream', () => { .join('') ).toContain('Just text') }) + + it('surfaces blocked prompts instead of completing an empty stream', async () => { + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + promptFeedback: { + blockReason: 'SAFETY', + blockReasonMessage: 'Prompt violated safety policy', + }, + } as any + })() + ) + + await expect(collectEvents(stream)).rejects.toThrow( + 'Gemini prompt blocked: SAFETY (Prompt violated safety policy)' + ) + }) }) diff --git a/apps/sim/providers/google/utils.test.ts b/apps/sim/providers/google/utils.test.ts index 7cd7fff8277..ead73247543 100644 --- a/apps/sim/providers/google/utils.test.ts +++ b/apps/sim/providers/google/utils.test.ts @@ -4,12 +4,57 @@ import { describe, expect, it } from 'vitest' import { convertToGeminiFormat, + convertUsageMetadata, ensureStructResponse, mapToThinkingBudget, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' import type { ProviderRequest } from '@/providers/types' +describe('convertUsageMetadata', () => { + it('carries the cached prompt subset through so callers can discount it', () => { + expect( + convertUsageMetadata({ + promptTokenCount: 100_000, + cachedContentTokenCount: 80_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ).toEqual({ + promptTokenCount: 100_000, + candidatesTokenCount: 1_000, + cachedContentTokenCount: 80_000, + totalTokenCount: 101_000, + }) + }) + + it('reports no cache hit when the field is absent or the metadata is missing', () => { + expect( + convertUsageMetadata({ + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }).cachedContentTokenCount + ).toBe(0) + expect(convertUsageMetadata(undefined).cachedContentTokenCount).toBe(0) + }) + + it('keeps the cached count a subset of the tool-use-inclusive prompt total', () => { + const usage = convertUsageMetadata({ + promptTokenCount: 8_000, + toolUsePromptTokenCount: 2_000, + cachedContentTokenCount: 6_000, + candidatesTokenCount: 100, + thoughtsTokenCount: 40, + totalTokenCount: 10_140, + }) + + expect(usage.promptTokenCount).toBe(10_000) + expect(usage.candidatesTokenCount).toBe(140) + expect(usage.cachedContentTokenCount).toBeLessThan(usage.promptTokenCount) + }) +}) + describe('mapToThinkingBudget', () => { it('maps named levels to a within-range budget for gemini-2.5-pro (128-32768, cannot disable)', () => { expect(mapToThinkingBudget('gemini-2.5-pro', 'low')).toBeGreaterThanOrEqual(128) diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 3a389261011..63822da75d0 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { buildGeminiMessageParts } from '@/providers/attachments' +import type { GeminiUsage } from '@/providers/gemini/types' import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest } from '@/providers/types' import { trackForcedToolUsage } from '@/providers/utils' @@ -38,23 +39,6 @@ export function ensureStructResponse(value: unknown): Record { return { value } } -/** - * Usage metadata for Google Gemini responses - */ -export interface GeminiUsage { - promptTokenCount: number - candidatesTokenCount: number - totalTokenCount: number -} - -/** - * Parsed function call from Gemini response - */ -interface ParsedFunctionCall { - name: string - args: Record -} - /** * Removes additionalProperties from a schema object (not supported by Gemini) */ @@ -93,40 +77,6 @@ export function extractTextContent(candidate: Candidate | undefined): string { return textParts.map((part) => part.text).join('\n') } -/** - * Extracts the first function call from a Gemini response candidate - */ -export function extractFunctionCall(candidate: Candidate | undefined): ParsedFunctionCall | null { - if (!candidate?.content?.parts) return null - - for (const part of candidate.content.parts) { - if (part.functionCall) { - return { - name: part.functionCall.name ?? '', - args: (part.functionCall.args ?? {}) as Record, - } - } - } - - return null -} - -/** - * Extracts the full Part containing the function call (preserves thoughtSignature) - * @deprecated Use extractAllFunctionCallParts for proper multi-tool handling - */ -export function extractFunctionCallPart(candidate: Candidate | undefined): Part | null { - if (!candidate?.content?.parts) return null - - for (const part of candidate.content.parts) { - if (part.functionCall) { - return part - } - } - - return null -} - /** * Extracts ALL Parts containing function calls from a candidate. * Gemini can return multiple function calls in a single response, @@ -142,6 +92,10 @@ export function extractAllFunctionCallParts(candidate: Candidate | undefined): P * Converts usage metadata from SDK response to our format. * Per Gemini docs, total = promptTokenCount + candidatesTokenCount + toolUsePromptTokenCount + thoughtsTokenCount * We include toolUsePromptTokenCount in input and thoughtsTokenCount in output for correct billing. + * + * `cachedContentTokenCount` is carried through unchanged — it stays a subset of + * the reported `promptTokenCount`, and callers subtract it when they need the + * tokens billed at the base input rate. */ export function convertUsageMetadata( usageMetadata: GenerateContentResponseUsageMetadata | undefined @@ -153,6 +107,7 @@ export function convertUsageMetadata( return { promptTokenCount, candidatesTokenCount, + cachedContentTokenCount: usageMetadata?.cachedContentTokenCount ?? 0, totalTokenCount: usageMetadata?.totalTokenCount ?? 0, } } @@ -291,12 +246,32 @@ export function createReadableStreamFromGeminiStream( ): ReadableStream { let fullContent = '' let fullThinking = '' - let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } + let usage: GeminiUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + cachedContentTokenCount: 0, + totalTokenCount: 0, + } + let cancelled = false + let streamIterator: AsyncIterator | undefined return new ReadableStream({ async start(controller) { try { - for await (const chunk of stream) { + streamIterator = stream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const chunk = next.value + if (chunk.promptFeedback?.blockReason) { + throw new Error( + `Gemini prompt blocked: ${chunk.promptFeedback.blockReason}${ + chunk.promptFeedback.blockReasonMessage + ? ` (${chunk.promptFeedback.blockReasonMessage})` + : '' + }` + ) + } if (chunk.usageMetadata) { usage = convertUsageMetadata(chunk.usageMetadata) } @@ -324,15 +299,22 @@ export function createReadableStreamFromGeminiStream( } } + if (cancelled) return onComplete?.(fullContent, usage, fullThinking || undefined) controller.close() } catch (error) { - logger.error('Error reading Google Gemini stream', { - error: toError(error).message, - }) - controller.error(error) + if (!cancelled) { + logger.error('Error reading Google Gemini stream', { + error: toError(error).message, + }) + controller.error(error) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts index 0b5ca8e4c84..7cf12b31de0 100644 --- a/apps/sim/providers/groq/index.test.ts +++ b/apps/sim/providers/groq/index.test.ts @@ -2,10 +2,13 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import type { ProviderRequest } from '@/providers/types' -const { mockCreate } = vi.hoisted(() => ({ +const { mockCreate, mockExecuteTool, mockPrepareToolsWithUsageControl } = vi.hoisted(() => ({ mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + mockPrepareToolsWithUsageControl: vi.fn(), })) vi.mock('groq-sdk', () => ({ @@ -46,17 +49,12 @@ vi.mock('@/providers/trace-enrichment', () => ({ vi.mock('@/providers/utils', () => ({ calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), - prepareToolsWithUsageControl: vi.fn(() => ({ - tools: [], - toolChoice: undefined, - forcedTools: [], - hasFilteredTools: false, - })), + prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, sumToolCosts: vi.fn(() => 0), trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) -vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { groqProvider } from '@/providers/groq/index' @@ -72,6 +70,14 @@ function request(overrides: Partial = {}): ProviderRequest { describe('groqProvider reasoning payload', () => { beforeEach(() => { mockCreate.mockReset() + mockExecuteTool.mockReset() + mockPrepareToolsWithUsageControl.mockReset() + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }) mockCreate.mockResolvedValue({ choices: [{ message: { content: 'ok', tool_calls: [] } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, @@ -128,4 +134,47 @@ describe('groqProvider reasoning payload', () => { expect(payload.reasoning_format).toBeUndefined() expect(payload.reasoning_effort).toBe('none') }) + + it('selects the live tool loop without a caller flag', async () => { + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [ + { + type: 'function', + function: { name: 'lookup', description: 'Lookup', parameters: {} }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + vi.mocked(createOpenAICompatStreamingToolLoopStream).mockReturnValue( + new ReadableStream() as never + ) + + const result = (await groqProvider.executeRequest( + request({ + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }) + )) as unknown as { + createStream: (handles: { + output: { content?: string } + finalizeTiming: () => void + }) => ReadableStream + } + + const output: { content?: string } = {} + result.createStream({ output, finalizeTiming: vi.fn() }) + + expect(createOpenAICompatStreamingToolLoopStream).toHaveBeenCalledTimes(1) + expect(mockCreate).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 78d3d3b0e14..8185c4b3de6 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { Groq } from 'groq-sdk' import type { ChatCompletionCreateParamsStreaming as GroqChatCompletionCreateParamsStreaming } from 'groq-sdk/resources/chat/completions' import type { @@ -13,6 +14,7 @@ import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -26,7 +28,6 @@ import { calculateCost, prepareToolExecution, prepareToolsWithUsageControl, - sumToolCosts, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -139,9 +140,7 @@ export const groqProvider: ProviderConfig = { } } - const shouldStreamToolCalls = request.streamToolCalls ?? false - - if (request.stream && shouldStreamToolCalls && payload.tools?.length) { + if (request.stream && payload.tools?.length) { logger.info('Using streaming tool loop for Groq request') const providerStartTime = Date.now() @@ -184,9 +183,7 @@ export const groqProvider: ProviderConfig = { logger, timeSegments, forcedTools, - preserveAssistantReasoning: - (!!request.thinkingLevel && request.thinkingLevel !== 'none') || - (payload.model as string).includes('gpt-oss'), + preserveAssistantReasoning: true, onComplete: (result) => { output.content = result.content output.tokens = result.tokens @@ -204,7 +201,7 @@ export const groqProvider: ProviderConfig = { }) } - if (request.stream && (!tools || tools.length === 0)) { + if (request.stream && !payload.tools?.length) { logger.info('Using streaming response for Groq request (no tools)') const providerStartTime = Date.now() @@ -227,7 +224,7 @@ export const groqProvider: ProviderConfig = { initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => + createStream: ({ output, finalizeTiming }) => createReadableStreamFromGroqStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes streamResponse as unknown as AsyncIterable, @@ -256,6 +253,7 @@ export const groqProvider: ProviderConfig = { segment.thinkingContent = thinking } } + finalizeTiming() } ), }) @@ -324,10 +322,25 @@ export const groqProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -345,6 +358,9 @@ export const groqProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -364,11 +380,14 @@ export const groqProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + const assistantReasoning = (assistantMessage as { reasoning?: string } | undefined) + ?.reasoning currentMessages.push({ role: 'assistant', - content: null, + content: assistantMessage?.content ?? '', tool_calls: toolCallsInResponse.map((tc) => ({ id: tc.id, type: 'function', @@ -377,13 +396,12 @@ export const groqProvider: ProviderConfig = { arguments: tc.function.arguments, }, })), + ...(assistantReasoning ? { reasoning: assistantReasoning } : {}), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -394,10 +412,12 @@ export const groqProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -494,101 +514,7 @@ export const groqProvider: ProviderConfig = { } } catch (error) { logger.error('Error in Groq request:', { error }) - } - - if (request.stream) { - logger.info('Using streaming for final Groq response after tool processing') - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path (re-applying the - * original forced tool_choice here would even guarantee a dead call). - */ - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: 'none' as const, - stream: true, - } - - const streamResponse = await groq.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromGroqStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes - streamResponse as unknown as AsyncIterable, - (streamedContent, usage, thinking) => { - if (!streamedContent && content) { - logger.warn('Groq final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - - if (thinking) { - const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - } - ), - }) - - return streamingResult + throw error } const providerEndTime = Date.now() @@ -622,6 +548,9 @@ export const groqProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 2f52c842461..7b0ee9697e8 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { envFlagsMockFns, resetEnvFlagsMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetApiKeyWithBYOK, mockExecuteRequest } = vi.hoisted(() => ({ mockGetApiKeyWithBYOK: vi.fn(), @@ -114,6 +115,106 @@ describe('executeProviderRequest — BYOK regression', () => { expect(segment?.cost?.total).toBeCloseTo(HOSTED_RATE_TOTAL_COST, 6) }) + /** + * Provider cost is now preferred over recomputation, because only the + * provider knows its cache tiers. Tool cost is the hazard in that branch: + * `executeProviderRequest` re-derives it from `toolResults`, so a provider + * that folded it into its own total must not have it counted twice. + */ + it('counts a provider-folded tool cost exactly once', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150 }, + cost: { + input: 0.0005, + output: 0.00125, + total: 0.00675, + toolCost: 0.005, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + }, + toolResults: [{ cost: { total: 0.005 } }], + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.toolCost).toBeCloseTo(0.005, 8) + expect(result.cost?.total).toBeCloseTo(0.00675, 8) + }) + + /** + * Gemini hands the same cost object to its response and its model segment. + * Adding tool cost by mutation would charge it to the segment too. + */ + it('does not leak tool cost into a segment sharing the provider cost object', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + envFlagsMockFns.getCostMultiplier.mockReturnValue(1) + const sharedCost = { + input: 0.0005, + output: 0.00125, + total: 0.00175, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + } + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150 }, + cost: sharedCost, + toolResults: [{ cost: { total: 0.004 } }], + timing: { + startTime: '2026-04-30T21:27:37.878Z', + endTime: '2026-04-30T21:27:38.000Z', + duration: 122, + timeSegments: [ + { + type: 'model', + name: 'claude-opus-4-6', + startTime: 1777584457878, + endTime: 1777584457940, + duration: 62, + cost: sharedCost, + }, + ], + }, + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.total).toBeCloseTo(0.00575, 8) + expect(result.timing?.timeSegments?.[0]?.cost?.total).toBeCloseTo(0.00175, 8) + }) + + it('keeps the provider cost rather than recomputing it from tokens', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + // Cache-tier pricing this layer cannot rebuild from `tokens` alone. + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150, cacheRead: 900, cacheWrite: 400 }, + cost: { + input: 0.0123, + output: 0.00125, + total: 0.01355, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + }, + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.input).toBeCloseTo(0.0123, 8) + expect(result.cost?.total).toBeCloseTo(0.01355, 8) + }) + it('preserves tool segment cost (BYOK does not suppress tool charges)', async () => { mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true }) const responseWithToolSegment: ProviderResponse = { @@ -215,3 +316,99 @@ describe('executeProviderRequest — BYOK regression', () => { expect(segments[0].cost.output).toBe(0) }) }) + +/** + * Streaming and non-streaming must charge identically. Providers price tokens + * inside the stream drain without knowing key provenance or the margin, so the + * shared policy is installed on the live output before the stream is returned. + */ +describe('executeProviderRequest — streaming cost policy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + }) + + afterEach(resetEnvFlagsMock) + + function makeStreamingExecution(initialCost?: Record) { + return { + stream: new ReadableStream(), + execution: { + success: true, + output: { + content: '', + model: 'claude-opus-4-6', + tokens: { input: 0, output: 0, total: 0 }, + ...(initialCost ? { cost: initialCost } : {}), + }, + logs: [], + }, + } + } + + it('applies the cost multiplier to cost the provider writes while streaming', async () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + const streaming = makeStreamingExecution() + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + stream: true, + }) + + streaming.execution.output.cost = { input: 1, output: 2, total: 3 } + + expect(streaming.execution.output.cost).toMatchObject({ input: 2, output: 4, total: 6 }) + }) + + it('does not charge for models Sim does not host', async () => { + const streaming = { + stream: new ReadableStream(), + execution: { + success: true, + output: { + content: '', + model: 'llama-3.3-70b-versatile', + tokens: { input: 0, output: 0, total: 0 }, + }, + logs: [], + }, + } + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('groq', { + model: 'llama-3.3-70b-versatile', + workspaceId: 'ws-1', + stream: true, + }) + + streaming.execution.output.cost = { input: 0.5, output: 1.5, total: 2 } + + expect(streaming.execution.output.cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) + + it('keeps tool cost from a settled stream that priced its tools before returning', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true }) + const streaming = makeStreamingExecution({ + input: 0.01, + output: 0.02, + total: 0.035, + toolCost: 0.005, + }) + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + stream: true, + }) + + expect(streaming.execution.output.cost).toMatchObject({ + input: 0, + output: 0, + total: 0.005, + toolCost: 0.005, + }) + }) +}) diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index b75860a1b11..9fc2c5593a2 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -1,8 +1,16 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { getApiKeyWithBYOK } from '@/lib/api-key/byok' -import { getCostMultiplier } from '@/lib/core/config/env-flags' import type { StreamingExecution } from '@/executor/types' +import { + applyModelCostPolicy, + applySegmentCostPolicy, + calculateBillableModelCost, + installStreamingCostPolicy, + type ModelCostPolicy, + resolveModelCostPolicy, + withoutToolCost, +} from '@/providers/cost-policy' import { attachLargeFileRemoteUrls, uploadLargeFilesToProvider, @@ -10,10 +18,9 @@ import { import { getProviderExecutor } from '@/providers/registry' import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' import { - calculateCost, generateStructuredOutputInstructions, - shouldBillModelUsage, sumToolCosts, + supportsPromptCaching, supportsReasoningEffort, supportsTemperature, supportsThinking, @@ -48,6 +55,10 @@ function sanitizeRequest(request: ProviderRequest): ProviderRequest { sanitizedRequest.thinkingLevel = undefined } + if (model && !supportsPromptCaching(model)) { + sanitizedRequest.promptCaching = undefined + } + return sanitizedRequest } @@ -59,71 +70,26 @@ function isReadableStream(response: any): response is ReadableStream { return response instanceof ReadableStream } -const ZERO_COST = Object.freeze({ - input: 0, - output: 0, - total: 0, - pricing: Object.freeze({ input: 0, output: 0, updatedAt: new Date(0).toISOString() }), -}) - -const ZERO_SEGMENT_COST = Object.freeze({ input: 0, output: 0, total: 0 }) - -/** - * Zeroes per-segment model cost on already-populated time segments so that - * `calculateCostSummary` (which sums `span.children[].cost.total` from the - * trace-spans pipeline) does not re-introduce the gross hosted-rate cost - * after the provider response's block-level cost was correctly zeroed for - * BYOK. Tool-segment costs are intentionally left intact. - */ -function zeroModelSegmentCosts( - segments: { type?: string; cost?: { input?: number; output?: number; total?: number } }[] -): void { - for (const segment of segments) { - if (segment.type === 'model' && segment.cost) { - segment.cost = { ...ZERO_SEGMENT_COST } - } - } -} - /** - * Prevents streaming callbacks from writing non-zero model cost for BYOK users - * while preserving tool costs. The property is frozen via defineProperty because - * providers set cost inside streaming callbacks that fire after this function returns. + * Applies the shared model-cost policy to a streaming response. * - * Also zeroes any per-segment model cost already written by trace enrichers - * (which run synchronously before streaming begins for all current providers). + * The streaming and non-streaming paths must charge identically for the same + * model and tokens, but streaming providers write their cost from inside the + * stream drain — long after this function returns — so the policy is installed + * on the live output object rather than applied to a value. */ -function zeroCostForBYOK(response: StreamingExecution): void { - const output = response.execution?.output as - | (Record & { - providerTiming?: { - timeSegments?: Array<{ - type?: string - cost?: { input?: number; output?: number; total?: number } - }> - } - }) - | undefined +function applyStreamingCostPolicy(response: StreamingExecution, policy: ModelCostPolicy): void { + const output = response.execution?.output if (!output || typeof output !== 'object') { - logger.warn('zeroCostForBYOK: output not available at intercept time; cost may not be zeroed') + logger.warn('Streaming output unavailable at intercept time; cost policy not applied') return } - let toolCost = 0 - Object.defineProperty(output, 'cost', { - get: () => (toolCost > 0 ? { ...ZERO_COST, toolCost, total: toolCost } : ZERO_COST), - set: (value: Record) => { - if (value?.toolCost && typeof value.toolCost === 'number') { - toolCost = value.toolCost - } - }, - configurable: true, - enumerable: true, - }) + installStreamingCostPolicy(output, policy) const segments = output.providerTiming?.timeSegments if (Array.isArray(segments)) { - zeroModelSegmentCosts(segments) + applySegmentCostPolicy(segments, policy) } } @@ -201,9 +167,7 @@ export async function executeProviderRequest( if (isStreamingExecution(response)) { logger.info('Provider returned StreamingExecution', { isBYOK }) - if (isBYOK) { - zeroCostForBYOK(response) - } + applyStreamingCostPolicy(response, resolveModelCostPolicy(sanitizedRequest.model, isBYOK)) return response } @@ -212,54 +176,51 @@ export async function executeProviderRequest( return response } + const costPolicy = resolveModelCostPolicy(response.model, isBYOK) + if (response.tokens) { const { input: promptTokens = 0, output: completionTokens = 0 } = response.tokens - const useCachedInput = !!request.context && request.context.length > 0 - - const shouldBill = shouldBillModelUsage(response.model) && !isBYOK - if (shouldBill) { - const costMultiplier = getCostMultiplier() - response.cost = calculateCost( - response.model, - promptTokens, - completionTokens, - useCachedInput, - costMultiplier, - costMultiplier + + /** + * Any provider that reports cache buckets also prices itself, because only + * it knows the tiers involved — Anthropic's 5m vs 1h writes cannot be + * reconstructed from a single `cacheWrite` count. Its cost is therefore + * authoritative and only the policy is applied on top. The fallback prices + * providers that report no cache usage at all. + * + * Tool cost is stripped either way: it is re-derived from `toolResults` + * below and must not be counted twice. + */ + response.cost = response.cost + ? (applyModelCostPolicy(withoutToolCost(response.cost), costPolicy) as typeof response.cost) + : calculateBillableModelCost(response.model, promptTokens, completionTokens, { isBYOK }) + + if (!costPolicy.billable) { + logger.info( + isBYOK + ? `Not billing model usage for ${response.model} - workspace BYOK key used` + : `Not billing model usage for ${response.model} - user provided API key or not hosted model` ) - } else { - response.cost = { - input: 0, - output: 0, - total: 0, - pricing: { - input: 0, - output: 0, - updatedAt: new Date().toISOString(), - }, - } - if (isBYOK) { - logger.info(`Not billing model usage for ${response.model} - workspace BYOK key used`) - } else { - logger.info( - `Not billing model usage for ${response.model} - user provided API key or not hosted model` - ) - } } } - // Per-segment model costs are written by trace enrichers regardless of BYOK - // status. Zero them here so the trace-spans aggregator (which sums child - // span cost) does not re-introduce the gross hosted-rate cost after the - // block-level response.cost was already set to zero above. - if (isBYOK && response.timing?.timeSegments) { - zeroModelSegmentCosts(response.timing.timeSegments) + // Per-segment model costs are written by trace enrichers regardless of key + // provenance. Align them with the block-level decision so the displayed + // breakdown does not contradict the authoritative block cost. + if (response.timing?.timeSegments) { + applySegmentCostPolicy(response.timing.timeSegments, costPolicy) } const toolCost = sumToolCosts(response.toolResults) if (toolCost > 0 && response.cost) { - response.cost.toolCost = toolCost - response.cost.total += toolCost + // Replaced rather than mutated: a provider-supplied cost can be the same + // object it also handed to a time segment, and tool cost belongs only to + // the block total. + response.cost = { + ...response.cost, + toolCost, + total: response.cost.total + toolCost, + } } return response diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index 8343321e79c..a211c33a9bf 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -11,7 +12,10 @@ import { getProviderDefaultModel, getProviderModels, } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -312,7 +316,7 @@ export const kimiProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -348,6 +352,9 @@ export const kimiProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -367,31 +374,21 @@ export const kimiProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - const assistantReasoning = ( - currentResponse.choices[0]?.message as { reasoning_content?: string } | undefined - )?.reasoning_content - - currentMessages.push({ - role: 'assistant', - content: null, - ...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}), - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -402,10 +399,12 @@ export const kimiProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -508,12 +507,56 @@ export const kimiProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'kimi' } ) + + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + } + finalPayload.tools = undefined + finalPayload.tool_choice = undefined + + const finalModelStartTime = Date.now() + currentResponse = await kimi.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'kimi' } + ) + iterationCount++ + } } } catch (error) { logger.error('Error in Kimi request:', { error }) @@ -521,23 +564,8 @@ export const kimiProvider: ProviderConfig = { } if (request.stream) { - logger.info('Using streaming for final Kimi response after tool processing') - - const streamingPayload: any = { - ...payload, - messages: currentMessages, - stream: true, - stream_options: { include_usage: true }, - } - streamingPayload.tools = undefined - streamingPayload.tool_choice = undefined - - const streamResponse = await kimi.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, @@ -559,8 +587,8 @@ export const kimiProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -571,32 +599,11 @@ export const kimiProvider: ProviderConfig = { : undefined, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromKimiStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - } - ), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -633,6 +640,10 @@ export const kimiProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 04efada3fa3..50563bf21db 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -72,19 +72,30 @@ vi.mock('@/providers/utils', () => ({ })) import { litellmProvider } from '@/providers/litellm' +import type { AgentStreamEvent } from '@/providers/stream-events' import { ProviderError } from '@/providers/types' interface ChatOptions { content?: string | null toolCalls?: Array<{ id: string; function: { name: string; arguments: string } }> usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } + reasoning_content?: string } -function chat({ content = null, toolCalls, usage }: ChatOptions = {}) { +function chat({ + content = null, + toolCalls, + usage, + reasoning_content: reasoningContent, +}: ChatOptions = {}) { return { choices: [ { - message: { content, tool_calls: toolCalls }, + message: { + content, + tool_calls: toolCalls, + ...(reasoningContent !== undefined ? { reasoning_content: reasoningContent } : {}), + }, finish_reason: toolCalls ? 'tool_calls' : 'stop', }, ], @@ -107,6 +118,16 @@ function run(request: Record) { const firstPayload = () => mockCreate.mock.calls[0][0] const lastPayload = () => mockCreate.mock.calls.at(-1)![0] +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + describe('litellmProvider.executeRequest', () => { beforeEach(() => { vi.clearAllMocks() @@ -190,12 +211,13 @@ describe('litellmProvider.executeRequest', () => { expect(result.content).toBe('{"answer":1}') }) - it('defers response_format into the final streaming call while keeping tools', async () => { + it('uses one distinct non-streaming extraction call for deferred response_format', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) ) .mockResolvedValueOnce(chat({ content: 'mid' })) + .mockResolvedValueOnce(chat({ content: '{"answer":1}' })) const result = await run({ stream: true, @@ -204,12 +226,46 @@ describe('litellmProvider.executeRequest', () => { }) const final = lastPayload() - expect(final.stream).toBe(true) + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(final.stream).toBeUndefined() expect(final.response_format.type).toBe('json_schema') expect(final.tools).toBeDefined() expect(final.tool_choice).toBe('none') expect(final.parallel_tool_calls).toBe(false) expect(result.execution.isStreaming).toBe(true) + expect(result.execution.output.content).toBe('{"answer":1}') + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + await expect(readAgentEvents(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: '{"answer":1}', turn: 'final' }, + ]) + }) + + it('projects a normal settled tool-loop answer without regeneration', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) + ) + .mockResolvedValueOnce(chat({ content: 'done' })) + + const result = await run({ stream: true, tools: [tool('known')] }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 10, output: 6, total: 16 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect(readAgentEvents(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: 'done', turn: 'final' }, + ]) }) it('threads assistant tool_calls and a named tool response, and reports toolCalls', async () => { @@ -238,7 +294,31 @@ describe('litellmProvider.executeRequest', () => { expect(result.content).toBe('done') }) - it('emits a stub tool response for an unanswered tool_call_id', async () => { + it('replays LiteLLM assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ + content: 'I will use the tool.', + toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }], + reasoning_content: 'Normalized reasoning.', + }) + ) + .mockResolvedValueOnce(chat({ content: 'done' })) + + await run({ tools: [tool('known')] }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Normalized reasoning.', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'known', arguments: '{}' } }], + }) + }) + + it('emits a structured response when the requested tool is unavailable', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'cX', function: { name: 'ghost', arguments: '{}' } }] }) @@ -254,7 +334,7 @@ describe('litellmProvider.executeRequest', () => { expect(toolMsg.content).toContain('not available') }) - it('executes a tool with empty arguments without failing', async () => { + it('rejects empty tool arguments without executing the tool', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'c1', function: { name: 'ping', arguments: '' } }] }) @@ -263,9 +343,9 @@ describe('litellmProvider.executeRequest', () => { await run({ tools: [tool('ping')] }) - expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect(mockExecuteTool).not.toHaveBeenCalled() const toolMsg = mockCreate.mock.calls[1][0].messages.find((m: any) => m.role === 'tool') - expect(toolMsg.content).not.toContain('"error":true') + expect(toolMsg.content).toContain('"error":true') }) it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => { @@ -275,8 +355,10 @@ describe('litellmProvider.executeRequest', () => { await run({ tools: [tool('known')] }) - expect(mockCreate).toHaveBeenCalledTimes(1 + 20) + expect(mockCreate).toHaveBeenCalledTimes(1 + 20 + 1) expect(mockExecuteTool).toHaveBeenCalledTimes(20) + expect(lastPayload().tools).toBeUndefined() + expect(lastPayload().tool_choice).toBeUndefined() }) it('returns a streaming execution when streaming without active tools', async () => { diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index e8ee2229e6e..0135aac64b2 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import { env } from '@/lib/core/config/env' @@ -8,7 +9,10 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromLiteLLMStream } from '@/providers/litellm/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -349,12 +353,25 @@ export const litellmProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = toolCall.function.arguments - ? JSON.parse(toolCall.function.arguments) - : {} + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -372,6 +389,9 @@ export const litellmProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -391,28 +411,21 @@ export const litellmProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - const respondedToolCallIds = new Set() - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -423,10 +436,12 @@ export const litellmProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -451,21 +466,6 @@ export const litellmProvider: ProviderConfig = { name: toolName, content: JSON.stringify(resultContent), }) - respondedToolCallIds.add(toolCall.id) - } - - for (const tc of toolCallsInResponse) { - if (respondedToolCallIds.has(tc.id)) continue - currentMessages.push({ - role: 'tool', - tool_call_id: tc.id, - name: tc.function.name, - content: JSON.stringify({ - error: true, - message: `Tool "${tc.function.name}" is not available`, - tool: tc.function.name, - }), - }) } const thisToolsTime = Date.now() - toolsStartTime @@ -538,92 +538,12 @@ export const litellmProvider: ProviderConfig = { ) } - if (request.stream) { - logger.info('Using streaming for final response after tool processing') - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - if (deferResponseFormat && responseFormatPayload) { - streamingParams.response_format = responseFormatPayload - streamingParams.parallel_tool_calls = false - } - const streamResponse = await litellm.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { - let cleanContent = content - if (cleanContent && request.responseFormat) { - cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() - } - - output.content = cleanContent - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) - - return streamingResult - } - + /** + * Deferred structured output is a distinct extraction step, not streaming + * regeneration. Some LiteLLM backends cannot combine schema output with tools. + */ if (deferResponseFormat && responseFormatPayload) { - logger.info('Applying deferred JSON schema response format after tool processing') + logger.info('Applying deferred JSON schema extraction after tool processing') const finalFormatStartTime = Date.now() const finalPayload: any = { @@ -653,7 +573,6 @@ export const litellmProvider: ProviderConfig = { if (formattedContent) { content = formattedContent.replace(/```json\n?|\n?```/g, '').trim() } - if (currentResponse.usage) { tokens.input += currentResponse.usage.prompt_tokens || 0 tokens.output += currentResponse.usage.completion_tokens || 0 @@ -666,6 +585,93 @@ export const litellmProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'litellm' } ) + } else if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await litellm.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'litellm' } + ) + } + + if (request.stream) { + logger.info('Projecting settled response after tool processing') + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) } const providerEndTime = Date.now() @@ -685,7 +691,7 @@ export const litellmProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -714,6 +720,10 @@ export const litellmProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/meta/index.ts b/apps/sim/providers/meta/index.ts index ecd8b141481..f4acdd81be0 100644 --- a/apps/sim/providers/meta/index.ts +++ b/apps/sim/providers/meta/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -7,7 +8,9 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromMetaStream } from '@/providers/meta/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -22,7 +25,6 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, - trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -149,6 +151,7 @@ export const metaProvider: ProviderConfig = { // backends reject a request that carries both `response_format` and active // `tools`/`tool_choice`. Defer the schema until after the tool loop completes. const deferResponseFormat = !!responseFormatPayload && hasActiveTools + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload } @@ -204,9 +207,6 @@ export const metaProvider: ProviderConfig = { } const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] let currentResponse = await meta.chat.completions.create( payload, @@ -225,7 +225,6 @@ export const metaProvider: ProviderConfig = { const toolResults: Record[] = [] const currentMessages = [...formattedMessages] let iterationCount = 0 - let hasUsedForcedTool = false let modelTime = firstResponseTime let toolsTime = 0 @@ -239,23 +238,6 @@ export const metaProvider: ProviderConfig = { }, ] - if ( - typeof originalToolChoice === 'object' && - currentResponse.choices[0]?.message?.tool_calls - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - originalToolChoice, - logger, - 'openai', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - try { while (iterationCount < MAX_TOOL_ITERATIONS) { if (currentResponse.choices[0]?.message?.content) { @@ -282,7 +264,7 @@ export const metaProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) // Every tool_call in the assistant message must be answered by a matching @@ -321,6 +303,9 @@ export const metaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -340,7 +325,7 @@ export const metaProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -355,11 +340,9 @@ export const metaProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -370,10 +353,12 @@ export const metaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -407,48 +392,12 @@ export const metaProvider: ProviderConfig = { messages: currentMessages, } - if ( - typeof originalToolChoice === 'object' && - hasUsedForcedTool && - forcedTools.length > 0 - ) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - - if (remainingTools.length > 0) { - nextPayload.tool_choice = { - type: 'function', - function: { name: remainingTools[0] }, - } - logger.info(`Forcing next tool: ${remainingTools[0]}`) - } else { - nextPayload.tool_choice = 'auto' - logger.info('All forced tools have been used, switching to auto tool_choice') - } - } - const nextModelStartTime = Date.now() currentResponse = await meta.chat.completions.create( nextPayload, request.abortSignal ? { signal: request.abortSignal } : undefined ) - if ( - typeof nextPayload.tool_choice === 'object' && - currentResponse.choices[0]?.message?.tool_calls - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - nextPayload.tool_choice, - logger, - 'openai', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - const nextModelEndTime = Date.now() const thisModelTime = nextModelEndTime - nextModelStartTime @@ -476,111 +425,66 @@ export const metaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'meta' } ) - } - } catch (error) { - logger.error('Error in Meta request:', { error }) - throw error - } - - if (request.stream) { - logger.info('Using streaming for final Meta response after tool processing') - - // The tool loop is complete: this final pass only produces the textual answer. - // Meta rejects tool_choice: "none" (only "auto" is supported), so instead of - // forcing tool_choice we omit `tools` from this call entirely — with no tools - // declared, the model cannot emit a fresh tool call for the text-only adapter to drop. - const { tools: _omittedTools, ...streamingBasePayload } = payload - const streamingPayload: any = { - ...streamingBasePayload, - messages: currentMessages, - stream: true, - stream_options: { include_usage: true }, - } - if (deferResponseFormat && responseFormatPayload) { - streamingPayload.response_format = responseFormatPayload - } - const streamResponse = await meta.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + if (cappedToolCalls?.length) { + const { tools: _omittedTools, ...finalPayload } = payload + finalPayload.messages = currentMessages + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await meta.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromMetaStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - } - ), - }) + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'meta' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Meta request:', { error }) + throw error } // Tools were active, so `response_format` was withheld from the loop. Make one final // tool-free call to obtain the structured response now that the tool work is done. - // Meta rejects tool_choice: "none", so `tools` is dropped from this payload instead - // (see the streaming pass above for the same constraint). - if (deferResponseFormat && responseFormatPayload) { + // Meta rejects tool_choice: "none", so `tools` is dropped from this payload instead. + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -625,6 +529,52 @@ export const metaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -642,7 +592,7 @@ export const metaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -656,6 +606,10 @@ export const metaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/mistral/index.test.ts b/apps/sim/providers/mistral/index.test.ts new file mode 100644 index 00000000000..ec5871ad47a --- /dev/null +++ b/apps/sim/providers/mistral/index.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderToolConfig } from '@/providers/types' + +const { mockCreate, mockExecuteTool } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: (messages: unknown) => messages, +})) +vi.mock('@/providers/mistral/utils', () => ({ + createReadableStreamFromMistralStream: vi.fn(), +})) +vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, + getProviderModels: vi.fn(() => []), + getProviderDefaultModel: vi.fn(() => 'mistral-large-latest'), +})) +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: vi.fn((tools) => ({ + tools, + toolChoice: 'auto', + forcedTools: [], + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +import { mistralProvider } from '@/providers/mistral' + +function makeTool(id: string): ProviderToolConfig { + return { + id, + name: id, + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + +describe('mistralProvider.executeRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) + }) + + it('projects the settled tool-loop answer without a final streaming request', async () => { + mockCreate + .mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + }) + .mockResolvedValueOnce({ + choices: [{ message: { content: 'done', tool_calls: undefined } }], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }) + + const result = await mistralProvider.executeRequest!({ + model: 'mistral-large-latest', + apiKey: 'key', + messages: [{ role: 'user', content: 'Use a tool' }], + stream: true, + tools: [makeTool('lookup')], + }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 6, output: 3, total: 9 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'done', turn: 'final' }]) + }) +}) diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 605967bb546..ddf625a4596 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -7,7 +8,9 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromMistralStream } from '@/providers/mistral/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -139,6 +142,11 @@ export const mistralProvider: ProviderConfig = { if (request.stream && (!tools || tools.length === 0)) { logger.info('Using streaming response for Mistral request') + /** + * Mistral reports stream usage on the terminal chunk on its own and + * rejects `stream_options` with HTTP 422 (`extra_forbidden`), so the + * opt-in every other OpenAI-compatible provider sends is omitted here. + */ const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, stream: true, @@ -271,10 +279,25 @@ export const mistralProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -292,6 +315,9 @@ export const mistralProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -311,7 +337,7 @@ export const mistralProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', content: null, @@ -325,11 +351,9 @@ export const mistralProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -340,10 +364,12 @@ export const mistralProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -434,29 +460,55 @@ export const mistralProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'mistral' } ) + + if (currentResponse.choices[0]?.message?.tool_calls?.length) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await mistral.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'mistral' } + ) + } } if (request.stream) { - logger.info('Using streaming for final response after tool processing') + logger.info('Projecting settled response after tool processing') const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path. - */ - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - } - const streamResponse = await mistral.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -465,7 +517,7 @@ export const mistralProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -476,7 +528,8 @@ export const mistralProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -486,34 +539,12 @@ export const mistralProvider: ProviderConfig = { } : undefined, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromMistralStream(streamResponse, (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('Mistral final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -533,7 +564,7 @@ export const mistralProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -547,6 +578,10 @@ export const mistralProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index 56eddcbeb6b..e8deb2a9311 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -5,10 +5,64 @@ import { describe, expect, it } from 'vitest' import { getBaseModelProviders, getHostedModels, + getModelsWithPromptCaching, + getPromptCachingMinimumTokens, + getThinkingStreamVisibility, isModelDeprecated, orderModelIdsByReleaseDate, PROVIDER_DEFINITIONS, } from '@/providers/models' +import { supportsPromptCaching } from '@/providers/utils' + +describe('Anthropic thinking stream visibility', () => { + it('classifies visible Claude thinking as summarized rather than raw', () => { + for (const providerId of ['anthropic', 'azure-anthropic'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + if (model.capabilities.thinking) { + expect(getThinkingStreamVisibility(model.id)).toBe('summary') + } + } + } + }) +}) + +describe('Meta thinking stream visibility', () => { + it('classifies private Muse reasoning as not streamed', () => { + expect(getThinkingStreamVisibility('muse-spark-1.1')).toBe('none') + }) +}) + +describe('prompt caching capability', () => { + const cachingModels = new Set(getModelsWithPromptCaching()) + + it('covers every Claude model on both Anthropic surfaces', () => { + for (const providerId of ['anthropic', 'azure-anthropic'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + expect(cachingModels.has(model.id)).toBe(true) + } + } + }) + + /** + * OpenAI and Gemini cache automatically with no caller control, so declaring + * the capability would put a switch in the UI that does nothing. + */ + it('excludes providers whose caching is automatic', () => { + for (const providerId of ['openai', 'google'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + expect(cachingModels.has(model.id)).toBe(false) + } + } + expect(supportsPromptCaching('gpt-5.5')).toBe(false) + }) + + it('reports the vendor minimum prefix, raised for Haiku', () => { + expect(getPromptCachingMinimumTokens('claude-sonnet-5')).toBe(1024) + expect(getPromptCachingMinimumTokens('claude-haiku-4-5')).toBe(2048) + expect(getPromptCachingMinimumTokens('azure-anthropic/claude-haiku-4-5')).toBe(2048) + expect(getPromptCachingMinimumTokens('gpt-5.5')).toBeNull() + }) +}) const DYNAMIC_PROVIDERS = new Set([ 'ollama', diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index c103bf2b083..aac911d4d5b 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -54,6 +54,18 @@ export interface ModelCapabilities { verbosity?: { values: string[] } + /** + * Model accepts caller-placed prompt-cache breakpoints, so caching is a real + * opt-in with a cost tradeoff (writes carry a premium over base input). + * + * Absent for providers whose caching is automatic and free — OpenAI and + * Gemini implicit caching need no switch, and exposing one would imply a + * control that does not exist. + */ + promptCaching?: { + /** Prefixes shorter than this are silently not cached by the vendor. */ + minimumCacheableTokens: number + } thinking?: { levels: string[] default?: string @@ -754,6 +766,9 @@ export const PROVIDER_DEFINITIONS: Record = { color: '#D97757', capabilities: { toolUsageControl: true, + // Every Claude model accepts cache_control breakpoints; Haiku raises the + // minimum prefix and overrides this per-model. + promptCaching: { minimumCacheableTokens: 1024 }, }, models: [ { @@ -873,7 +888,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -894,7 +909,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -915,7 +930,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -935,7 +950,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -956,7 +971,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -978,7 +993,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -998,7 +1013,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1017,10 +1032,11 @@ export const PROVIDER_DEFINITIONS: Record = { temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, maxOutputTokens: 64000, + promptCaching: { minimumCacheableTokens: 2048 }, thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1371,6 +1387,8 @@ export const PROVIDER_DEFINITIONS: Record = { isReseller: true, capabilities: { toolUsageControl: true, + // Microsoft Foundry supports the same cache_control breakpoints. + promptCaching: { minimumCacheableTokens: 1024 }, }, models: [ { @@ -1388,7 +1406,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -1409,7 +1427,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1430,7 +1448,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1450,7 +1468,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1469,10 +1487,11 @@ export const PROVIDER_DEFINITIONS: Record = { temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, maxOutputTokens: 64000, + promptCaching: { minimumCacheableTokens: 2048 }, thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1935,7 +1954,7 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'deepseek', name: 'DeepSeek', description: "DeepSeek's chat models", - defaultModel: 'deepseek-chat', + defaultModel: 'deepseek-v4-flash', modelPatterns: [], icon: DeepseekIcon, color: '#4D6BFE', @@ -1952,10 +1971,14 @@ export const PROVIDER_DEFINITIONS: Record = { updatedAt: '2026-06-16', }, capabilities: { + reasoningEffort: { + values: ['high', 'max'], + }, thinking: { - levels: ['enabled'], + levels: ['none', 'enabled'], default: 'enabled', }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2026-04-24', @@ -1970,10 +1993,14 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['high', 'max'], + }, thinking: { - levels: ['enabled'], + levels: ['none', 'enabled'], default: 'enabled', }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2026-04-24', @@ -1988,10 +2015,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, - thinking: { - levels: ['enabled'], - default: 'enabled', - }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2024-12-26', @@ -2038,10 +2062,14 @@ export const PROVIDER_DEFINITIONS: Record = { updatedAt: '2026-06-11', }, capabilities: { + reasoningEffort: { + values: ['high', 'max'], + }, thinking: { levels: ['enabled'], default: 'enabled', }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2025-01-20', @@ -4401,6 +4429,33 @@ export function getThinkingCapability( return null } +/** + * Get all models that accept caller-placed prompt-cache breakpoints. + * + * Reads merged provider+model capabilities because prompt caching is declared + * once per provider (every Claude model supports it) with per-model overrides + * only for the minimum prefix length. + */ +export function getModelsWithPromptCaching(): string[] { + const models: string[] = [] + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + for (const model of provider.models) { + if (model.capabilities.promptCaching ?? provider.capabilities?.promptCaching) { + models.push(model.id) + } + } + } + return models +} + +/** + * Minimum prefix length the model will cache, or `null` when the model does + * not support caller-placed breakpoints. + */ +export function getPromptCachingMinimumTokens(modelId: string): number | null { + return getModelCapabilities(modelId)?.promptCaching?.minimumCacheableTokens ?? null +} + /** * Get all models that support thinking capability */ @@ -4428,8 +4483,9 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null { /** * Per-provider defaults for thinking stream visibility, used when a model does * not declare `capabilities.thinking.streamed` explicitly. Gemini and OpenAI - * stream summaries only; Bedrock never requests reasoning; OpenAI-compat - * vendors that expose reasoning stream the raw chain of thought. + * stream summaries only; Bedrock and Meta do not expose reasoning text; + * OpenAI-compatible vendors that expose reasoning stream the raw chain of + * thought. */ const PROVIDER_THINKING_STREAM_DEFAULTS: Record = { google: 'summary', @@ -4437,6 +4493,7 @@ const PROVIDER_THINKING_STREAM_DEFAULTS: Record t.id === toolName) if (!tool) { @@ -298,6 +303,9 @@ export const nvidiaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -317,26 +325,21 @@ export const nvidiaProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -347,10 +350,12 @@ export const nvidiaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -453,104 +458,67 @@ export const nvidiaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'nvidia' } ) - } - } catch (error) { - logger.error('Error in NVIDIA NIM request:', { error }) - throw error - } - - if (request.stream) { - logger.info('Using streaming for final NVIDIA NIM response after tool processing') - - const streamingPayload: any = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - if (deferResponseFormat && responseFormatPayload) { - streamingPayload.response_format = responseFormatPayload - streamingPayload.parallel_tool_calls = false - } - const streamResponse = await nvidia.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + tool_choice: 'none', + } + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.parallel_tool_calls = false + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await nvidia.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromNvidiaStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - } - ), - }) + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'nvidia' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in NVIDIA NIM request:', { error }) + throw error } - if (deferResponseFormat && responseFormatPayload) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -596,6 +564,52 @@ export const nvidiaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -613,7 +627,7 @@ export const nvidiaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -627,6 +641,10 @@ export const nvidiaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index 1164e0be3e3..e199c2ebbf3 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -64,7 +64,11 @@ vi.mock('@/providers/ollama-cloud/utils', () => ({ onComplete: (content: string, usage: StreamUsage) => void ) => { streamOnComplete.current = onComplete - return 'OLLAMA_CLOUD_STREAM' + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, })) vi.mock('@/providers/utils', () => ({ @@ -79,10 +83,11 @@ vi.mock('@/providers/utils', () => ({ vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { ollamaCloudProvider } from '@/providers/ollama-cloud' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface StreamingResult { - stream: string + stream: string | ReadableStream execution: { output: { content: string @@ -97,10 +102,23 @@ interface StreamingResult { type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } } function completion( - opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {} + opts: { + content?: string | null + toolCalls?: ToolCallChunk[] + usage?: StreamUsage + reasoning?: string + } = {} ) { return { - choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }], + choices: [ + { + message: { + content: opts.content ?? null, + tool_calls: opts.toolCalls, + ...(opts.reasoning !== undefined ? { reasoning: opts.reasoning } : {}), + }, + }, + ], usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, } } @@ -116,6 +134,16 @@ function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): Provide } } +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + const baseRequest: ProviderRequest = { model: 'ollama-cloud/gpt-oss:120b', messages: [{ role: 'user', content: 'hi' }], @@ -234,6 +262,41 @@ describe('ollamaCloudProvider.executeRequest', () => { }) }) + it('replays Ollama Cloud assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'done' })) + + await ollamaCloudProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('mytool')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'mytool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without aborting the loop', async () => { mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' }) mockCreate @@ -321,7 +384,7 @@ describe('ollamaCloudProvider.executeRequest', () => { stream: true, })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_CLOUD_STREAM') + expect(result.stream).toBeInstanceOf(ReadableStream) expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true }) expect(result.execution.output.model).toBe('gpt-oss:120b') @@ -334,7 +397,7 @@ describe('ollamaCloudProvider.executeRequest', () => { expect(result.execution.output.tokens).toMatchObject({ input: 4, output: 6, total: 10 }) }) - it('streams the final response after a tool loop and removes tools/tool_choice', async () => { + it('projects the settled tool-loop answer without a regeneration call', async () => { mockCreate .mockResolvedValueOnce( completion({ @@ -343,7 +406,7 @@ describe('ollamaCloudProvider.executeRequest', () => { ], }) ) - .mockResolvedValueOnce(completion({ content: 'intermediate' })) + .mockResolvedValueOnce(completion({ content: 'final answer' })) const result = (await ollamaCloudProvider.executeRequest({ ...baseRequest, @@ -351,19 +414,14 @@ describe('ollamaCloudProvider.executeRequest', () => { tools: [makeTool('mytool')], })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_CLOUD_STREAM') + expect(mockCreate).toHaveBeenCalledTimes(2) expect(mockExecuteTool).toHaveBeenCalledTimes(1) - - const finalCall = mockCreate.mock.calls[2][0] - expect(finalCall.tools).toBeUndefined() - expect(finalCall.tool_choice).toBeUndefined() - - streamOnComplete.current?.('final answer', { - prompt_tokens: 2, - completion_tokens: 4, - total_tokens: 6, - }) expect(result.execution.output.content).toBe('final answer') + expect(result.execution.output.tokens).toEqual({ input: 10, output: 6, total: 16 }) expect(result.execution.output.toolCalls).toMatchObject({ count: 1 }) + expect(result.stream).toBeInstanceOf(ReadableStream) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) }) }) diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index c41134fac80..c3ba7a2a097 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -1,5 +1,6 @@ import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk, @@ -9,8 +10,11 @@ import type { CompletionUsage } from 'openai/resources/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import type { AgentStreamEvent } from '@/providers/stream-events' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' @@ -63,8 +67,8 @@ export interface OllamaCoreConfig { /** * Shared execution logic for the Ollama-family providers, which speak the same * OpenAI-compatible Ollama API. Ollama ignores `tool_choice`, so tools are sent - * as `tool_choice: 'auto'` (forced tools degrade to auto) and the final post-tool - * call drops tools entirely rather than relying on `tool_choice: 'none'`. + * as `tool_choice: 'auto'` (forced tools degrade to auto). Tool-disabled calls + * drop tools entirely rather than relying on `tool_choice: 'none'`. */ export async function executeOllamaProviderRequest( request: ProviderRequest, @@ -288,10 +292,25 @@ export async function executeOllamaProviderRequest( const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -309,6 +328,9 @@ export async function executeOllamaProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -328,26 +350,21 @@ export async function executeOllamaProviderRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -358,10 +375,12 @@ export async function executeOllamaProviderRequest( toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -440,91 +459,10 @@ export async function executeOllamaProviderRequest( ) } - if (request.stream) { - logger.info(`Using streaming for final ${providerLabel} response after tool processing`) - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - const { tools: _tools, tool_choice: _toolChoice, ...streamPayload } = payload - - const finalMessages = request.responseFormat - ? applyJsonResponseFormat(streamPayload, currentMessages, request.responseFormat) - : currentMessages - - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...streamPayload, - messages: finalMessages, - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await ollama.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - config.createStream(streamResponse, (content, usage) => { - output.content = content - - if (content && request.responseFormat) { - output.content = content.replace(/```json\n?|\n?```/g, '').trim() - } - - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) - - return streamingResult - } - - // Deferred structured output: one final JSON-mode call now that tools have run. + /** + * Deferred structured output is a distinct extraction step, not streaming + * regeneration. Ollama cannot combine its JSON mode reliably with tool use. + */ if (request.responseFormat && hasActiveTools) { const finalPayload: any = { model: payload.model } if (payload.temperature !== undefined) finalPayload.temperature = payload.temperature @@ -566,6 +504,92 @@ export async function executeOllamaProviderRequest( finalResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: providerId } ) + } else if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { + /** + * The capped turn still requests tools, so make one tool-disabled call to + * synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await ollama.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: providerId } + ) + } + + if (request.stream) { + logger.info(`Projecting settled ${providerLabel} response after tool processing`) + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) } const providerEndTime = Date.now() @@ -585,7 +609,7 @@ export async function executeOllamaProviderRequest( modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -614,6 +638,10 @@ export async function executeOllamaProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index 00ec2b43c32..a0c358d7559 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -51,7 +51,11 @@ vi.mock('@/providers/ollama/utils', () => ({ onComplete: (content: string, usage: StreamUsage) => void ) => { streamOnComplete.current = onComplete - return 'OLLAMA_STREAM' + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, })) vi.mock('@/providers/utils', () => ({ @@ -69,10 +73,11 @@ vi.mock('@/stores/providers', () => ({ })) import { ollamaProvider } from '@/providers/ollama' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface StreamingResult { - stream: string + stream: string | ReadableStream execution: { output: { content: string @@ -85,10 +90,23 @@ interface StreamingResult { type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } } function completion( - opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {} + opts: { + content?: string | null + toolCalls?: ToolCallChunk[] + usage?: StreamUsage + reasoning?: string + } = {} ) { return { - choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }], + choices: [ + { + message: { + content: opts.content ?? null, + tool_calls: opts.toolCalls, + ...(opts.reasoning !== undefined ? { reasoning: opts.reasoning } : {}), + }, + }, + ], usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, } } @@ -104,6 +122,16 @@ function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): Provide } } +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + const baseRequest: ProviderRequest = { model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }], @@ -230,6 +258,41 @@ describe('ollamaProvider.executeRequest', () => { }) }) + it('replays Ollama assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'done' })) + + await ollamaProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('mytool')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'mytool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without aborting the loop', async () => { mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' }) mockCreate @@ -321,7 +384,7 @@ describe('ollamaProvider.executeRequest', () => { stream: true, })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_STREAM') + expect(result.stream).toBeInstanceOf(ReadableStream) expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true }) streamOnComplete.current?.('streamed text', { @@ -348,7 +411,7 @@ describe('ollamaProvider.executeRequest', () => { expect(result.execution.output.content).toBe('{"a":1}') }) - it('streams the final response after a tool loop, carrying tool calls', async () => { + it('projects the settled tool-loop answer without a regeneration call', async () => { mockCreate .mockResolvedValueOnce( completion({ @@ -357,7 +420,7 @@ describe('ollamaProvider.executeRequest', () => { ], }) ) - .mockResolvedValueOnce(completion({ content: 'intermediate' })) + .mockResolvedValueOnce(completion({ content: 'final answer' })) const result = (await ollamaProvider.executeRequest({ ...baseRequest, @@ -365,19 +428,56 @@ describe('ollamaProvider.executeRequest', () => { tools: [makeTool('mytool')], })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_STREAM') + expect(mockCreate).toHaveBeenCalledTimes(2) expect(mockExecuteTool).toHaveBeenCalledTimes(1) - - const finalCall = mockCreate.mock.calls[2][0] - expect(finalCall.tools).toBeUndefined() - expect(finalCall.tool_choice).toBeUndefined() - - streamOnComplete.current?.('final answer', { - prompt_tokens: 2, - completion_tokens: 4, - total_tokens: 6, - }) expect(result.execution.output.content).toBe('final answer') + expect(result.execution.output.tokens).toEqual({ input: 10, output: 6, total: 16 }) expect(result.execution.output.toolCalls).toMatchObject({ count: 1 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + expect(result.stream).toBeInstanceOf(ReadableStream) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) + }) + + it('retains one distinct structured extraction call after a streamed tool loop', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'unstructured answer' })) + .mockResolvedValueOnce(completion({ content: '```json\n{"a":1}\n```' })) + + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [makeTool('mytool')], + responseFormat: { name: 'r', schema: { type: 'object' } }, + })) as unknown as StreamingResult + + expect(mockCreate).toHaveBeenCalledTimes(3) + const extractionCall = mockCreate.mock.calls[2][0] + expect(extractionCall.stream).toBeUndefined() + expect(extractionCall.response_format).toEqual({ type: 'json_object' }) + expect(extractionCall.tools).toBeUndefined() + expect(result.execution.output.content).toBe('{"a":1}') + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: '{"a":1}', turn: 'final' }]) }) }) diff --git a/apps/sim/providers/openai-compat/assistant-history.ts b/apps/sim/providers/openai-compat/assistant-history.ts new file mode 100644 index 00000000000..24b4b1d002b --- /dev/null +++ b/apps/sim/providers/openai-compat/assistant-history.ts @@ -0,0 +1,74 @@ +export type OpenAICompatReasoningField = 'reasoning' | 'reasoning_content' + +interface OpenAICompatResponseAssistantMessage { + content: string | null +} + +interface OpenAICompatResponseAssistantReasoning { + reasoning?: string + reasoning_content?: string +} + +interface OpenAICompatToolCall { + id: string + function: { + name: string + arguments: string + } +} + +export interface OpenAICompatAssistantHistoryMessage { + [key: string]: unknown + role: 'assistant' + content: string | null + tool_calls: Array<{ + id: string + type: 'function' + function: { + name: string + arguments: string + } + }> + reasoning?: string + reasoning_content?: string +} + +interface CreateOpenAICompatAssistantHistoryOptions { + message: OpenAICompatResponseAssistantMessage + toolCalls: readonly OpenAICompatToolCall[] + reasoningFields: readonly OpenAICompatReasoningField[] +} + +/** + * Replays an OpenAI-compatible assistant tool turn without replacing provider + * content or inventing reasoning fields the provider did not emit. + */ +export function createOpenAICompatAssistantHistory({ + message, + toolCalls, + reasoningFields, +}: CreateOpenAICompatAssistantHistoryOptions): OpenAICompatAssistantHistoryMessage { + const reasoningMessage = message as OpenAICompatResponseAssistantMessage & + OpenAICompatResponseAssistantReasoning + const history: OpenAICompatAssistantHistoryMessage = { + role: 'assistant', + content: message.content, + tool_calls: toolCalls.map((toolCall) => ({ + id: toolCall.id, + type: 'function', + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments, + }, + })), + } + + for (const field of reasoningFields) { + const value = reasoningMessage[field] + if (typeof value === 'string') { + history[field] = value + } + } + + return history +} diff --git a/apps/sim/providers/openai-compat/stream-events.test.ts b/apps/sim/providers/openai-compat/stream-events.test.ts index b1d2b31f5e5..74fb5305d2e 100644 --- a/apps/sim/providers/openai-compat/stream-events.test.ts +++ b/apps/sim/providers/openai-compat/stream-events.test.ts @@ -153,4 +153,18 @@ describe('createOpenAICompatibleAgentEventStream', () => { ).toBe('Hello world') expect(onComplete.mock.calls[0][0].content).toBe('Hello world') }) + + it('surfaces documented in-band provider errors', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield { + error: { message: 'Upstream provider failed' }, + choices: [], + } as any + })(), + { providerName: 'OpenRouter' } + ) + + await expect(collectEvents(stream)).rejects.toThrow('Upstream provider failed') + }) }) diff --git a/apps/sim/providers/openai-compat/stream-events.ts b/apps/sim/providers/openai-compat/stream-events.ts index cf4369bb120..9d5ef438279 100644 --- a/apps/sim/providers/openai-compat/stream-events.ts +++ b/apps/sim/providers/openai-compat/stream-events.ts @@ -12,6 +12,10 @@ import { createLogger } from '@sim/logger' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' +import { + getOpenRouterReasoningDetailText, + type OpenRouterReasoningDetail, +} from '@/providers/openrouter/reasoning' import type { AgentStreamEvent, TextDeltaTurn } from '@/providers/stream-events' import { ensureToolCallId } from '@/providers/tool-call-id' @@ -28,6 +32,8 @@ export interface OpenAICompatStreamComplete { reasoning_content?: string /** Groq-style reasoning field accumulated from deltas. */ reasoning?: string + /** OpenRouter reasoning blocks accumulated without reordering or normalization. */ + reasoning_details?: OpenRouterReasoningDetail[] usage: CompletionUsage /** Assembled when emitToolCallStarts is true (id + name + args). */ toolCalls?: OpenAICompatAssembledToolCall[] @@ -55,6 +61,24 @@ type CompatChunkDelta = ChatCompletionChunk.Choice.Delta & { reasoning?: string | { text?: string } } +export type OpenRouterChatCompletionChunk = ChatCompletionChunk & { + choices: Array< + ChatCompletionChunk.Choice & { + delta: CompatChunkDelta & { + reasoning_details?: OpenRouterReasoningDetail[] + } + } + > +} + +interface CompatStreamExtension { + error?: string | { message?: string } + x_groq?: { + usage?: CompletionUsage + error?: string | { message?: string } + } +} + function extractDeltaReasoning(delta: CompatChunkDelta | undefined): { text: string reasoning_content?: string @@ -89,6 +113,8 @@ export function createOpenAICompatibleAgentEventStream( ): ReadableStream { const { providerName, turn = 'final', emitToolCallStarts = false, onComplete } = options const streamLogger = createLogger(`${providerName}Utils`) + let cancelled = false + let streamIterator: AsyncIterator | undefined return new ReadableStream({ async start(controller) { @@ -96,6 +122,7 @@ export function createOpenAICompatibleAgentEventStream( let fullThinking = '' let reasoningContent = '' let reasoning = '' + const reasoningDetails: OpenRouterReasoningDetail[] = [] let promptTokens = 0 let completionTokens = 0 let totalTokens = 0 @@ -107,15 +134,29 @@ export function createOpenAICompatibleAgentEventStream( >() try { - for await (const chunk of stream) { + streamIterator = stream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const chunk = next.value + const extension = chunk as ChatCompletionChunk & CompatStreamExtension + if (extension.error) { + const message = + typeof extension.error === 'string' ? extension.error : extension.error.message + throw new Error(message || `${providerName} stream error`) + } + if (extension.x_groq?.error) { + const message = + typeof extension.x_groq.error === 'string' + ? extension.x_groq.error + : extension.x_groq.error.message + throw new Error(message || 'Groq stream error') + } /** * Groq puts stream usage under `x_groq.usage` on the final chunk * instead of the OpenAI `usage` field; accept either shape. */ - const usage = - chunk.usage ?? - (chunk as { x_groq?: { usage?: CompletionUsage } }).x_groq?.usage ?? - undefined + const usage = chunk.usage ?? extension.x_groq?.usage if (usage) { promptTokens = usage.prompt_tokens ?? 0 completionTokens = usage.completion_tokens ?? 0 @@ -127,13 +168,29 @@ export function createOpenAICompatibleAgentEventStream( finishReason = choice.finish_reason } const delta: CompatChunkDelta | undefined = choice?.delta + const openRouterDelta = (chunk as OpenRouterChatCompletionChunk).choices?.[0]?.delta + const chunkReasoningDetails = Array.isArray(openRouterDelta?.reasoning_details) + ? openRouterDelta.reasoning_details + : [] + let structuredThinking = '' + for (const detail of chunkReasoningDetails) { + reasoningDetails.push(detail) + const text = getOpenRouterReasoningDetailText(detail) + if (text) { + structuredThinking += text + controller.enqueue({ type: 'thinking_delta', text }) + } + } + fullThinking += structuredThinking const extracted = extractDeltaReasoning(delta) if (extracted.text) { - fullThinking += extracted.text if (extracted.reasoning_content) reasoningContent += extracted.reasoning_content if (extracted.reasoning) reasoning += extracted.reasoning - controller.enqueue({ type: 'thinking_delta', text: extracted.text }) + if (!structuredThinking) { + fullThinking += extracted.text + controller.enqueue({ type: 'thinking_delta', text: extracted.text }) + } } const content = typeof delta?.content === 'string' ? delta.content : '' @@ -182,6 +239,7 @@ export function createOpenAICompatibleAgentEventStream( } } + if (cancelled) return if (onComplete) { if (promptTokens === 0 && completionTokens === 0) { streamLogger.warn(`${providerName} stream completed without usage data`) @@ -202,6 +260,7 @@ export function createOpenAICompatibleAgentEventStream( thinking: fullThinking, ...(reasoningContent ? { reasoning_content: reasoningContent } : {}), ...(reasoning ? { reasoning } : {}), + ...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}), usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, @@ -214,8 +273,14 @@ export function createOpenAICompatibleAgentEventStream( controller.close() } catch (error) { - controller.error(error) + if (!cancelled) { + controller.error(error) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts index 80e263b9b81..b08b4050cba 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -1,11 +1,18 @@ /** * @vitest-environment node */ + +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' import { beforeEach, describe, expect, it, vi } from 'vitest' import { openaiCompatToolCallStartChunks } from '@/providers/__fixtures__/openai-compat' -import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' +import type { OpenRouterChatCompletionChunk } from '@/providers/openai-compat/stream-events' +import { + createOpenAICompatStreamingToolLoopStream, + type OpenAICompatCreateCompletion, +} from '@/providers/openai-compat/streaming-tool-loop' import type { AgentStreamEvent } from '@/providers/stream-events' -import type { TimeSegment } from '@/providers/types' +import type { ProviderToolConfig, TimeSegment } from '@/providers/types' const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ mockExecuteTool: vi.fn(), @@ -80,6 +87,7 @@ function toolThenAnswerChunks(toolName: string, args: string, answer: string) { { choices: [ { + finish_reason: 'tool_calls', delta: { tool_calls: [{ index: 0, function: { arguments: args } }], }, @@ -91,6 +99,21 @@ function toolThenAnswerChunks(toolName: string, args: string, answer: string) { ] as const } +function openRouterChunk( + delta: OpenRouterChatCompletionChunk['choices'][number]['delta'], + finishReason: ChatCompletionChunk.Choice['finish_reason'] = null, + usage?: CompletionUsage +): OpenRouterChatCompletionChunk { + return { + id: 'chunk', + object: 'chat.completion.chunk', + created: 0, + model: 'openrouter/test-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + usage, + } +} + describe('createOpenAICompatStreamingToolLoopStream', () => { const logger = { info: vi.fn(), @@ -127,7 +150,12 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { } return (async function* () { yield { - choices: [{ delta: { content: 'done', reasoning_content: 'final thought' } }], + choices: [ + { + delta: { content: 'done', reasoning_content: 'final thought' }, + finish_reason: 'stop', + }, + ], usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, } })() @@ -163,6 +191,133 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { role: 'assistant', reasoning_content: 'I should call the tool. ', }) + const modelSegments = timeSegments.filter((segment) => segment.type === 'model') + expect(modelSegments[0]).toMatchObject({ + thinkingContent: 'I should call the tool. ', + finishReason: 'tool_calls', + tokens: { input: 5, output: 3, total: 8 }, + provider: 'deepseek', + toolCalls: [{ id: 'call_1', name: 'lookup', arguments: {} }], + }) + expect(modelSegments[1]).toMatchObject({ + assistantContent: 'done', + thinkingContent: 'final thought', + finishReason: 'stop', + tokens: { input: 8, output: 2, total: 10 }, + provider: 'deepseek', + }) + }) + + it('replays interleaved OpenRouter reasoning_details unchanged and in order', async () => { + const messageHistory: unknown[][] = [] + const lookupTool: ProviderToolConfig = { + id: 'lookup', + name: 'lookup', + description: 'Looks up a value', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } + const reasoningDetails = [ + { + type: 'reasoning.text', + text: 'Inspect the request. ', + signature: null, + id: 'reasoning-1', + format: 'anthropic-claude-v1', + index: 0, + }, + { + type: 'reasoning.encrypted', + data: 'opaque-data', + id: 'reasoning-2', + format: 'anthropic-claude-v1', + index: 1, + }, + { + type: 'reasoning.summary', + summary: 'Use the lookup result.', + id: 'reasoning-3', + format: 'anthropic-claude-v1', + index: 2, + }, + ] as const + let call = 0 + + const createStream: OpenAICompatCreateCompletion = vi.fn(async (params) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield openRouterChunk({ reasoning_details: [reasoningDetails[0]] }) + yield openRouterChunk({ + content: 'I will look that up.', + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '' }, + }, + ], + }) + yield openRouterChunk({ reasoning_details: [reasoningDetails[1]] }) + yield openRouterChunk( + { + reasoning_details: [reasoningDetails[2]], + tool_calls: [{ index: 0, function: { arguments: '{}' } }], + }, + 'tool_calls', + { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } + ) + })() + } + return (async function* () { + yield openRouterChunk({ content: 'done' }, 'stop', { + prompt_tokens: 8, + completion_tokens: 2, + total_tokens: 10, + }) + })() + }) + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenRouter', + request: { + model: 'openrouter/anthropic/claude-sonnet-4', + apiKey: 'k', + messages: [], + tools: [lookupTool], + }, + basePayload: { model: 'anthropic/claude-sonnet-4' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(events.filter((event) => event.type === 'thinking_delta')).toEqual([ + { type: 'thinking_delta', text: 'Inspect the request. ' }, + { type: 'thinking_delta', text: 'Use the lookup result.' }, + ]) + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (message) => message.role === 'assistant' && Array.isArray(message.tool_calls) + ) + expect(assistantWithTools).toEqual({ + role: 'assistant', + content: 'I will look that up.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + reasoning_details: reasoningDetails, + }) }) it('omits reasoning_content when preserveAssistantReasoning is false', async () => { @@ -179,7 +334,7 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { } return (async function* () { yield { - choices: [{ delta: { content: 'done' } }], + choices: [{ delta: { content: 'done' }, finish_reason: 'stop' }], usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, } })() @@ -226,7 +381,7 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { } return (async function* () { yield { - choices: [{ delta: { content: 'final answer' } }], + choices: [{ delta: { content: 'final answer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, } })() @@ -283,7 +438,7 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { } return (async function* () { yield { - choices: [{ delta: { content: 'recovered' } }], + choices: [{ delta: { content: 'recovered' }, finish_reason: 'stop' }], usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, } })() @@ -319,6 +474,129 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { expect(createStream).toHaveBeenCalledTimes(2) }) + it.each(['null', '[]', '"text"', '0', 'false'])( + 'does not execute tools with non-object arguments: %s', + async (argumentsJson) => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', argumentsJson, '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'recovered' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(createStream).toHaveBeenCalledTimes(2) + } + ) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + const createStream = vi.fn(async () => { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + }) + mockExecuteTool.mockRejectedValueOnce(new DOMException('cancelled', 'AbortError')) + + const onComplete = vi.fn() + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(createStream).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 5, output: 3, total: 8 } }) + ) + }) + + it.each([ + [false, 'false'], + [0, '0'], + ['', '""'], + [null, 'null'], + ] as const)('replays successful falsy tool output %j', async (output, expectedContent) => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'done' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + mockExecuteTool.mockResolvedValueOnce({ success: true, output }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + const secondPayload = createStream.mock.calls[1][0] as { + messages: Array<{ role: string; content?: string }> + } + expect(secondPayload.messages).toContainEqual( + expect.objectContaining({ role: 'tool', content: expectedContent }) + ) + }) + it('streams thinking live and assembles tool args from deltas', async () => { let call = 0 const createStream = vi.fn(async () => { @@ -336,11 +614,14 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { ], usage: { prompt_tokens: 4, completion_tokens: 6, total_tokens: 10 }, } + yield { + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + } })() } return (async function* () { yield { - choices: [{ delta: { content: 'fetched' } }], + choices: [{ delta: { content: 'fetched' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, } })() @@ -372,4 +653,118 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { expect.not.objectContaining({ skipPostProcess: true }) ) }) + + it('finalizes truncated text when finish_reason is length without a tool call', async () => { + const createStream = vi.fn(async () => + (async function* () { + yield { + choices: [{ delta: { content: 'Truncated answer' }, finish_reason: 'length' }], + usage: { prompt_tokens: 7, completion_tokens: 9, total_tokens: 16 }, + } + })() + ) + const onComplete = vi.fn() + const timeSegments: TimeSegment[] = [] + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenAI', + request: { model: 'gpt-4.1', apiKey: 'k', messages: [] }, + basePayload: { model: 'gpt-4.1', max_tokens: 9 }, + messages: [{ role: 'user', content: 'answer' }], + createStream: createStream as never, + logger, + timeSegments, + onComplete, + }) + ) + + expect(events).toEqual([ + { type: 'text_delta', text: 'Truncated answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Truncated answer', + tokens: { input: 7, output: 9, total: 16 }, + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'length', + assistantContent: 'Truncated answer', + }) + }) + + it('rejects a length-capped turn containing a partial tool call', async () => { + const createStream = vi.fn(async () => + (async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_partial', + type: 'function', + function: { name: 'lookup', arguments: '{"query":' }, + }, + ], + }, + finish_reason: null, + }, + ], + } + yield { + choices: [{ delta: {}, finish_reason: 'length' }], + usage: { prompt_tokens: 7, completion_tokens: 9, total_tokens: 16 }, + } + })() + ) + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenAI', + request: { + model: 'gpt-4.1', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'gpt-4.1', max_tokens: 9 }, + messages: [{ role: 'user', content: 'answer' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toEqual(new Error('OpenAI returned tool calls with finish_reason length')) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'call_partial', + name: 'lookup', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'call_partial', + name: 'lookup', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.ts index 170a6dbe25d..7f766317be7 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.ts @@ -13,6 +13,7 @@ import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import { MAX_TOOL_ITERATIONS } from '@/providers' @@ -20,12 +21,16 @@ import { createOpenAICompatibleAgentEventStream, type OpenAICompatAssembledToolCall, } from '@/providers/openai-compat/stream-events' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { isAbortError, + parseToolArguments, type StreamingToolLoopComplete, settleOpenTools, + terminateToolLoop, } from '@/providers/streaming-tool-loop-shared' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderRequest, TimeSegment } from '@/providers/types' import { calculateCost, @@ -85,6 +90,16 @@ export function createOpenAICompatStreamingToolLoopStream( preserveAssistantReasoning = false, } = options const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeEventReader: ReadableStreamDefaultReader | undefined + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } return new ReadableStream({ async start(controller) { @@ -100,24 +115,49 @@ export function createOpenAICompatStreamingToolLoopStream( const toolCalls: unknown[] = [] const toolResults: Record[] = [] const openToolStarts = new Map() - const streamOpts = request.abortSignal ? { signal: request.abortSignal } : undefined + const streamOpts = { signal: loopAbortController.signal } let currentToolChoice = basePayload.tool_choice let usedForcedTools: string[] = [] let hasUsedForcedTool = false + const reportProgress = () => { + const modelCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: modelCost.input, + output: modelCost.output, + total: modelCost.total + (toolCost || 0), + ...(toolCost ? { toolCost } : {}), + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (request.abortSignal?.aborted) { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { settleOpenTools(controller, openToolStarts, 'cancelled') throw new DOMException('Stream aborted', 'AbortError') } const modelStart = Date.now() + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS const turnPayload = { ...basePayload, messages: currentMessages, - ...(currentToolChoice !== undefined ? { tool_choice: currentToolChoice } : {}), + ...(finalSynthesis + ? { tools: undefined, tool_choice: 'none' } + : currentToolChoice !== undefined + ? { tool_choice: currentToolChoice } + : {}), stream: true as const, } @@ -130,11 +170,26 @@ export function createOpenAICompatStreamingToolLoopStream( let turnContent = '' let turnReasoningContent = '' let turnReasoning = '' + let turnReasoningDetails: OpenRouterReasoningDetail[] | undefined let turnFinishReason: string | undefined let assembledTools: OpenAICompatAssembledToolCall[] = [] const liveText: string[] = [] + let sawToolCallDelta = false + const inspectedStream = (async function* () { + for await (const chunk of stream) { + if ( + chunk.choices.some( + (choice) => + Array.isArray(choice.delta.tool_calls) && choice.delta.tool_calls.length > 0 + ) + ) { + sawToolCallDelta = true + } + yield chunk + } + })() - const eventStream = createOpenAICompatibleAgentEventStream(stream, { + const eventStream = createOpenAICompatibleAgentEventStream(inspectedStream, { providerName, emitToolCallStarts: true, onComplete: (result) => { @@ -146,6 +201,7 @@ export function createOpenAICompatStreamingToolLoopStream( turnContent = result.content || '' turnReasoningContent = result.reasoning_content || '' turnReasoning = result.reasoning || '' + turnReasoningDetails = result.reasoning_details turnFinishReason = result.finishReason assembledTools = result.toolCalls ?? [] }, @@ -153,6 +209,7 @@ export function createOpenAICompatStreamingToolLoopStream( { const reader = eventStream.getReader() + activeEventReader = reader while (true) { const { done, value } = await reader.read() if (done) break @@ -168,23 +225,37 @@ export function createOpenAICompatStreamingToolLoopStream( controller.enqueue({ type: 'text_delta', text: value.text, turn: 'pending' }) } } + activeEventReader = undefined } - /** - * Only execute tools when the turn completed normally. A `length` - * finish means the stream truncated mid-generation — assembled tool - * arguments would be partial JSON. - */ - const toolsExecutable = turnFinishReason !== 'length' const assembledPendingTools = assembledTools.filter((tc) => tc.id && tc.function?.name) - if (assembledPendingTools.length > 0 && !toolsExecutable) { - logger.warn('Skipping tool execution for truncated turn', { + if (turnFinishReason === undefined) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error(`${providerName} stream ended without finish_reason`) + } + if (finalSynthesis && assembledPendingTools.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error(`${providerName} returned tool calls during final synthesis`) + } + if (assembledPendingTools.length > 0 && turnFinishReason !== 'tool_calls') { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `${providerName} returned tool calls with finish_reason ${turnFinishReason}` + ) + } + const cappedTextTurn = + turnFinishReason === 'length' && !sawToolCallDelta && openToolStarts.size === 0 + if ( + assembledPendingTools.length === 0 && + turnFinishReason !== 'stop' && + !cappedTextTurn + ) { + logger.warn('Rejecting incomplete model turn', { finishReason: turnFinishReason, - toolCount: assembledPendingTools.length, }) - settleOpenTools(controller, openToolStarts, 'error') + throw new Error(`${providerName} stream ended with finish_reason ${turnFinishReason}`) } - const pendingTools = toolsExecutable ? assembledPendingTools : [] + const pendingTools = assembledPendingTools const turnTag = pendingTools.length > 0 ? 'intermediate' : 'final' const turnText = turnContent || liveText.join('') // If the parser assembled text but we somehow missed deltas, still emit @@ -193,10 +264,7 @@ export function createOpenAICompatStreamingToolLoopStream( controller.enqueue({ type: 'text_delta', text: turnText, turn: 'pending' }) } controller.enqueue({ type: 'turn_end', turn: turnTag }) - if (turnText) { - // Keep the latest turn's text so a MAX_TOOL_ITERATIONS exit still has content. - content = turnText - } + content = turnText const modelEnd = Date.now() const thisModelTime = modelEnd - modelStart @@ -210,6 +278,31 @@ export function createOpenAICompatStreamingToolLoopStream( endTime: modelEnd, duration: thisModelTime, }) + enrichLastModelSegmentFromChatCompletions( + timeSegments, + { + choices: [ + { + message: { + content: turnText, + tool_calls: pendingTools, + ...(turnReasoningContent ? { reasoning_content: turnReasoningContent } : {}), + ...(turnReasoning ? { reasoning: turnReasoning } : {}), + ...(turnReasoningDetails?.length + ? { reasoning_details: turnReasoningDetails } + : {}), + }, + finish_reason: turnFinishReason, + }, + ], + usage: turnUsage, + }, + pendingTools, + { + model: request.model, + provider: providerName.toLowerCase(), + } + ) tokens.input += turnUsage.prompt_tokens tokens.output += turnUsage.completion_tokens tokens.total += @@ -240,9 +333,10 @@ export function createOpenAICompatStreamingToolLoopStream( const assistantHistory: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam & { reasoning_content?: string reasoning?: string + reasoning_details?: OpenRouterReasoningDetail[] } = { role: 'assistant', - content: turnText || null, + content: turnText, tool_calls: pendingTools, } if (preserveAssistantReasoning) { @@ -253,6 +347,9 @@ export function createOpenAICompatStreamingToolLoopStream( assistantHistory.reasoning = turnReasoning } } + if (turnReasoningDetails?.length) { + assistantHistory.reasoning_details = turnReasoningDetails + } currentMessages.push(assistantHistory) const toolsStartTime = Date.now() @@ -266,14 +363,10 @@ export function createOpenAICompatStreamingToolLoopStream( * with defaulted `{}` args could fire side effects with missing * parameters. Fail the call and let the model react to the error. */ - let toolArgs: Record = {} - let argsParseError = false + let toolArgs: Record try { - toolArgs = JSON.parse(tc.function.arguments || '{}') - } catch { - argsParseError = true - } - if (argsParseError) { + toolArgs = parseToolArguments(tc.function.arguments, toolName) + } catch (error) { const endTime = Date.now() openToolStarts.delete(toolUseId) controller.enqueue({ @@ -290,7 +383,7 @@ export function createOpenAICompatStreamingToolLoopStream( result: { success: false as const, output: undefined, - error: `Invalid tool arguments JSON for ${toolName}`, + error: getErrorMessage(error, `Invalid tool arguments for ${toolName}`), }, startTime: toolCallStartTime, endTime, @@ -300,7 +393,7 @@ export function createOpenAICompatStreamingToolLoopStream( } try { - if (request.abortSignal?.aborted) { + if (loopAbortController.signal.aborted) { throw new DOMException('Stream aborted', 'AbortError') } const tool = request.tools?.find((t) => t.id === toolName) @@ -336,7 +429,7 @@ export function createOpenAICompatStreamingToolLoopStream( request ) const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, + signal: loopAbortController.signal, }) const toolCallEndTime = Date.now() const value = { @@ -359,11 +452,29 @@ export function createOpenAICompatStreamingToolLoopStream( }) return value } catch (error) { - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - if (!cancelled) { - logger.error('Error processing tool call:', { error, toolName }) - } const toolCallEndTime = Date.now() + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'cancelled', + }) + throw error + } + if (isAbortError(error)) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) const value = { toolUseId, toolName, @@ -377,7 +488,7 @@ export function createOpenAICompatStreamingToolLoopStream( startTime: toolCallStartTime, endTime: toolCallEndTime, duration: toolCallEndTime - toolCallStartTime, - status: (cancelled ? 'cancelled' : 'error') as ToolCallEndStatus, + status: 'error' as ToolCallEndStatus, } openToolStarts.delete(toolUseId) controller.enqueue({ @@ -402,9 +513,11 @@ export function createOpenAICompatStreamingToolLoopStream( }) let resultContent: unknown - if (value.result.success && value.result.output) { - toolResults.push(value.result.output as Record) - resultContent = value.result.output + if (value.result.success) { + if (isRecordLike(value.result.output)) { + toolResults.push(value.result.output) + } + resultContent = value.result.output ?? null } else { resultContent = { error: true, @@ -448,39 +561,35 @@ export function createOpenAICompatStreamingToolLoopStream( iterationCount++ } - /** - * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the - * answer channel would otherwise be empty. Flush the last turn's text - * as the final answer so legacy consumers still receive content. - */ - if (!sawFinalTurn && content) { - controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + if (!sawFinalTurn) { + throw new Error(`${providerName} tool loop ended without a final response`) } - const modelCost = calculateCost(request.model, tokens.input, tokens.output) - const toolCostTotal = sumToolCosts(toolResults) - onComplete({ - content, - tokens, - cost: { - input: modelCost.input, - output: modelCost.output, - total: modelCost.total + (toolCostTotal || 0), - ...(toolCostTotal ? { toolCost: toolCostTotal } : {}), - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - modelTime, - toolsTime, - firstResponseTime, - iterations: modelCalls, - }) + reportProgress() controller.close() } catch (error) { - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - settleOpenTools(controller, openToolStarts, cancelled ? 'cancelled' : 'error') - controller.error(toError(error)) + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error(`${providerName} streaming tool loop failed`, { + error: toError(cause).message, + }), + }) + } finally { + activeEventReader = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) } }, + async cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + await activeEventReader?.cancel(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, }) } diff --git a/apps/sim/providers/openai/core.cache-key.test.ts b/apps/sim/providers/openai/core.cache-key.test.ts new file mode 100644 index 00000000000..c12bfa34928 --- /dev/null +++ b/apps/sim/providers/openai/core.cache-key.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + * + * OpenAI prompt caching is automatic, so the only lever is routing stickiness: + * a `prompt_cache_key` that is stable for one agent block and distinct between + * blocks. Sharing a key across blocks with different prefixes would lower the + * hit rate rather than raise it. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeResponsesProviderRequest } from '@/providers/openai/core' +import type { ProviderRequest } from '@/providers/types' + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/utils', () => ({ + calculateCost: () => ({ input: 0, output: 0, total: 0 }), + sumToolCosts: () => 0, + enforceStrictSchema: (schema: unknown) => schema, + prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }), + prepareToolsWithUsageControl: (tools: unknown[]) => ({ + tools, + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), + supportsReasoningEffort: () => false, +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +const COMPLETED_RESPONSE = { + id: 'resp_1', + status: 'completed', + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, +} + +describe('executeResponsesProviderRequest prompt cache key', () => { + let fetchMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + // A Response body reads once, so each call needs its own instance. + fetchMock = vi.fn( + async () => + new Response(JSON.stringify(COMPLETED_RESPONSE), { + headers: { 'Content-Type': 'application/json' }, + }) + ) + }) + + async function sentCacheKey(request: Partial): Promise { + await executeResponsesProviderRequest( + { + model: 'gpt-5.5', + apiKey: 'k', + messages: [{ role: 'user', content: 'hi' }], + ...request, + }, + { + providerId: 'openai', + providerLabel: 'OpenAI', + modelName: 'gpt-5.5', + endpoint: 'https://api.openai.com/v1/responses', + headers: { Authorization: 'Bearer k' }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + fetch: fetchMock as unknown as typeof fetch, + } + ) + const body = JSON.parse(fetchMock.mock.calls.at(-1)?.[1].body as string) + return body.prompt_cache_key + } + + it('sends the same key for repeat runs of one block', async () => { + const first = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const second = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + + expect(first).toBeTruthy() + expect(second).toBe(first) + }) + + it('sends a different key for another block in the same workflow', async () => { + const blockA = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const blockB = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-b' }) + + expect(blockB).not.toBe(blockA) + }) + + it('sends a different key for the same block id in another workflow', async () => { + const workflowOne = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const workflowTwo = await sentCacheKey({ workflowId: 'wf-2', blockId: 'block-a' }) + + expect(workflowTwo).not.toBe(workflowOne) + }) + + it('leaks no internal identifier into the key', async () => { + const key = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + + expect(key).not.toContain('wf-1') + expect(key).not.toContain('block-a') + }) + + it('omits the key when the caller has no stable identity', async () => { + expect(await sentCacheKey({ workflowId: 'wf-1' })).toBeUndefined() + expect(await sentCacheKey({ blockId: 'block-a' })).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts index 06f5e926331..a9b588951b0 100644 --- a/apps/sim/providers/openai/core.reasoning.test.ts +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -7,6 +7,7 @@ * unverified-organization 400 falls back to a summary-free retry. */ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import type { BlockTokens } from '@/executor/types' import { executeResponsesProviderRequest } from '@/providers/openai/core' import type { ProviderRequest } from '@/providers/types' import { executeTool } from '@/tools' @@ -18,8 +19,8 @@ vi.mock('@/providers/utils', () => ({ sumToolCosts: () => 0, enforceStrictSchema: (schema: unknown) => schema, prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }), - prepareToolsWithUsageControl: () => ({ - tools: [], + prepareToolsWithUsageControl: (tools: unknown[]) => ({ + tools, toolChoice: undefined, forcedTools: [], hasFilteredTools: false, @@ -37,6 +38,22 @@ function jsonResponse(body: unknown, status = 200) { }) } +function sseResponse(events: unknown[]) { + const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('') + return new Response(body, { headers: { 'Content-Type': 'text/event-stream' } }) +} + +async function collect(stream: ReadableStream) { + const events: unknown[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + const COMPLETED_RESPONSE = { id: 'resp_1', status: 'completed', @@ -61,6 +78,7 @@ describe('executeResponsesProviderRequest reasoning payload', () => { let fetchMock: ReturnType beforeEach(() => { + vi.clearAllMocks() fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE)) }) @@ -127,6 +145,46 @@ describe('executeResponsesProviderRequest reasoning payload', () => { expect((result as { content: string }).content).toBe('hello') }) + it('remembers summary rejection for later tool-loop turns', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { + error: { + message: + "Your organization must be verified to generate reasoning summaries. (param: 'reasoning.summary')", + }, + }, + 400 + ) + ) + .mockResolvedValueOnce( + jsonResponse({ + id: 'resp_tool', + status: 'completed', + output: [ + { type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: '{}' }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }) + ) + .mockResolvedValueOnce(jsonResponse(COMPLETED_RESPONSE)) + ;(executeTool as Mock).mockResolvedValue({ success: true, output: { results: [] } }) + + await run({ + model: 'o3', + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string).reasoning).toEqual({ + summary: 'auto', + }) + expect(JSON.parse(fetchMock.mock.calls[1][1].body as string).reasoning).toBeUndefined() + expect(JSON.parse(fetchMock.mock.calls[2][1].body as string).reasoning).toBeUndefined() + }) + it('does not retry on unrelated 400s', async () => { fetchMock.mockResolvedValue( jsonResponse({ error: { message: 'Invalid value for input' } }, 400) @@ -164,75 +222,464 @@ describe('executeResponsesProviderRequest reasoning payload', () => { expect(body.reasoning).toBeUndefined() }) - describe('final regenerated stream after the tool loop', () => { - const TOOL_CALL_RESPONSE = { - id: 'resp_tool', - status: 'completed', - output: [{ type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: '{}' }], - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, - } - - const SETTLED_ANSWER_RESPONSE = { - id: 'resp_answer', - status: 'completed', - output: [ - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Settled answer from loop' }], - }, - ], - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, - } - - /** An SSE stream that ends without any output_text (dead function_call turn). */ - function emptySseResponse() { - return new Response('data: {"type":"response.completed"}\n\ndata: [DONE]\n\n', { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }) - } + describe('live streaming tool loop', () => { + it('streams reasoning and tool lifecycle in real time without a regeneration call', async () => { + const toolTurnResponse = { + id: 'resp_tool', + status: 'completed', + output: [ + { + id: 'rs_1', + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'I should search.' }], + }, + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 }, + } + const answerTurnResponse = { + id: 'resp_answer', + status: 'completed', + output: [ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'Final answer', annotations: [] }], + }, + ], + usage: { input_tokens: 4, output_tokens: 5, total_tokens: 9 }, + } - async function collect(stream: ReadableStream) { + fetchMock + .mockResolvedValueOnce( + sseResponse([ + { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'I should search.', + }, + { + type: 'response.output_item.added', + output_index: 1, + sequence_number: 2, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + sequence_number: 3, + response: toolTurnResponse, + }, + ]) + ) + .mockResolvedValueOnce( + sseResponse([ + { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_2', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'I have the result.', + }, + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 1, + content_index: 0, + sequence_number: 2, + delta: 'Final answer', + logprobs: [], + }, + { + type: 'response.completed', + sequence_number: 3, + response: answerTurnResponse, + }, + ]) + ) + let resolveTool!: (value: { success: true; output: { results: string[] } }) => void + ;(executeTool as Mock).mockReturnValue( + new Promise((resolve) => { + resolveTool = resolve + }) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream; execution: { output: { content: string } } } + + const reader = result.stream.getReader() const events: unknown[] = [] - const reader = stream.getReader() + for (let index = 0; index < 3; index++) { + const next = await reader.read() + expect(next.done).toBe(false) + events.push(next.value) + } + + expect(events).toEqual([ + { type: 'thinking_delta', text: 'I should search.' }, + { type: 'tool_call_start', id: 'call_1', name: 'exa_search' }, + { type: 'turn_end', turn: 'intermediate' }, + ]) + expect(fetchMock).toHaveBeenCalledTimes(1) + + resolveTool({ success: true, output: { results: ['hit'] } }) while (true) { - const { done, value } = await reader.read() - if (done) break - events.push(value) + const next = await reader.read() + if (next.done) break + events.push(next.value) } - return events - } - it('forces tool_choice none and keeps the tool-loop answer when the stream has no text', async () => { + expect(events).toEqual([ + { type: 'thinking_delta', text: 'I should search.' }, + { type: 'tool_call_start', id: 'call_1', name: 'exa_search' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'tool_call_end', id: 'call_1', name: 'exa_search', status: 'success' }, + { type: 'thinking_delta', text: 'I have the result.' }, + { type: 'text_delta', text: 'Final answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(fetchMock).toHaveBeenCalledTimes(2) + const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body as string) + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body as string) + expect(firstBody.stream).toBe(true) + expect(secondBody.stream).toBe(true) + expect(secondBody.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'rs_1', type: 'reasoning' }), + expect.objectContaining({ call_id: 'call_1', type: 'function_call' }), + expect.objectContaining({ call_id: 'call_1', type: 'function_call_output' }), + ]) + ) + expect(result.execution.output.content).toBe('Final answer') + }) + + it('fails an unexpected tool AbortError and preserves completed usage', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: 'resp_tool', + status: 'completed', + output: [ + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 }, + }, + }, + ]) + ) + ;(executeTool as Mock).mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { + stream: ReadableStream + execution: { output: { tokens: BlockTokens } } + } + + await expect(collect(result.stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(result.execution.output.tokens).toEqual({ + input: 2, + output: 3, + total: 5, + cacheRead: 0, + cacheWrite: 0, + }) + }) + + it('makes the final answer turn after the maximum tool batches', async () => { + for (let index = 0; index < 5; index++) { + const callId = `call_${index}` + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: `fc_${index}`, + type: 'function_call', + call_id: callId, + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: `resp_${index}`, + status: 'completed', + output: [ + { + id: `fc_${index}`, + type: 'function_call', + call_id: callId, + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + } + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_text.delta', + item_id: 'msg_final', + output_index: 0, + content_index: 0, + sequence_number: 1, + delta: 'Answer after five tools', + logprobs: [], + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: 'resp_final', + status: 'completed', + output: [ + { + id: 'msg_final', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: 'Answer after five tools', annotations: [] }, + ], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) ;(executeTool as Mock).mockResolvedValue({ success: true, output: { results: [] } }) - fetchMock - .mockResolvedValueOnce(jsonResponse(TOOL_CALL_RESPONSE)) - .mockResolvedValueOnce(jsonResponse(SETTLED_ANSWER_RESPONSE)) - .mockResolvedValueOnce(emptySseResponse()) const result = (await run({ model: 'gpt-5.5', stream: true, + agentEvents: true, tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, })) as { stream: ReadableStream; execution: { output: { content: string } } } - expect(fetchMock).toHaveBeenCalledTimes(3) - const streamBody = JSON.parse(fetchMock.mock.calls[2][1].body as string) - expect(streamBody.stream).toBe(true) - // The regeneration exists to stream prose; streamed calls are never executed. - expect(streamBody.tool_choice).toBe('none') - - const events = await collect(result.stream) - // Settled chips for the silent loop's executed calls ride ahead of the answer. - expect(events[0]).toEqual({ type: 'tool_call_start', id: 'call_1', name: 'exa_search' }) - expect(events[1]).toEqual({ - type: 'tool_call_end', - id: 'call_1', - name: 'exa_search', - status: 'success', + await collect(result.stream) + + expect(fetchMock).toHaveBeenCalledTimes(6) + expect(executeTool).toHaveBeenCalledTimes(5) + const finalBody = JSON.parse(fetchMock.mock.calls[5][1].body as string) + expect(finalBody.tool_choice).toBe('none') + expect(finalBody.tools).toBeUndefined() + expect(result.execution.output.content).toBe('Answer after five tools') + }) + + it('finalizes truncated text when max_output_tokens is reached without a tool call', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_text.delta', + item_id: 'msg_partial', + output_index: 0, + content_index: 0, + sequence_number: 1, + delta: 'Partial answer', + logprobs: [], + }, + { + type: 'response.incomplete', + sequence_number: 2, + response: { + id: 'resp_incomplete', + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { + stream: ReadableStream + execution: { + output: { + content: string + tokens: { input: number; output: number; total: number } + } + } + } + + await expect(collect(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Partial answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(result.execution.output).toMatchObject({ + content: 'Partial answer', + tokens: { input: 1, output: 1, total: 2 }, + }) + }) + + it('rejects a max_output_tokens turn containing a partial tool call', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'fc_partial', + output_index: 0, + sequence_number: 2, + delta: '{"query":', + }, + { + type: 'response.incomplete', + sequence_number: 3, + response: { + id: 'resp_incomplete_tool', + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [ + { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'exa_search', + arguments: '{"query":', + status: 'incomplete', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream } + + await expect(collect(result.stream)).rejects.toThrow( + 'OpenAI Responses stream incomplete: max_output_tokens' + ) + expect(executeTool).not.toHaveBeenCalled() + }) + + it('aborts the active Responses stream when its consumer cancels', async () => { + let requestSignal: AbortSignal | undefined + const encoder = new TextEncoder() + fetchMock.mockImplementation(async (_url: string, init: RequestInit) => { + requestSignal = init.signal as AbortSignal + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'Still working', + })}\n\n` + ) + ) + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } } + ) + }) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream } + + const reader = result.stream.getReader() + expect(await reader.read()).toEqual({ + done: false, + value: { type: 'thinking_delta', text: 'Still working' }, }) - expect(result.execution.output.content).toBe('Settled answer from loop') + + await reader.cancel('client disconnected') + + expect(requestSignal?.aborted).toBe(true) }) }) }) diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index d7cbce000a8..df4ca1b6cd0 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -1,20 +1,27 @@ +import { createHash } from 'node:crypto' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type OpenAI from 'openai' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' -import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { createOpenAIResponsesStreamingToolLoopStream } from '@/providers/openai/streaming-tool-loop' +import { enrichLastModelSegmentFromOpenAIResponse } from '@/providers/openai/trace' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' -import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' import { - calculateCost, enforceStrictSchema, prepareToolExecution, prepareToolsWithUsageControl, - sumToolCosts, supportsReasoningEffort, trackForcedToolUsage, } from '@/providers/utils' @@ -24,7 +31,6 @@ import { convertResponseOutputToInputItems, convertToolsToResponses, createReadableStreamFromResponses, - extractResponseReasoning, extractResponseText, extractResponseToolCalls, parseResponsesUsage, @@ -36,6 +42,22 @@ import { type PreparedTools = ReturnType type ToolChoice = PreparedTools['toolChoice'] +/** + * Stable routing key for OpenAI's prompt cache, scoped to one agent block. + * + * Per-block rather than per-workflow: two blocks in the same workflow have + * different prefixes, so sharing a key would pull them onto the same engine and + * lower the hit rate. Hashed so no internal identifier leaves the system. + * Returns `undefined` when the caller has no stable identity to key on. + */ +function buildPromptCacheKey(request: ProviderRequest): string | undefined { + if (!request.workflowId || !request.blockId) return undefined + return createHash('sha256') + .update(`${request.workflowId}:${request.blockId}`) + .digest('hex') + .slice(0, 32) +} + export interface ResponsesProviderConfig { providerId: string providerLabel: string @@ -97,6 +119,19 @@ export async function executeResponsesProviderRequest( model: config.modelName, } + /** + * OpenAI prompt caching is automatic and free, so there is nothing to toggle + * — but requests only hit a warm cache when they route to the same engine. + * A stable key per agent block sharpens that routing and is required for + * reliable matching on GPT-5.6+. + * + * `prompt_cache_key` is absent from the pinned SDK's typings, which is + * harmless: this body is a plain object posted through `fetch`, never + * `responses.create()`. Do not delete it as an unknown parameter. + */ + const promptCacheKey = buildPromptCacheKey(request) + if (promptCacheKey) basePayload.prompt_cache_key = promptCacheKey + if (request.temperature !== undefined) basePayload.temperature = request.temperature if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens @@ -127,11 +162,6 @@ export async function executeResponsesProviderRequest( } } - // Store response format config - for Azure with tools, we defer applying it until after tool calls complete - let deferredTextFormat: OpenAI.Responses.ResponseFormatTextJSONSchemaConfig | undefined - const hasTools = !!request.tools?.length - const isAzure = config.providerId === 'azure-openai' - if (request.responseFormat) { const isStrict = request.responseFormat.strict !== false const rawSchema = request.responseFormat.schema || request.responseFormat @@ -145,20 +175,11 @@ export async function executeResponsesProviderRequest( strict: isStrict, } - // Azure OpenAI has issues combining tools + response_format in the same request - // Defer the format until after tool calls complete for Azure - if (isAzure && hasTools) { - deferredTextFormat = textFormat - logger.info( - `Deferring JSON schema response format for ${config.providerLabel} (will apply after tool calls complete)` - ) - } else { - basePayload.text = { - ...((basePayload.text as Record) ?? {}), - format: textFormat, - } - logger.info(`Added JSON schema response format to ${config.providerLabel} request`) + basePayload.text = { + ...((basePayload.text as Record) ?? {}), + format: textFormat, } + logger.info(`Added JSON schema response format to ${config.providerLabel} request`) } const tools = request.tools?.length @@ -247,14 +268,20 @@ export async function executeResponsesProviderRequest( : bodyRest } + let reasoningSummariesUnavailable = false + const fetchResponsesWithSummaryFallback = async ( - body: Record + requestedBody: Record, + abortSignal = request.abortSignal ): Promise => { + const body = reasoningSummariesUnavailable + ? (stripReasoningSummary(requestedBody) ?? requestedBody) + : requestedBody const response = await fetchImpl(config.endpoint, { method: 'POST', headers: config.headers, body: JSON.stringify(body), - signal: request.abortSignal, + signal: abortSignal, }) if (response.ok) return response @@ -266,6 +293,7 @@ export async function executeResponsesProviderRequest( throw new Error(`${config.providerLabel} API error (${response.status}): ${message}`) } + reasoningSummariesUnavailable = true logger.warn( `${config.providerLabel} rejected reasoning summaries (organization not verified); retrying without summary`, { model: config.modelName } @@ -274,7 +302,7 @@ export async function executeResponsesProviderRequest( method: 'POST', headers: config.headers, body: JSON.stringify(strippedBody), - signal: request.abortSignal, + signal: abortSignal, }) if (!retryResponse.ok) { const retryMessage = await parseErrorResponse(retryResponse) @@ -296,7 +324,58 @@ export async function executeResponsesProviderRequest( const providerStartTimeISO = new Date(providerStartTime).toISOString() try { - if (request.stream && (!tools || tools.length === 0)) { + const hasActiveTools = Array.isArray(basePayload.tools) && basePayload.tools.length > 0 + + if (request.stream && hasActiveTools) { + logger.info(`Using live streaming tool loop for ${config.providerLabel} request`) + const timeSegments: TimeSegment[] = [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAIResponsesStreamingToolLoopStream({ + providerId: config.providerId, + providerLabel: config.providerLabel, + request, + initialInput, + initialToolChoice: responsesToolChoice, + forcedTools: preparedTools?.forcedTools, + createStream: (input, overrides, abortSignal) => + fetchResponsesWithSummaryFallback(createRequestBody(input, overrides), abortSignal), + logger, + timeSegments, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + + if (request.stream && !hasActiveTools) { logger.info(`Using streaming response for ${config.providerLabel} request`) const streamResponse = await fetchResponsesWithSummaryFallback( @@ -313,23 +392,12 @@ export async function executeResponsesProviderRequest( streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromResponses(streamResponse, (content, usage, thinking) => { - output.content = content - output.tokens = { - input: usage?.promptTokens || 0, - output: usage?.completionTokens || 0, - total: usage?.totalTokens || 0, - } + const accumulator = createOpenAIUsageAccumulator() + addOpenAIUsage(accumulator, usage) - const costResult = calculateCost( - request.model, - usage?.promptTokens || 0, - usage?.completionTokens || 0 - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + output.content = content + output.tokens = buildOpenAIUsageTokens(accumulator) + output.cost = buildOpenAIUsageCost(request.model, accumulator) if (thinking) { const segment = output.providerTiming?.timeSegments?.[0] @@ -377,21 +445,11 @@ export async function executeResponsesProviderRequest( ) const firstResponseTime = Date.now() - initialCallTime - const initialUsage = parseResponsesUsage(currentResponse.usage) - const tokens = { - input: initialUsage?.promptTokens || 0, - output: initialUsage?.completionTokens || 0, - total: initialUsage?.totalTokens || 0, - } + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, parseResponsesUsage(currentResponse.usage)) const toolCalls = [] const toolResults: Record[] = [] - /** - * Executed calls in completion order, for settled tool chips on the - * regenerated answer stream (the silent loop has no live stream to emit - * lifecycle events on while tools actually run). - */ - const toolLifecycle: Array<{ id: string; name: string; status: ToolCallEndStatus }> = [] let iterationCount = 0 let modelTime = firstResponseTime let toolsTime = 0 @@ -450,11 +508,24 @@ export async function executeResponsesProviderRequest( const toolName = toolCall.name try { - const toolArgs = toolCall.arguments ? JSON.parse(toolCall.arguments) : {} + const toolArgs = parseToolArguments(toolCall.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { - return null + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) @@ -473,6 +544,9 @@ export async function executeResponsesProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -492,13 +566,11 @@ export async function executeResponsesProviderRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -509,10 +581,12 @@ export async function executeResponsesProviderRequest( toolCallId: toolCall.id, }) - let resultContent: Record - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output as Record + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -531,12 +605,6 @@ export async function executeResponsesProviderRequest( success: result.success, }) - toolLifecycle.push({ - id: toolCall.id, - name: toolName, - status: result.success ? 'success' : 'error', - }) - currentInput.push({ type: 'function_call_output', call_id: toolCall.id, @@ -596,12 +664,7 @@ export async function executeResponsesProviderRequest( modelTime += thisModelTime - const usage = parseResponsesUsage(currentResponse.usage) - if (usage) { - tokens.input += usage.promptTokens - tokens.output += usage.completionTokens - tokens.total += usage.totalTokens - } + addOpenAIUsage(usage, parseResponsesUsage(currentResponse.usage)) iterationCount++ } @@ -618,225 +681,6 @@ export async function executeResponsesProviderRequest( ) } - // For Azure with deferred format: make a final call with the response format applied - // This happens whenever we have a deferred format, even if no tools were called - // (the initial call was made without the format, so we need to apply it now) - let appliedDeferredFormat = false - if (deferredTextFormat) { - logger.info( - `Applying deferred JSON schema response format for ${config.providerLabel} (iterationCount: ${iterationCount})` - ) - - const finalFormatStartTime = Date.now() - - // Determine what input to use for the formatted call - let formattedInput: ResponsesInputItem[] - - if (iterationCount > 0) { - // Tools were called - include the conversation history with tool results - const lastOutputItems = convertResponseOutputToInputItems(currentResponse.output) - if (lastOutputItems.length) { - currentInput.push(...lastOutputItems) - } - formattedInput = currentInput - } else { - // No tools were called - just retry the initial call with format applied - // Don't include the model's previous unformatted response - formattedInput = initialInput - } - - // Make final call with the response format - build payload without tools - const finalPayload: Record = { - model: config.modelName, - input: formattedInput, - text: { - ...((basePayload.text as Record) ?? {}), - format: deferredTextFormat, - }, - } - - // Copy over non-tool related settings - if (request.temperature !== undefined) finalPayload.temperature = request.temperature - if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens - if (supportsReasoningEffort(config.modelName) && basePayload.reasoning) { - finalPayload.reasoning = basePayload.reasoning - } - if (request.verbosity !== undefined && request.verbosity !== 'auto') { - finalPayload.text = { - ...((finalPayload.text as Record) ?? {}), - verbosity: request.verbosity, - } - } - - currentResponse = await postResponses(finalPayload) - - const finalFormatEndTime = Date.now() - const finalFormatDuration = finalFormatEndTime - finalFormatStartTime - - timeSegments.push({ - type: 'model', - name: 'Final formatted response', - startTime: finalFormatStartTime, - endTime: finalFormatEndTime, - duration: finalFormatDuration, - }) - - modelTime += finalFormatDuration - - const finalUsage = parseResponsesUsage(currentResponse.usage) - if (finalUsage) { - tokens.input += finalUsage.promptTokens - tokens.output += finalUsage.completionTokens - tokens.total += finalUsage.totalTokens - } - - // Update content with the formatted response - const formattedText = extractResponseText(currentResponse.output) - if (formattedText) { - content = formattedText - } - - enrichLastModelSegmentFromOpenAIResponse( - timeSegments, - currentResponse, - formattedText, - extractResponseToolCalls(currentResponse.output), - { model: request.model } - ) - - appliedDeferredFormat = true - } - - // Skip streaming if we already applied deferred format - we have the formatted content - // Making another streaming call would lose the formatted response - if (request.stream && !appliedDeferredFormat) { - logger.info('Using streaming for final response after tool processing') - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed function calls are never executed. With `tool_choice: 'auto'` - * a reasoning model can re-decide to call a tool here, ending the stream - * with a dead function_call and an empty answer. - */ - const streamOverrides: Record = { stream: true, tool_choice: 'none' } - if (deferredTextFormat) { - streamOverrides.text = { - ...((basePayload.text as Record) ?? {}), - format: deferredTextFormat, - } - } - - const streamResponse = await fetchResponsesWithSummaryFallback( - createRequestBody(currentInput, streamOverrides) - ) - - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => { - const answerStream = createReadableStreamFromResponses( - streamResponse, - (streamedContent, usage, thinking) => { - /** - * Belt-and-braces for the regeneration ending without text: keep - * the tool loop's settled answer instead of clobbering it with an - * empty string (clients then render it from the final envelope). - */ - if (!streamedContent && content) { - logger.warn( - `${config.providerLabel} final stream produced no text; keeping tool-loop answer` - ) - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + (usage?.promptTokens || 0), - output: tokens.output + (usage?.completionTokens || 0), - total: tokens.total + (usage?.totalTokens || 0), - } - - const streamCost = calculateCost( - request.model, - usage?.promptTokens || 0, - usage?.completionTokens || 0 - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - - if (thinking) { - const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - } - ) - - if (toolLifecycle.length === 0) { - return answerStream - } - - /** - * Settled tool chips ride ahead of the answer: the silent loop's - * calls already completed, so opted-in consumers get start+end pairs - * (name + status only) before the regenerated text streams. Runs - * without a sink never see these events (the byte projection ignores - * non-text), so legacy output is unchanged. - */ - const answerReader = answerStream.getReader() - return new ReadableStream({ - start(controller) { - for (const call of toolLifecycle) { - controller.enqueue({ type: 'tool_call_start', id: call.id, name: call.name }) - controller.enqueue({ - type: 'tool_call_end', - id: call.id, - name: call.name, - status: call.status, - }) - } - }, - async pull(controller) { - const { done, value } = await answerReader.read() - if (done) { - controller.close() - return - } - controller.enqueue(value) - }, - cancel(reason) { - return answerReader.cancel(reason) - }, - }) - }, - }) - - return streamingResult - } - const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -844,7 +688,13 @@ export async function executeResponsesProviderRequest( return { content, model: request.model, - tokens, + tokens: buildOpenAIUsageTokens(usage), + /** + * No tool cost here: `executeProviderRequest` re-derives it from + * `toolResults` for non-streaming responses, so folding it in would + * double-charge it. + */ + cost: buildOpenAIUsageCost(request.model, usage), toolCalls: toolCalls.length > 0 ? toolCalls : undefined, toolResults: toolResults.length > 0 ? toolResults : undefined, timing: { @@ -868,6 +718,10 @@ export async function executeResponsesProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, @@ -875,82 +729,3 @@ export async function executeResponsesProviderRequest( }) } } - -/** - * Determines a finish reason for an OpenAI Responses API response. - * Maps to conventional values: 'tool_calls' | 'length' | 'stop'. - */ -function deriveOpenAIFinishReason( - response: OpenAI.Responses.Response, - toolCalls: ResponsesToolCall[] -): string | undefined { - const incompleteReason = response.incomplete_details?.reason - if (incompleteReason === 'max_output_tokens') return 'length' - if (incompleteReason === 'content_filter') return 'content_filter' - if (toolCalls.length > 0) return 'tool_calls' - if (incompleteReason) return incompleteReason - if (response.status === 'failed') return 'error' - if (response.status === 'incomplete') return 'length' - if (response.status && response.status !== 'completed') return response.status - return 'stop' -} - -/** - * Enriches the last model segment with per-iteration content extracted from an - * OpenAI Responses API response: assistant text, tool calls, finish reason, - * and token usage for the iteration. - */ -function enrichLastModelSegmentFromOpenAIResponse( - timeSegments: TimeSegment[], - response: OpenAI.Responses.Response, - assistantText: string, - toolCallsInResponse: ResponsesToolCall[], - extras?: { - model?: string - ttft?: number - errorType?: string - errorMessage?: string - } -): void { - const toolCalls: IterationToolCall[] = toolCallsInResponse.map((tc) => ({ - id: tc.id, - name: tc.name, - arguments: - typeof tc.arguments === 'string' ? parseToolCallArguments(tc.arguments) : tc.arguments, - })) - - const usage = parseResponsesUsage(response.usage) - const thinkingContent = extractResponseReasoning(response.output) - - let cost: { input: number; output: number; total: number } | undefined - if (extras?.model && usage) { - const full = calculateCost( - extras.model, - usage.promptTokens, - usage.completionTokens, - usage.cachedTokens > 0 - ) - cost = { input: full.input, output: full.output, total: full.total } - } - - enrichLastModelSegment(timeSegments, { - assistantContent: assistantText || undefined, - thinkingContent: thinkingContent || undefined, - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - finishReason: deriveOpenAIFinishReason(response, toolCallsInResponse), - tokens: usage - ? { - input: usage.promptTokens, - output: usage.completionTokens, - total: usage.totalTokens, - ...(usage.cachedTokens > 0 && { cacheRead: usage.cachedTokens }), - ...(usage.reasoningTokens > 0 && { reasoning: usage.reasoningTokens }), - } - : undefined, - cost, - provider: 'openai', - ttft: extras?.ttft, - errorType: extras?.errorType, - errorMessage: extras?.errorMessage, - }) -} diff --git a/apps/sim/providers/openai/streaming-tool-loop.ts b/apps/sim/providers/openai/streaming-tool-loop.ts new file mode 100644 index 00000000000..7f9a75788d7 --- /dev/null +++ b/apps/sim/providers/openai/streaming-tool-loop.ts @@ -0,0 +1,579 @@ +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type OpenAI from 'openai' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { enrichLastModelSegmentFromOpenAIResponse } from '@/providers/openai/trace' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' +import { + extractResponseText, + extractResponseToolCalls, + isMaxOutputTokensIncompleteResponse, + isResponseFunctionCallEvent, + iterateResponsesStreamEvents, + parseResponsesUsage, + type ResponsesInputItem, + type ResponsesToolCall, + type ResponsesToolChoice, + responseContainsFunctionCall, +} from '@/providers/openai/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + parseToolArguments, + type StreamingToolLoopComplete, + settleOpenTools, + terminateToolLoop, +} from '@/providers/streaming-tool-loop-shared' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { executeTool } from '@/tools' + +export type CreateOpenAIResponsesStream = ( + input: ResponsesInputItem[], + overrides: Record, + abortSignal: AbortSignal +) => Promise + +type OpenAIStreamingToolLoopComplete = Omit & { + tokens: ReturnType +} + +export interface CreateOpenAIResponsesStreamingToolLoopOptions { + providerId: string + providerLabel: string + request: ProviderRequest + initialInput: ResponsesInputItem[] + initialToolChoice?: ResponsesToolChoice + forcedTools?: string[] + createStream: CreateOpenAIResponsesStream + logger: Logger + timeSegments: TimeSegment[] + onComplete: (result: OpenAIStreamingToolLoopComplete) => void +} + +interface OpenAIResponsesTurn { + response: OpenAI.Responses.Response + text: string + toolCalls: ResponsesToolCall[] +} + +interface OpenAIToolExecutionResult { + toolCall: ResponsesToolCall + toolName: string + toolParams: Record + result: { + success: boolean + output?: Record + error?: string + } + startTime: number + endTime: number + duration: number +} + +/** + * Streams one OpenAI Responses turn and returns its assembled terminal response. + */ +async function streamResponsesTurn( + response: Response, + controller: ReadableStreamDefaultController, + openTools: Map, + abortSignal?: AbortSignal +): Promise { + let terminalResponse: OpenAI.Responses.Response | undefined + let streamedText = '' + let sawFunctionCall = false + + for await (const event of iterateResponsesStreamEvents(response, abortSignal)) { + if (isResponseFunctionCallEvent(event)) { + sawFunctionCall = true + } + if (event.type === 'error') { + throw new Error(event.message || 'OpenAI Responses stream error') + } + if (event.type === 'response.failed') { + throw new Error(event.response.error?.message || 'OpenAI Responses stream failed') + } + if (event.type === 'response.incomplete') { + const reason = event.response.incomplete_details?.reason ?? 'unknown' + if ( + !isMaxOutputTokensIncompleteResponse(event.response) || + sawFunctionCall || + openTools.size > 0 || + responseContainsFunctionCall(event.response) + ) { + throw new Error(`OpenAI Responses stream incomplete: ${reason}`) + } + terminalResponse = event.response + continue + } + + if (event.type === 'response.reasoning_summary_text.delta') { + if (event.delta) { + controller.enqueue({ type: 'thinking_delta', text: event.delta }) + } + continue + } + + if (event.type === 'response.output_text.delta') { + if (event.delta) { + streamedText += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'pending' }) + } + continue + } + if (event.type === 'response.refusal.delta') { + if (event.delta) { + streamedText += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'pending' }) + } + continue + } + + if (event.type === 'response.output_item.added' && event.item.type === 'function_call') { + const id = event.item.call_id + const name = event.item.name + if (!openTools.has(id)) { + openTools.set(id, name) + controller.enqueue({ type: 'tool_call_start', id, name }) + } + continue + } + + if (event.type === 'response.completed') { + terminalResponse = event.response + } + } + + if (!terminalResponse) { + throw new Error('OpenAI Responses stream ended without a terminal response') + } + + const toolCalls = extractResponseToolCalls(terminalResponse.output) + const text = streamedText || extractResponseText(terminalResponse.output) + + return { response: terminalResponse, text, toolCalls } +} + +/** + * Finalizes one tool execution and emits its terminal lifecycle event. + */ +function completeToolExecution( + controller: ReadableStreamDefaultController, + openTools: Map, + toolCall: ResponsesToolCall, + toolParams: Record, + result: OpenAIToolExecutionResult['result'], + startTime: number, + status: ToolCallEndStatus +): OpenAIToolExecutionResult { + const endTime = Date.now() + openTools.delete(toolCall.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolCall.id, + name: toolCall.name, + status, + }) + return { + toolCall, + toolName: toolCall.name, + toolParams, + result, + startTime, + endTime, + duration: endTime - startTime, + } +} + +/** + * Executes one assembled OpenAI function call. + */ +async function executeOpenAIToolCall(options: { + toolCall: ResponsesToolCall + request: ProviderRequest + controller: ReadableStreamDefaultController + openTools: Map + logger: Logger +}): Promise { + const { toolCall, request, controller, openTools, logger } = options + const startTime = Date.now() + let toolArgs: Record + + try { + toolArgs = parseToolArguments(toolCall.arguments, toolCall.name) + } catch (error) { + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, `Invalid tool arguments for ${toolCall.name}`), + }, + startTime, + 'error' + ) + } + + const tool = request.tools?.find((candidate) => candidate.id === toolCall.name) + if (!tool) { + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: `Tool not found: ${toolCall.name}`, + }, + startTime, + 'error' + ) + } + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolCall.name, executionParams, { + signal: request.abortSignal, + }) + return completeToolExecution( + controller, + openTools, + toolCall, + toolParams, + result, + startTime, + result.success ? 'success' : 'error' + ) + } catch (error) { + if (request.abortSignal?.aborted) { + completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution cancelled'), + }, + startTime, + 'cancelled' + ) + throw error + } + if (isAbortError(error)) { + completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution aborted unexpectedly'), + }, + startTime, + 'error' + ) + throw error + } + + logger.error('Error processing OpenAI tool call:', { + error, + toolName: toolCall.name, + }) + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime, + 'error' + ) + } +} + +/** + * Multi-turn OpenAI Responses tool loop as an `agent-events-v1` object stream. + */ +export function createOpenAIResponsesStreamingToolLoopStream( + options: CreateOpenAIResponsesStreamingToolLoopOptions +): ReadableStream { + const { + providerId, + providerLabel, + request, + initialInput, + initialToolChoice, + createStream, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } + + const loopRequest: ProviderRequest = { + ...request, + abortSignal: loopAbortController.signal, + } + + return new ReadableStream({ + start(controller) { + void (async () => { + const currentInput = [...initialInput] + const usedForcedTools = new Set() + const usage = createOpenAIUsageAccumulator() + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openTools = new Map() + let currentToolChoice = initialToolChoice + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens: buildOpenAIUsageTokens(usage), + cost: buildOpenAIUsageCost(request.model, usage, toolCost), + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } + + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + settleOpenTools(controller, openTools, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const modelStart = Date.now() + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + const streamResponse = await createStream( + currentInput, + { + stream: true, + ...(finalSynthesis + ? { tools: undefined, tool_choice: 'none' } + : currentToolChoice !== undefined + ? { tool_choice: currentToolChoice } + : {}), + }, + loopAbortController.signal + ) + const turn = await streamResponsesTurn( + streamResponse, + controller, + openTools, + loopAbortController.signal + ) + const modelEnd = Date.now() + const modelDuration = modelEnd - modelStart + const turnUsage = parseResponsesUsage(turn.response.usage) + const reachedToolLimit = iterationCount >= MAX_TOOL_ITERATIONS + const toolsExecutable = turn.response.status === 'completed' && !reachedToolLimit + const executableTools = toolsExecutable ? turn.toolCalls : [] + + if (turn.toolCalls.length > 0 && !toolsExecutable) { + logger.warn('Skipping OpenAI tool execution', { + status: turn.response.status, + toolCount: turn.toolCalls.length, + reachedToolLimit, + }) + settleOpenTools(controller, openTools, 'error') + } + + const executableToolIds = new Set(executableTools.map((toolCall) => toolCall.id)) + for (const [id, name] of openTools) { + if (!executableToolIds.has(id)) { + openTools.delete(id) + controller.enqueue({ type: 'tool_call_end', id, name, status: 'error' }) + } + } + + for (const toolCall of executableTools) { + if (!openTools.has(toolCall.id)) { + openTools.set(toolCall.id, toolCall.name) + controller.enqueue({ + type: 'tool_call_start', + id: toolCall.id, + name: toolCall.name, + }) + } + } + + const turnKind = executableTools.length > 0 ? 'intermediate' : 'final' + content = turn.text + controller.enqueue({ type: 'turn_end', turn: turnKind }) + + modelTime += modelDuration + modelCalls++ + if (modelCalls === 1) { + firstResponseTime = modelDuration + } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: modelDuration, + }) + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + turn.response, + turn.text, + turn.toolCalls, + { model: request.model } + ) + + addOpenAIUsage(usage, turnUsage) + + if (executableTools.length === 0) { + break + } + + currentInput.push(...turn.response.output) + + if (typeof currentToolChoice === 'object') { + for (const toolCall of executableTools) { + if (forcedTools.includes(toolCall.name)) { + usedForcedTools.add(toolCall.name) + } + } + } + + const toolsStart = Date.now() + const orderedResults = await Promise.all( + executableTools.map((toolCall) => + executeOpenAIToolCall({ + toolCall, + request: loopRequest, + controller, + openTools, + logger, + }) + ) + ) + + for (const result of orderedResults) { + timeSegments.push({ + type: 'tool', + name: result.toolName, + startTime: result.startTime, + endTime: result.endTime, + duration: result.duration, + toolCallId: result.toolCall.id, + }) + + const resultContent = result.result.success + ? (result.result.output ?? null) + : { + error: true, + message: result.result.error || 'Tool execution failed', + tool: result.toolName, + } + + if (result.result.success && isRecordLike(result.result.output)) { + toolResults.push(result.result.output) + } + + toolCalls.push({ + name: result.toolName, + arguments: result.toolParams, + startTime: new Date(result.startTime).toISOString(), + endTime: new Date(result.endTime).toISOString(), + duration: result.duration, + result: resultContent, + success: result.result.success, + }) + + currentInput.push({ + type: 'function_call_output', + call_id: result.toolCall.id, + output: JSON.stringify(resultContent), + }) + } + + toolsTime += Date.now() - toolsStart + + if (typeof currentToolChoice === 'object') { + const remaining = forcedTools.filter((toolName) => !usedForcedTools.has(toolName)) + currentToolChoice = + remaining.length > 0 ? { type: 'function', name: remaining[0] } : 'auto' + if (remaining.length === 0) { + logger.info('All forced tools have been used, switching to auto tool_choice') + } else { + logger.info(`Forcing next tool: ${remaining[0]}`) + } + } + + iterationCount++ + } + + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error(`Error in ${providerLabel} streaming tool loop`, { + providerId, + error: cause, + }), + }) + } finally { + request.abortSignal?.removeEventListener('abort', abortFromRequest) + } + })().catch((error) => { + // `start` cannot be async (the loop must not block the first pull), so + // a throw escaping the IIFE would surface as an unhandled rejection. + logger.error(`Unhandled failure in ${providerLabel} streaming tool loop`, { + providerId, + error: toError(error).message, + }) + }) + }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, + }) +} diff --git a/apps/sim/providers/openai/trace.ts b/apps/sim/providers/openai/trace.ts new file mode 100644 index 00000000000..f0147d461d8 --- /dev/null +++ b/apps/sim/providers/openai/trace.ts @@ -0,0 +1,82 @@ +import type OpenAI from 'openai' +import type { IterationToolCall } from '@/executor/types' +import { LIST_PRICE_POLICY, priceModelUsage } from '@/providers/cost-policy' +import { + extractResponseReasoning, + parseResponsesUsage, + type ResponsesToolCall, + toOpenAIModelUsage, +} from '@/providers/openai/utils' +import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' +import type { TimeSegment } from '@/providers/types' + +/** + * Maps a Responses API terminal response to Sim's conventional finish reason. + */ +function deriveOpenAIFinishReason( + response: OpenAI.Responses.Response, + toolCalls: ResponsesToolCall[] +): string | undefined { + const incompleteReason = response.incomplete_details?.reason + if (incompleteReason === 'max_output_tokens') return 'length' + if (incompleteReason === 'content_filter') return 'content_filter' + if (toolCalls.length > 0) return 'tool_calls' + if (incompleteReason) return incompleteReason + if (response.status === 'failed') return 'error' + if (response.status === 'incomplete') return 'length' + if (response.status && response.status !== 'completed') return response.status + return 'stop' +} + +/** + * Enriches the latest model segment from a terminal Responses API response. + */ +export function enrichLastModelSegmentFromOpenAIResponse( + timeSegments: TimeSegment[], + response: OpenAI.Responses.Response, + assistantText: string, + toolCallsInResponse: ResponsesToolCall[], + extras?: { + model?: string + ttft?: number + errorType?: string + errorMessage?: string + } +): void { + const toolCalls: IterationToolCall[] = toolCallsInResponse.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + arguments: parseToolCallArguments(toolCall.arguments), + })) + + const usage = parseResponsesUsage(response.usage) + const thinkingContent = extractResponseReasoning(response.output) + + let cost: { input: number; output: number; total: number } | undefined + if (extras?.model && usage) { + const full = priceModelUsage(extras.model, toOpenAIModelUsage(usage), LIST_PRICE_POLICY) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: assistantText || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: deriveOpenAIFinishReason(response, toolCallsInResponse), + tokens: usage + ? { + input: usage.promptTokens, + output: usage.completionTokens, + total: usage.totalTokens, + ...(usage.cachedTokens > 0 && { cacheRead: usage.cachedTokens }), + ...(usage.cacheWriteTokens > 0 && { cacheWrite: usage.cacheWriteTokens }), + ...(usage.reasoningTokens > 0 && { reasoning: usage.reasoningTokens }), + } + : undefined, + cost, + provider: 'openai', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} diff --git a/apps/sim/providers/openai/usage.test.ts b/apps/sim/providers/openai/usage.test.ts new file mode 100644 index 00000000000..6d546a7db5f --- /dev/null +++ b/apps/sim/providers/openai/usage.test.ts @@ -0,0 +1,201 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' +import type { ResponsesUsageTokens } from '@/providers/openai/utils' +import { calculateCost } from '@/providers/utils' + +/** input $2.50/M, cachedInput $1.25/M, output $10.00/M. */ +const MODEL = 'gpt-4o' +/** input $2.50/M, cachedInput $0.25/M, output $15.00/M — bills cache writes. */ +const CACHE_WRITE_MODEL = 'gpt-5.6-terra' + +/** + * Builds a Responses usage payload. `promptTokens` is inclusive of cached and + * written tokens, matching what {@link parseResponsesUsage} emits. + */ +function responsesUsage(partial: Partial): ResponsesUsageTokens { + const promptTokens = partial.promptTokens ?? 0 + const completionTokens = partial.completionTokens ?? 0 + return { + promptTokens, + completionTokens, + totalTokens: partial.totalTokens ?? promptTokens + completionTokens, + cachedTokens: partial.cachedTokens ?? 0, + cacheWriteTokens: partial.cacheWriteTokens ?? 0, + reasoningTokens: partial.reasoningTokens ?? 0, + } +} + +describe('OpenAI usage aggregation', () => { + it('matches plain list pricing when nothing was cached', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 12_345, completionTokens: 6_789 })) + + const uncached = calculateCost(MODEL, 12_345, 6_789) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 12_345, + output: 6_789, + total: 19_134, + cacheRead: 0, + cacheWrite: 0, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: uncached.input, + output: uncached.output, + total: uncached.total, + }) + }) + + it('bills cached tokens at the cached rate instead of the full input rate', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 1_000_000, cachedTokens: 600_000, completionTokens: 0 }) + ) + + /** 400k uncached at $2.50/M plus 600k cached at $1.25/M. */ + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: 1.75, + output: 0, + total: 1.75, + }) + expect(calculateCost(MODEL, 1_000_000, 0).input).toBe(2.5) + }) + + it('reports cache reads separately while keeping the prompt total intact', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 1_000, cachedTokens: 800, completionTokens: 100 }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 200, + output: 100, + total: 1_100, + cacheRead: 800, + cacheWrite: 0, + }) + }) + + it('bills GPT-5.6 cache writes at 1.25x the uncached input rate', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + completionTokens: 0, + }) + ) + + /** 1M written at $2.50/M x 1.25. */ + expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ + input: 3.125, + output: 0, + total: 3.125, + }) + }) + + it('aggregates uncached, cached, written, and output tokens in one turn', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000_000, + cachedTokens: 600_000, + cacheWriteTokens: 200_000, + completionTokens: 100_000, + }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 200_000, + output: 100_000, + total: 1_100_000, + cacheRead: 600_000, + cacheWrite: 200_000, + }) + /** 0.5 uncached + 0.15 cached + 0.625 written input, 1.5 output. */ + expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ + input: 1.275, + output: 1.5, + total: 2.775, + }) + }) + + it('accumulates each tool-loop turn exactly once', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000, completionTokens: 100 })) + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 2_000, cachedTokens: 1_500, completionTokens: 200 }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 1_500, + output: 300, + total: 3_300, + cacheRead: 1_500, + cacheWrite: 0, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: 0.005625, + output: 0.003, + total: 0.008625, + }) + }) + + it('ignores turns that reported no usage', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000, completionTokens: 100 })) + addOpenAIUsage(usage, undefined) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 1_000, + output: 100, + total: 1_100, + cacheRead: 0, + cacheWrite: 0, + }) + }) + + it('adds tool cost to the total and only reports the field when charged', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000_000, completionTokens: 0 })) + + expect(buildOpenAIUsageCost(MODEL, usage, 0.25)).toMatchObject({ + input: 2.5, + total: 2.75, + toolCost: 0.25, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).not.toHaveProperty('toolCost') + }) + + it('does not charge for cache tokens a vendor payload over-reported', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000, + cachedTokens: 900, + cacheWriteTokens: 400, + completionTokens: 0, + }) + ) + + expect(buildOpenAIUsageTokens(usage)).toMatchObject({ + input: 0, + cacheRead: 900, + cacheWrite: 100, + }) + }) +}) diff --git a/apps/sim/providers/openai/usage.ts b/apps/sim/providers/openai/usage.ts new file mode 100644 index 00000000000..3d9cdb362e7 --- /dev/null +++ b/apps/sim/providers/openai/usage.ts @@ -0,0 +1,128 @@ +import type { BlockTokens } from '@/executor/types' +import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy' +import { + OPENAI_CACHE_WRITE_MULTIPLIER, + type ResponsesUsageTokens, + splitOpenAIUsage, +} from '@/providers/openai/utils' +import type { ModelPricing } from '@/providers/types' + +export interface OpenAIUsageAccumulator { + /** + * Tokens billed at the base input rate. EXCLUDES cache reads and writes, + * which OpenAI reports as subsets of `input_tokens` and which are billed at + * their own rates. + */ + input: number + output: number + /** Every token the request consumed, cache reads and writes included. */ + total: number + cacheRead: number + cacheWrite: number +} + +interface OpenAIUsageCost { + input: number + output: number + total: number + toolCost?: number + pricing: ModelPricing +} + +function roundedCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Creates an empty accumulator for one OpenAI provider request. + */ +export function createOpenAIUsageAccumulator(): OpenAIUsageAccumulator { + return { + input: 0, + output: 0, + total: 0, + cacheRead: 0, + cacheWrite: 0, + } +} + +/** + * Adds one Responses API turn's usage without counting cache tokens as + * uncached input. + * + * Normalization goes through {@link splitOpenAIUsage} so that subtracting the + * cache buckets out of the prompt total — and clamping a vendor payload that + * reports more cache tokens than it processed — stays in one place. + */ +export function addOpenAIUsage( + accumulator: OpenAIUsageAccumulator, + usage: ResponsesUsageTokens | undefined +): void { + if (!usage) return + + const split = splitOpenAIUsage(usage) + + accumulator.input += split.input + accumulator.output += split.output + accumulator.cacheRead += split.cacheRead + accumulator.cacheWrite += split.cacheWrite + accumulator.total += usage.totalTokens +} + +/** + * Builds the block token shape. `total` is OpenAI's own reported total, which + * already counts cache reads and writes alongside the uncached remainder. + */ +export function buildOpenAIUsageTokens( + accumulator: OpenAIUsageAccumulator +): Required> { + return { + input: accumulator.input, + output: accumulator.output, + total: accumulator.total, + cacheRead: accumulator.cacheRead, + cacheWrite: accumulator.cacheWrite, + } +} + +/** + * Builds the normalized usage for one OpenAI request. + * + * `input` is already the uncached remainder because {@link addOpenAIUsage} + * subtracted the cache buckets per turn — unlike Anthropic, whose + * `input_tokens` arrives exclusive of them. + */ +export function buildOpenAIModelUsage(accumulator: OpenAIUsageAccumulator): ModelUsage { + return { + input: accumulator.input, + output: accumulator.output, + cacheRead: accumulator.cacheRead, + cacheWrites: [ + { tokens: accumulator.cacheWrite, inputRateMultiplier: OPENAI_CACHE_WRITE_MULTIPLIER }, + ], + } +} + +/** + * Prices one OpenAI request, cache reads and writes included, through the + * shared pricing function. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function buildOpenAIUsageCost( + model: string, + accumulator: OpenAIUsageAccumulator, + toolCost = 0 +): OpenAIUsageCost { + const cost = priceModelUsage(model, buildOpenAIModelUsage(accumulator), LIST_PRICE_POLICY) + + return { + input: cost.input, + output: cost.output, + total: roundedCost(cost.total + toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: cost.pricing, + } +} diff --git a/apps/sim/providers/openai/utils.stream.test.ts b/apps/sim/providers/openai/utils.stream.test.ts index ed8b74899a8..bed841d345a 100644 --- a/apps/sim/providers/openai/utils.stream.test.ts +++ b/apps/sim/providers/openai/utils.stream.test.ts @@ -68,8 +68,140 @@ describe('createReadableStreamFromResponses', () => { event: 'response.output_text.delta', data: { type: 'response.output_text.delta', delta: 'Hi' }, }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + }, ]) const events = await collectEvents(createReadableStreamFromResponses(response)) expect(events).toEqual([{ type: 'text_delta', text: 'Hi', turn: 'final' }]) }) + + it('streams refusal text as the model answer', async () => { + const response = sseResponse([ + { + event: 'response.refusal.delta', + data: { type: 'response.refusal.delta', delta: "I can't help with that." }, + }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 1, output_tokens: 5 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response)) + expect(events).toEqual([{ type: 'text_delta', text: "I can't help with that.", turn: 'final' }]) + }) + + it('finalizes truncated text when max_output_tokens is the only incomplete condition', async () => { + const onComplete = vi.fn() + const response = sseResponse([ + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Truncated answer' }, + }, + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response, onComplete)) + + expect(events).toEqual([{ type: 'text_delta', text: 'Truncated answer', turn: 'final' }]) + expect(onComplete).toHaveBeenCalledWith( + 'Truncated answer', + { + promptTokens: 3, + completionTokens: 5, + totalTokens: 8, + cachedTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }, + undefined + ) + }) + + it('rejects a max_output_tokens response with a partial function call', async () => { + const response = sseResponse([ + { + event: 'response.output_item.added', + data: { + type: 'response.output_item.added', + item: { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'lookup', + arguments: '', + status: 'in_progress', + }, + }, + }, + { + event: 'response.function_call_arguments.delta', + data: { + type: 'response.function_call_arguments.delta', + item_id: 'fc_partial', + output_index: 0, + delta: '{"query":', + }, + }, + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8 }, + }, + }, + }, + ]) + + await expect(collectEvents(createReadableStreamFromResponses(response))).rejects.toThrow( + 'OpenAI Responses stream incomplete: max_output_tokens' + ) + }) + + it('continues rejecting non-token-cap incomplete responses', async () => { + const response = sseResponse([ + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'content_filter' }, + output: [], + }, + }, + }, + ]) + + await expect(collectEvents(createReadableStreamFromResponses(response))).rejects.toThrow( + 'OpenAI Responses stream incomplete: content_filter' + ) + }) }) diff --git a/apps/sim/providers/openai/utils.test.ts b/apps/sim/providers/openai/utils.test.ts index ba943f3f4aa..e8c97793b32 100644 --- a/apps/sim/providers/openai/utils.test.ts +++ b/apps/sim/providers/openai/utils.test.ts @@ -1,8 +1,82 @@ /** * @vitest-environment node */ +import type OpenAI from 'openai' import { describe, expect, it } from 'vitest' -import { buildResponsesInputFromMessages } from '@/providers/openai/utils' +import { + buildResponsesInputFromMessages, + parseResponsesUsage, + toOpenAIModelUsage, +} from '@/providers/openai/utils' + +describe('parseResponsesUsage', () => { + it('reads cache writes, which GPT-5.6+ bills at a premium', () => { + const usage = parseResponsesUsage({ + input_tokens: 1000, + output_tokens: 100, + total_tokens: 1100, + input_tokens_details: { cached_tokens: 600, cache_write_tokens: 200 }, + output_tokens_details: { reasoning_tokens: 0 }, + } as OpenAI.Responses.ResponseUsage) + + expect(usage).toMatchObject({ promptTokens: 1000, cachedTokens: 600, cacheWriteTokens: 200 }) + }) + + it('reports zero cache writes on model families that do not charge for them', () => { + const usage = parseResponsesUsage({ + input_tokens: 1000, + output_tokens: 100, + total_tokens: 1100, + input_tokens_details: { cached_tokens: 600 }, + output_tokens_details: { reasoning_tokens: 0 }, + } as OpenAI.Responses.ResponseUsage) + + expect(usage?.cacheWriteTokens).toBe(0) + }) +}) + +describe('toOpenAIModelUsage', () => { + /** + * OpenAI reports cached and written tokens as subsets of `input_tokens`; + * double-counting them would over-bill every cached request. + */ + it('subtracts cache buckets out of the prompt total', () => { + const usage = toOpenAIModelUsage({ + promptTokens: 1000, + completionTokens: 100, + totalTokens: 1100, + cachedTokens: 600, + cacheWriteTokens: 200, + reasoningTokens: 0, + }) + + expect(usage).toEqual({ + input: 200, + output: 100, + cacheRead: 600, + cacheWrites: [{ tokens: 200, inputRateMultiplier: 1.25 }], + }) + }) + + /** + * OpenAI has shipped payloads where reads plus writes exceeded the prompt + * total. Left unclamped that bills more input than the request contained. + */ + it('never bills more cache tokens than the request reported', () => { + const usage = toOpenAIModelUsage({ + promptTokens: 4583, + completionTokens: 15, + totalTokens: 4598, + cachedTokens: 3945, + cacheWriteTokens: 4580, + reasoningTokens: 0, + }) + + expect(usage.input).toBe(0) + expect(usage.cacheRead).toBe(3945) + expect(usage.cacheWrites?.[0].tokens).toBe(4583 - 3945) + }) +}) describe('buildResponsesInputFromMessages', () => { it('should convert user message files to Responses multipart content', () => { diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index 2aaf3cd0ad9..fa6e295f071 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -1,41 +1,141 @@ -import { createLogger } from '@sim/logger' import type OpenAI from 'openai' +import { Stream } from 'openai/streaming' import { buildOpenAIMessageContent } from '@/providers/attachments' +import type { ModelUsage } from '@/providers/cost-policy' import type { AgentStreamEvent } from '@/providers/stream-events' import type { Message } from '@/providers/types' -const logger = createLogger('ResponsesUtils') - export interface ResponsesUsageTokens { + /** Total input tokens. INCLUDES {@link cachedTokens}, per the OpenAI usage schema. */ promptTokens: number completionTokens: number totalTokens: number + /** Input tokens served from cache — a subset of {@link promptTokens}. */ cachedTokens: number + /** Input tokens written to cache. Billed at 1.25x on GPT-5.6+, free before it. */ + cacheWriteTokens: number reasoningTokens: number } +/** GPT-5.6 and later bill cache writes at 1.25x the uncached input rate. */ +export const OPENAI_CACHE_WRITE_MULTIPLIER = 1.25 + +/** + * One OpenAI response's tokens split into the buckets that price differently. + * `input` is the uncached remainder, so `input + cacheRead + cacheWrite` is the + * prompt total OpenAI reported. + */ +export interface OpenAITokenSplit { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +/** + * Splits a cache-inclusive OpenAI prompt total into its billing buckets. + * + * `cached_tokens` and `cache_write_tokens` are subsets of `input_tokens`, so the + * uncached remainder is the subtraction — the opposite of Anthropic, whose + * `input_tokens` already excludes cache tokens. + * + * Both buckets are clamped to what the request actually contained. OpenAI has + * shipped payloads where reads plus writes summed past the prompt total, so + * without this a vendor reporting bug becomes an overcharge. + */ +export function splitOpenAIUsage(usage: ResponsesUsageTokens): OpenAITokenSplit { + const promptTokens = Math.max(0, usage.promptTokens) + const cacheRead = Math.min(Math.max(0, usage.cachedTokens), promptTokens) + const cacheWrite = Math.min(Math.max(0, usage.cacheWriteTokens), promptTokens - cacheRead) + + return { + input: promptTokens - cacheRead - cacheWrite, + output: usage.completionTokens, + cacheRead, + cacheWrite, + } +} + +/** Adapts a {@link splitOpenAIUsage} result to the shared pricing shape. */ +export function toOpenAIModelUsage(usage: ResponsesUsageTokens): ModelUsage { + const { input, output, cacheRead, cacheWrite } = splitOpenAIUsage(usage) + + return { + input, + output, + cacheRead, + cacheWrites: [{ tokens: cacheWrite, inputRateMultiplier: OPENAI_CACHE_WRITE_MULTIPLIER }], + } +} + export interface ResponsesToolCall { id: string name: string arguments: string } -export type ResponsesInputItem = - | { - role: 'system' | 'user' | 'assistant' - content: string | OpenAI.Responses.ResponseInputContent[] - } - | { - type: 'function_call' - call_id: string - name: string - arguments: string +export type ResponsesStreamEvent = OpenAI.Responses.ResponseStreamEvent + +export type ResponsesInputItem = OpenAI.Responses.ResponseInputItem + +/** + * Identifies the one incomplete Responses status that still contains a valid + * truncated answer: the configured output-token cap was reached. + */ +export function isMaxOutputTokensIncompleteResponse(response: OpenAI.Responses.Response): boolean { + return ( + response.status === 'incomplete' && response.incomplete_details?.reason === 'max_output_tokens' + ) +} + +/** + * Checks the terminal Responses output for a function call, including one + * whose arguments or status remain incomplete. + */ +export function responseContainsFunctionCall(response: OpenAI.Responses.Response): boolean { + return response.output.some((item) => item.type === 'function_call') +} + +/** + * Detects documented Responses stream events that prove function-call + * generation started, even when the terminal output omits the partial item. + */ +export function isResponseFunctionCallEvent(event: ResponsesStreamEvent): boolean { + return ( + (event.type === 'response.output_item.added' && event.item.type === 'function_call') || + event.type === 'response.function_call_arguments.delta' || + event.type === 'response.function_call_arguments.done' + ) +} + +/** + * Parses a Responses API SSE body with the official OpenAI stream decoder. + */ +export async function* iterateResponsesStreamEvents( + response: Response, + abortSignal?: AbortSignal +): AsyncGenerator { + const parserController = new AbortController() + const abortParser = () => parserController.abort(abortSignal?.reason) + + if (abortSignal?.aborted) { + abortParser() + } else { + abortSignal?.addEventListener('abort', abortParser, { once: true }) + } + + try { + const stream = Stream.fromSSEResponse(response, parserController) + for await (const event of stream) { + yield event } - | { - type: 'function_call_output' - call_id: string - output: string + } finally { + abortSignal?.removeEventListener('abort', abortParser) + if (!parserController.signal.aborted) { + parserController.abort() } + } +} export interface ResponsesToolDefinition { type: 'function' @@ -44,6 +144,8 @@ export interface ResponsesToolDefinition { parameters?: Record } +export type ResponsesToolChoice = 'auto' | 'none' | { type: 'function'; name: string } + /** * Converts chat-style messages into Responses API input items. */ @@ -136,7 +238,7 @@ export function toResponsesToolChoice( | { type: 'tool'; name: string } | { type: 'any'; any: { model: string; name: string } } | undefined -): 'auto' | 'none' | { type: 'function'; name: string } | undefined { +): ResponsesToolChoice | undefined { if (!toolChoice) { return undefined } @@ -176,17 +278,10 @@ function extractTextFromMessageItem(item: unknown): string { continue } - if ((part.type === 'output_text' || part.type === 'text') && typeof part.text === 'string') { + if (part.type === 'output_text' && typeof part.text === 'string') { textParts.push(part.text) - continue - } - - if (part.type === 'output_json') { - if (typeof part.text === 'string') { - textParts.push(part.text) - } else if (part.json !== undefined) { - textParts.push(JSON.stringify(part.json)) - } + } else if (part.type === 'refusal' && typeof part.refusal === 'string') { + textParts.push(part.refusal) } } @@ -228,13 +323,8 @@ export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutput const parts: string[] = [] for (const item of output) { if (!item || item.type !== 'reasoning') continue - // double-cast-allowed: the Responses SDK types summary entries as non-null, but the wire can carry null entries/text on partial reasoning items; widen to read defensively - const summary = (item as unknown as { summary?: Array<{ text?: string | null } | null> }) - .summary - if (!Array.isArray(summary)) continue - for (const entry of summary) { - const text = entry?.text - if (typeof text === 'string' && text.length > 0) parts.push(text) + for (const entry of item.summary) { + if (entry.text.length > 0) parts.push(entry.text) } } return parts.join('\n\n') @@ -246,75 +336,7 @@ export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutput export function convertResponseOutputToInputItems( output: OpenAI.Responses.ResponseOutputItem[] ): ResponsesInputItem[] { - if (!Array.isArray(output)) { - return [] - } - - const items: ResponsesInputItem[] = [] - for (const item of output) { - if (!isRecord(item)) { - continue - } - - if (item.type === 'message') { - const text = extractTextFromMessageItem(item) - if (text) { - items.push({ - role: 'assistant', - content: text, - }) - } - - // Handle Chat Completions-style tool_calls nested under message items - const toolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : [] - for (const toolCall of toolCalls) { - const tc = toolCall as Record - const fn = tc.function as Record | undefined - const callId = tc.id as string | undefined - const name = (fn?.name ?? tc.name) as string | undefined - if (!callId || !name) { - continue - } - - const argumentsValue = - typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {}) - - items.push({ - type: 'function_call', - call_id: callId, - name, - arguments: argumentsValue, - }) - } - - continue - } - - if (item.type === 'function_call') { - const fc = item as OpenAI.Responses.ResponseFunctionToolCall - const callId = fc.call_id ?? (typeof item.id === 'string' ? item.id : undefined) - const name = - fc.name ?? - (isRecord(item.function) && typeof item.function.name === 'string' - ? item.function.name - : undefined) - if (!callId || !name) { - continue - } - - const argumentsValue = - typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {}) - - items.push({ - type: 'function_call', - call_id: callId, - name, - arguments: argumentsValue, - }) - } - } - - return items + return Array.isArray(output) ? output : [] } /** @@ -336,13 +358,7 @@ export function extractResponseToolCalls( if (item.type === 'function_call') { const fc = item as OpenAI.Responses.ResponseFunctionToolCall - const callId = fc.call_id ?? (typeof item.id === 'string' ? item.id : undefined) - const name = - fc.name ?? - (isRecord(item.function) && typeof item.function.name === 'string' - ? item.function.name - : undefined) - if (!callId || !name) { + if (!fc.call_id || !fc.name) { continue } @@ -350,33 +366,10 @@ export function extractResponseToolCalls( typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {}) toolCalls.push({ - id: callId, - name, + id: fc.call_id, + name: fc.name, arguments: argumentsValue, }) - continue - } - - // Handle Chat Completions-style tool_calls nested under message items - if (item.type === 'message' && Array.isArray(item.tool_calls)) { - for (const toolCall of item.tool_calls) { - const tc = toolCall as Record - const fn = tc.function as Record | undefined - const callId = tc.id as string | undefined - const name = (fn?.name ?? tc.name) as string | undefined - if (!callId || !name) { - continue - } - - const argumentsValue = - typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {}) - - toolCalls.push({ - id: callId, - name, - arguments: argumentsValue, - }) - } } } @@ -398,7 +391,12 @@ export function parseResponsesUsage( const inputTokens = usage.input_tokens ?? 0 const outputTokens = usage.output_tokens ?? 0 - const cachedTokens = usage.input_tokens_details?.cached_tokens ?? 0 + const details = usage.input_tokens_details as + | { cached_tokens?: number | null; cache_write_tokens?: number | null } + | undefined + const cachedTokens = details?.cached_tokens ?? 0 + // Added for GPT-5.6; absent (and free) on earlier model families. + const cacheWriteTokens = details?.cache_write_tokens ?? 0 const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0 const completionTokens = Math.max(outputTokens, reasoningTokens) const totalTokens = inputTokens + completionTokens @@ -408,6 +406,7 @@ export function parseResponsesUsage( completionTokens, totalTokens, cachedTokens, + cacheWriteTokens, reasoningTokens, } } @@ -424,141 +423,86 @@ export function createReadableStreamFromResponses( response: Response, onComplete?: (content: string, usage?: ResponsesUsageTokens, thinking?: string) => void ): ReadableStream { - let fullContent = '' - let fullThinking = '' - let finalUsage: ResponsesUsageTokens | undefined - let activeEventType: string | undefined + const streamAbortController = new AbortController() return new ReadableStream({ - async start(controller) { - const reader = response.body?.getReader() - if (!reader) { - controller.close() - return - } - - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { - continue + start(controller) { + void (async () => { + let fullContent = '' + let fullThinking = '' + let finalUsage: ResponsesUsageTokens | undefined + let completed = false + let sawFunctionCall = false + + try { + for await (const event of iterateResponsesStreamEvents( + response, + streamAbortController.signal + )) { + if (isResponseFunctionCallEvent(event)) { + sawFunctionCall = true } - - if (trimmed.startsWith('event:')) { - activeEventType = trimmed.slice(6).trim() - continue + if (event.type === 'error') { + throw new Error(event.message || 'OpenAI Responses stream error') } - - if (!trimmed.startsWith('data:')) { - continue + if (event.type === 'response.failed') { + throw new Error(event.response.error?.message || 'OpenAI Responses stream failed') } - - const data = trimmed.slice(5).trim() - if (data === '[DONE]') { + if (event.type === 'response.incomplete') { + const reason = event.response.incomplete_details?.reason ?? 'unknown' + if ( + !isMaxOutputTokensIncompleteResponse(event.response) || + sawFunctionCall || + responseContainsFunctionCall(event.response) + ) { + throw new Error(`OpenAI Responses stream incomplete: ${reason}`) + } + finalUsage = parseResponsesUsage(event.response.usage) + completed = true continue } - - let event: Record - try { - event = JSON.parse(data) - } catch (error) { - logger.debug('Skipping non-JSON response stream chunk', { - data: data.slice(0, 200), - error, - }) + if (event.type === 'response.reasoning_summary_text.delta') { + if (event.delta) { + fullThinking += event.delta + controller.enqueue({ type: 'thinking_delta', text: event.delta }) + } continue } - - const eventType = event?.type ?? activeEventType - - if ( - eventType === 'response.error' || - eventType === 'error' || - eventType === 'response.failed' - ) { - const errorObj = event.error as Record | undefined - const message = (errorObj?.message as string) || 'Responses API stream error' - controller.error(new Error(message)) - return - } - - // Reasoning *summaries* only (`response.reasoning_summary_text.delta` - // per the Responses streaming reference) — never raw reasoning - // (`response.reasoning_text.delta`) and never encrypted_content. - if (eventType === 'response.reasoning_summary_text.delta') { - let deltaText = '' - const delta = event.delta as string | Record | undefined - if (typeof delta === 'string') { - deltaText = delta - } else if (delta && typeof delta.text === 'string') { - deltaText = delta.text - } else if (typeof event.text === 'string') { - deltaText = event.text - } - if (deltaText.length > 0) { - fullThinking += deltaText - controller.enqueue({ type: 'thinking_delta', text: deltaText }) + if (event.type === 'response.output_text.delta') { + if (event.delta) { + fullContent += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'final' }) } continue } - - if ( - eventType === 'response.output_text.delta' || - eventType === 'response.output_json.delta' - ) { - let deltaText = '' - const delta = event.delta as string | Record | undefined - if (typeof delta === 'string') { - deltaText = delta - } else if (delta && typeof delta.text === 'string') { - deltaText = delta.text - } else if (delta && delta.json !== undefined) { - deltaText = JSON.stringify(delta.json) - } else if (event.json !== undefined) { - deltaText = JSON.stringify(event.json) - } else if (typeof event.text === 'string') { - deltaText = event.text - } - - if (deltaText.length > 0) { - fullContent += deltaText - controller.enqueue({ type: 'text_delta', text: deltaText, turn: 'final' }) + if (event.type === 'response.refusal.delta') { + if (event.delta) { + fullContent += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'final' }) } + continue } - - if (eventType === 'response.completed') { - const responseObj = event.response as Record | undefined - const usageData = (responseObj?.usage ?? event.usage) as - | OpenAI.Responses.ResponseUsage - | undefined - finalUsage = parseResponsesUsage(usageData) + if (event.type === 'response.completed') { + finalUsage = parseResponsesUsage(event.response.usage) + completed = true } } - } - if (onComplete) { - onComplete(fullContent, finalUsage, fullThinking || undefined) - } + if (!completed) { + throw new Error('OpenAI Responses stream ended without a completed response') + } - controller.close() - } catch (error) { - controller.error(error) - } finally { - reader.releaseLock() - } + onComplete?.(fullContent, finalUsage, fullThinking || undefined) + controller.close() + } catch (error) { + if (!streamAbortController.signal.aborted) { + controller.error(error) + } + } + })() + }, + cancel(reason) { + streamAbortController.abort(reason) }, }) } diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index 8c26f611b8c..c611a5ee7f8 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -70,7 +70,9 @@ vi.mock('@/providers/utils', () => ({ generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), })) +import type { StreamingExecution } from '@/executor/types' import { openRouterProvider } from '@/providers/openrouter/index' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface Usage { @@ -89,12 +91,25 @@ function textResponse( } } -function toolCallResponse(name: string, args: Record, id = 'call_1') { +function toolCallResponse( + name: string, + args: Record, + id = 'call_1', + assistant: { + content?: string | null + reasoning?: string + reasoning_details?: OpenRouterReasoningDetail[] + } = {} +) { return { choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning !== undefined ? { reasoning: assistant.reasoning } : {}), + ...(assistant.reasoning_details !== undefined + ? { reasoning_details: assistant.reasoning_details } + : {}), tool_calls: [ { id, type: 'function', function: { name, arguments: JSON.stringify(args) } }, ], @@ -129,6 +144,9 @@ describe('openRouterProvider.executeRequest', () => { mockCreate.mockReset() mockExecuteTool.mockReset() mockSupportsNative.mockResolvedValue(false) + mockCreateStream.mockReturnValue( + new ReadableStream({ start: (controller) => controller.close() }) + ) }) it('requires an API key', async () => { @@ -233,6 +251,85 @@ describe('openRouterProvider.executeRequest', () => { }) }) + it('replays OpenRouter reasoning_details unchanged with assistant content', async () => { + const reasoningDetails: OpenRouterReasoningDetail[] = [ + { + type: 'reasoning.summary', + summary: 'Need current weather.', + id: 'reasoning-1', + format: 'anthropic-claude-v1', + index: 0, + }, + { + type: 'reasoning.encrypted', + data: 'opaque-data', + id: 'reasoning-2', + format: 'anthropic-claude-v1', + index: 1, + }, + { + type: 'reasoning.text', + text: 'Call the weather tool.', + signature: null, + id: 'reasoning-3', + format: 'anthropic-claude-v1', + index: 2, + }, + ] + mockCreate + .mockResolvedValueOnce( + toolCallResponse('get_weather', { city: 'SF' }, 'call_1', { + content: 'I will check.', + reasoning_details: reasoningDetails, + }) + ) + .mockResolvedValueOnce(textResponse('done')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will check.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"SF"}' }, + }, + ], + reasoning_details: reasoningDetails, + }) + }) + + it('does not add OpenRouter reasoning fields when the provider omitted them', async () => { + mockCreate + .mockResolvedValueOnce( + toolCallResponse('get_weather', { city: 'SF' }, 'call_1', { + content: 'I will check.', + }) + ) + .mockResolvedValueOnce(textResponse('done')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).not.toHaveProperty('reasoning') + expect(assistant).not.toHaveProperty('reasoning_details') + }) + it('applies native structured outputs (json_schema + require_parameters) when no tools are active', async () => { mockSupportsNative.mockResolvedValue(true) mockCreate.mockResolvedValueOnce(textResponse('{"x":1}')) @@ -329,8 +426,40 @@ describe('openRouterProvider.executeRequest', () => { expect(res).toHaveProperty('execution.output.model', 'anthropic/claude-3.5-sonnet') }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate + .mockResolvedValueOnce(toolCallResponse('get_weather', { city: 'SF' })) + .mockResolvedValueOnce( + textResponse('It is sunny', { prompt_tokens: 20, completion_tokens: 6, total_tokens: 26 }) + ) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + const res = (await openRouterProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [tool('get_weather')], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(res.execution.output).toMatchObject({ + content: 'It is sunny', + tokens: { input: 28, output: 10, total: 38 }, + toolCalls: { count: 1 }, + }) + const reader = res.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'It is sunny', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + }) + it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => { - mockCreate.mockResolvedValue(toolCallResponse('looping', {})) + mockCreate.mockImplementation((payload) => + payload.tool_choice === 'none' + ? textResponse('iteration limit answer') + : toolCallResponse('looping', {}) + ) mockExecuteTool.mockResolvedValue({ success: true, output: {} }) const res = (await openRouterProvider.executeRequest({ @@ -338,9 +467,11 @@ describe('openRouterProvider.executeRequest', () => { tools: [tool('looping')], })) as ProviderResponse - expect(mockCreate).toHaveBeenCalledTimes(11) + expect(mockCreate).toHaveBeenCalledTimes(12) expect(mockExecuteTool).toHaveBeenCalledTimes(10) expect(res.toolCalls?.length).toBe(10) + expect(res.content).toBe('iteration limit answer') + expect(mockCreate.mock.calls.at(-1)?.[0]).toMatchObject({ tool_choice: 'none' }) }) it('wraps SDK errors in a ProviderError', async () => { diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 72780553942..41146d1599c 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -1,17 +1,28 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' +import type { + ChatCompletionCreateParamsStreaming, + ChatCompletionMessage, +} from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { + createOpenAICompatAssistantHistory, + type OpenAICompatAssistantHistoryMessage, +} from '@/providers/openai-compat/assistant-history' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import { checkForForcedToolUsage, createReadableStreamFromOpenAIStream, supportsNativeStructuredOutputs, } from '@/providers/openrouter/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -34,6 +45,12 @@ import { executeTool } from '@/tools' const logger = createLogger('OpenRouterProvider') +type OpenRouterAssistantMessage = ChatCompletionMessage & { + reasoning?: string + reasoning_content?: string + reasoning_details?: OpenRouterReasoningDetail[] +} + /** * Applies structured output configuration to a payload based on model capabilities. * Uses json_schema with require_parameters for supported models, falls back to json_object with prompt instructions. @@ -265,10 +282,25 @@ export const openRouterProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -286,6 +318,9 @@ export const openRouterProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (OpenRouter):', { error: toError(error).message, @@ -308,26 +343,27 @@ export const openRouterProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message as + | OpenRouterAssistantMessage + | undefined + if (assistantMessage) { + const assistantHistory: OpenAICompatAssistantHistoryMessage & { + reasoning_details?: OpenRouterReasoningDetail[] + } = createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + if (Array.isArray(assistantMessage.reasoning_details)) { + assistantHistory.reasoning_details = assistantMessage.reasoning_details + } + currentMessages.push(assistantHistory) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -338,10 +374,12 @@ export const openRouterProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -419,92 +457,61 @@ export const openRouterProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'openrouter' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'openrouter', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path. - */ - const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('OpenRouter final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'openrouter' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -560,6 +567,45 @@ export const openRouterProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -577,7 +623,7 @@ export const openRouterProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -600,6 +646,10 @@ export const openRouterProvider: ProviderConfig = { } logger.error('Error in OpenRouter request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/openrouter/reasoning.ts b/apps/sim/providers/openrouter/reasoning.ts new file mode 100644 index 00000000000..2ebaa05bc91 --- /dev/null +++ b/apps/sim/providers/openrouter/reasoning.ts @@ -0,0 +1,35 @@ +export type OpenRouterReasoningFormat = + | 'unknown' + | 'openai-responses-v1' + | 'azure-openai-responses-v1' + | 'xai-responses-v1' + | 'anthropic-claude-v1' + | 'google-gemini-v1' + +interface OpenRouterReasoningDetailBase { + format?: OpenRouterReasoningFormat + id?: string | null + index?: number +} + +export type OpenRouterReasoningDetail = + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.encrypted' + data: string + }) + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.summary' + summary: string + }) + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.text' + signature?: string | null + text?: string | null + }) + +/** Returns the user-displayable text from a documented OpenRouter reasoning block. */ +export function getOpenRouterReasoningDetailText(detail: OpenRouterReasoningDetail): string { + if (detail.type === 'reasoning.summary') return detail.summary + if (detail.type === 'reasoning.text' && typeof detail.text === 'string') return detail.text + return '' +} diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 5b9e6e54e52..7e9cf12da79 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -7,7 +8,9 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -127,6 +130,7 @@ export const sakanaProvider: ProviderConfig = { // backends reject a request that carries both `response_format` and active // `tools`/`tool_choice`. Defer the schema until after the tool loop completes. const deferResponseFormat = !!responseFormatPayload && hasActiveTools + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload } @@ -260,7 +264,7 @@ export const sakanaProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) // Every tool_call in the assistant message must be answered by a matching @@ -299,6 +303,9 @@ export const sakanaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -318,7 +325,7 @@ export const sakanaProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -333,11 +340,9 @@ export const sakanaProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -348,10 +353,12 @@ export const sakanaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -454,109 +461,69 @@ export const sakanaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'sakana' } ) - } - } catch (error) { - logger.error('Error in Sakana request:', { error }) - throw error - } - - if (request.stream) { - logger.info('Using streaming for final Sakana response after tool processing') - - // The tool loop is complete: this final pass only produces the textual answer. - // Force `tool_choice: 'none'` so the model cannot emit fresh tool calls that the - // text-only stream adapter would silently drop. - const streamingPayload: any = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - if (deferResponseFormat && responseFormatPayload) { - streamingPayload.response_format = responseFormatPayload - streamingPayload.parallel_tool_calls = false - } - const streamResponse = await sakana.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + tool_choice: 'none', + } + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.parallel_tool_calls = false + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await sakana.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromSakanaStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - } - ), - }) + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'sakana' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Sakana request:', { error }) + throw error } // Tools were active, so `response_format` was withheld from the loop. Make one final // tool-free call to obtain the structured response now that the tool work is done. - if (deferResponseFormat && responseFormatPayload) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -602,6 +569,52 @@ export const sakanaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -619,7 +632,7 @@ export const sakanaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -633,6 +646,10 @@ export const sakanaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts new file mode 100644 index 00000000000..d7d1ea5c4c9 --- /dev/null +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -0,0 +1,572 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { basetenProvider } from '@/providers/baseten/index' +import { cerebrasProvider } from '@/providers/cerebras' +import { fireworksProvider } from '@/providers/fireworks/index' +import { kimiProvider } from '@/providers/kimi' +import { metaProvider } from '@/providers/meta' +import { nvidiaProvider } from '@/providers/nvidia' +import { openRouterProvider } from '@/providers/openrouter/index' +import { sakanaProvider } from '@/providers/sakana' +import { togetherProvider } from '@/providers/together/index' +import type { ProviderConfig, ProviderResponse, ProviderToolConfig } from '@/providers/types' +import { xAIProvider } from '@/providers/xai' +import { zaiProvider } from '@/providers/zai' + +const { mockCreate, mockExecuteTool } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@cerebras/cerebras_cloud_sdk', () => ({ + Cerebras: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 1 })) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, + getModelCapabilities: vi.fn(), + getProviderModels: vi.fn((provider: string) => [`${provider}/test-model`]), + getProviderDefaultModel: vi.fn((provider: string) => `${provider}/test-model`), +})) + +vi.mock('@/providers/tool-schema-adapter', () => ({ + adaptOpenAIChatToolSchema: vi.fn((tool: ProviderToolConfig) => ({ + type: 'function', + function: { + name: tool.id, + description: tool.description, + parameters: tool.parameters, + }, + })), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 1, output: 2, total: 3 })), + enforceStrictSchema: vi.fn((schema) => schema), + generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), + prepareToolExecution: vi.fn((_tool, args) => ({ + toolParams: args, + executionParams: args, + })), + prepareToolsWithUsageControl: vi.fn(() => ({ + tools: [{ type: 'function', function: { name: 'lookup' } }], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + })), + sumToolCosts: vi.fn(() => 4), + trackForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), +})) + +vi.mock('@/providers/baseten/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/fireworks/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/openrouter/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/together/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/cerebras/utils', () => ({ + createReadableStreamFromCerebrasStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/kimi/utils', () => ({ + createReadableStreamFromKimiStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/meta/utils', () => ({ + createReadableStreamFromMetaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/nvidia/utils', () => ({ + createReadableStreamFromNvidiaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/sakana/utils', () => ({ + createReadableStreamFromSakanaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/zai/utils', () => ({ + createReadableStreamFromZaiStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/xai/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromXAIStream: vi.fn(() => createEmptyStream()), + createResponseFormatPayload: vi.fn(() => ({})), +})) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +interface ToolCall { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +const TOOL: ProviderToolConfig = { + id: 'lookup', + name: 'lookup', + description: 'Looks up a value', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, +} + +const PROVIDERS = [ + { name: 'Cerebras', provider: cerebrasProvider, model: 'cerebras/test-model' }, + { name: 'Z.ai', provider: zaiProvider, model: 'zai/test-model' }, + { name: 'Sakana', provider: sakanaProvider, model: 'sakana/test-model' }, + { name: 'Kimi', provider: kimiProvider, model: 'kimi/test-model' }, + { name: 'Meta', provider: metaProvider, model: 'meta/test-model' }, + { name: 'NVIDIA', provider: nvidiaProvider, model: 'nvidia/test-model' }, + { name: 'xAI', provider: xAIProvider, model: 'xai/test-model' }, +] as const + +const REASONING_HISTORY_PROVIDERS = [ + { + name: 'Cerebras', + provider: cerebrasProvider, + model: 'cerebras/test-model', + field: 'reasoning', + }, + { + name: 'Kimi', + provider: kimiProvider, + model: 'kimi/test-model', + field: 'reasoning_content', + }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + field: 'reasoning_content', + }, + { + name: 'xAI', + provider: xAIProvider, + model: 'xai/test-model', + field: 'reasoning_content', + }, + { + name: 'Z.ai', + provider: zaiProvider, + model: 'zai/test-model', + field: 'reasoning_content', + }, +] as const + +const CAPPED_PROVIDERS = [ + { + name: 'Cerebras', + provider: cerebrasProvider, + model: 'cerebras/test-model', + disablesTools: 'none', + }, + { name: 'Z.ai', provider: zaiProvider, model: 'zai/test-model', disablesTools: 'omit' }, + { + name: 'Sakana', + provider: sakanaProvider, + model: 'sakana/test-model', + disablesTools: 'none', + }, + { name: 'Kimi', provider: kimiProvider, model: 'kimi/test-model', disablesTools: 'omit' }, + { name: 'Meta', provider: metaProvider, model: 'meta/test-model', disablesTools: 'omit' }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + disablesTools: 'none', + }, +] as const + +const STRUCTURED_OUTPUT_PROVIDERS = [ + { + name: 'Baseten', + provider: basetenProvider, + model: 'baseten/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Fireworks', + provider: fireworksProvider, + model: 'fireworks/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'OpenRouter', + provider: openRouterProvider, + model: 'openrouter/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Together', + provider: togetherProvider, + model: 'together/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Meta', + provider: metaProvider, + model: 'meta/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + responseFormatType: 'json_schema', + disablesTools: 'none', + }, + { + name: 'Sakana', + provider: sakanaProvider, + model: 'sakana/test-model', + responseFormatType: 'json_schema', + disablesTools: 'none', + }, + { + name: 'Z.ai', + provider: zaiProvider, + model: 'zai/test-model', + responseFormatType: 'json_object', + disablesTools: 'omit', + }, +] as const + +function createEmptyStream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) +} + +function toolCall(id: string, argumentsJson = '{}'): ToolCall { + return { + id, + type: 'function', + function: { name: 'lookup', arguments: argumentsJson }, + } +} + +function response( + content: string | null, + toolCalls?: ToolCall[], + reasoning?: { reasoning?: string; reasoning_content?: string } +) { + return { + choices: [{ message: { content, tool_calls: toolCalls, ...reasoning } }], + usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + } +} + +async function executeStreamingRequest( + provider: ProviderConfig, + model: string +): Promise { + return provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + stream: true, + }) +} + +async function executeStreamingStructuredRequest( + provider: ProviderConfig, + model: string +): Promise { + return provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + responseFormat: { + name: 'lookup_result', + schema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + strict: true, + }, + stream: true, + }) +} + +async function readSettledEvents(result: ProviderResponse | StreamingExecution) { + if (!('stream' in result)) { + throw new Error('Expected a streaming execution') + } + + const events: unknown[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return { events, result } +} + +function expectModelIterations(result: StreamingExecution, expectedIterations: number) { + const timing = result.execution.output.providerTiming + expect(timing?.iterations).toBe(expectedIterations) + expect(timing?.timeSegments?.filter((segment) => segment.type === 'model')).toHaveLength( + expectedIterations + ) +} + +describe('settled provider tool streams', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockReset() + mockExecuteTool.mockReset() + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'found' } }) + }) + + it.each(PROVIDERS)( + '$name projects the existing final answer without another provider call', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('final answer')) + + const { events, result } = await readSettledEvents( + await executeStreamingRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockCreate.mock.calls.some(([payload]) => payload.stream === true)).toBe(false) + expect(result.streamFormat).toBe('agent-events-v1') + expect(events).toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: 'final answer', + tokens: { input: 10, output: 6, total: 16 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + toolCalls: { count: 1 }, + }) + expectModelIterations(result, 2) + } + ) + + it.each(STRUCTURED_OUTPUT_PROVIDERS)( + '$name performs deferred structured extraction before projecting a settled stream', + async ({ provider, model, responseFormatType, disablesTools }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('intermediate answer')) + .mockResolvedValueOnce(response('{"value":"found"}')) + + const { events, result } = await readSettledEvents( + await executeStreamingStructuredRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(mockCreate.mock.calls[0][0].response_format).toBeUndefined() + const finalPayload = mockCreate.mock.calls[2][0] + expect(finalPayload.response_format).toMatchObject({ type: responseFormatType }) + if (disablesTools === 'none') { + expect(finalPayload.tool_choice).toBe('none') + } else { + expect(finalPayload.tools).toBeUndefined() + expect(finalPayload.tool_choice).toBeUndefined() + } + expect(events).toEqual([{ type: 'text_delta', text: '{"value":"found"}', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: '{"value":"found"}', + tokens: { input: 15, output: 9, total: 24 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + toolCalls: { count: 1 }, + }) + expectModelIterations(result, 3) + } + ) + + it.each(STRUCTURED_OUTPUT_PROVIDERS)( + '$name makes only one schema-bearing final call when the tool loop reaches its cap', + async ({ provider, model, responseFormatType }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response(null, [toolCall('call-2')])) + .mockResolvedValueOnce(response('{"value":"capped"}')) + + const { events, result } = await readSettledEvents( + await executeStreamingStructuredRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(mockCreate.mock.calls[2][0].response_format).toMatchObject({ + type: responseFormatType, + }) + expect(events).toEqual([{ type: 'text_delta', text: '{"value":"capped"}', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: '{"value":"capped"}', + tokens: { input: 15, output: 9, total: 24 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + }) + expectModelIterations(result, 3) + } + ) + + it.each(REASONING_HISTORY_PROVIDERS)( + '$name preserves the complete reasoning-bearing assistant tool turn', + async ({ provider, model, field }) => { + mockCreate + .mockResolvedValueOnce( + response('I need the lookup result.', [toolCall('call-1')], { + [field]: 'provider reasoning', + }) + ) + .mockResolvedValueOnce(response('final answer')) + + await provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + }) + + const secondPayload = mockCreate.mock.calls[1][0] as { + messages: Array> + } + const assistant = secondPayload.messages.find((message) => message.role === 'assistant') + expect(assistant).toEqual({ + role: 'assistant', + content: 'I need the lookup result.', + tool_calls: [toolCall('call-1')], + [field]: 'provider reasoning', + }) + } + ) + + it.each(PROVIDERS)( + '$name rejects tool AbortError instead of replaying it as a result', + async ({ provider, model }) => { + mockCreate.mockResolvedValueOnce(response(null, [toolCall('call-1')])) + mockExecuteTool.mockRejectedValueOnce(new DOMException('cancelled', 'AbortError')) + + await expect(executeStreamingRequest(provider, model)).rejects.toMatchObject({ + name: 'AbortError', + }) + expect(mockCreate).toHaveBeenCalledTimes(1) + } + ) + + it.each(PROVIDERS)( + '$name does not execute malformed tool arguments', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1', '{"query":')])) + .mockResolvedValueOnce(response('recovered')) + + await executeStreamingRequest(provider, model) + + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockCreate).toHaveBeenCalledTimes(2) + } + ) + + it.each(PROVIDERS)( + '$name replays a successful false output as a valid tool result', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('final answer')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: false }) + + await executeStreamingRequest(provider, model) + + const secondPayload = mockCreate.mock.calls[1][0] as { + messages: Array<{ role: string; content?: string }> + } + expect(secondPayload.messages).toContainEqual( + expect.objectContaining({ role: 'tool', content: 'false' }) + ) + } + ) + + it.each(CAPPED_PROVIDERS)( + '$name uses one tool-disabled synthesis when the iteration cap ends on a tool call', + async ({ provider, model, disablesTools }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response(null, [toolCall('call-2')])) + .mockResolvedValueOnce(response('cap synthesis')) + + const { events, result } = await readSettledEvents( + await executeStreamingRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + const finalPayload = mockCreate.mock.calls[2][0] + expect(finalPayload.stream).toBeUndefined() + if (disablesTools === 'none') { + expect(finalPayload.tool_choice).toBe('none') + } else { + expect(finalPayload.tools).toBeUndefined() + expect(finalPayload.tool_choice).toBeUndefined() + } + expect(events).toEqual([{ type: 'text_delta', text: 'cap synthesis', turn: 'final' }]) + expectModelIterations(result, 3) + } + ) +}) diff --git a/apps/sim/providers/stream-events.test.ts b/apps/sim/providers/stream-events.test.ts index 6f497d5eb3f..76c93cd3373 100644 --- a/apps/sim/providers/stream-events.test.ts +++ b/apps/sim/providers/stream-events.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { type AgentStreamEvent, createAgentEventReadableStream, + createSettledAgentEventStream, isAgentStreamEvent, isTextDeltaTurn, isToolCallEndStatus, @@ -94,4 +95,14 @@ describe('stream-events contract', () => { await expect(reader.read()).rejects.toThrow(/Invalid AgentStreamEvent/) }) }) + + it('projects a settled answer without model regeneration', async () => { + const reader = createSettledAgentEventStream('settled answer').getReader() + + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'settled answer', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + }) }) diff --git a/apps/sim/providers/stream-events.ts b/apps/sim/providers/stream-events.ts index f96608dc67d..09e460f730f 100644 --- a/apps/sim/providers/stream-events.ts +++ b/apps/sim/providers/stream-events.ts @@ -140,3 +140,13 @@ export function createAgentEventReadableStream( }, }) } + +/** + * Projects an already-settled provider answer onto the canonical stream without + * asking the model to generate it a second time. + */ +export function createSettledAgentEventStream(content: string): ReadableStream { + return createAgentEventReadableStream( + content ? [{ type: 'text_delta', text: content, turn: 'final' }] : [] + ) +} diff --git a/apps/sim/providers/streaming-execution.test.ts b/apps/sim/providers/streaming-execution.test.ts index f5d4c678932..83552be2883 100644 --- a/apps/sim/providers/streaming-execution.test.ts +++ b/apps/sim/providers/streaming-execution.test.ts @@ -135,9 +135,10 @@ describe('createStreamingExecution', () => { vi.restoreAllMocks() }) - it('only finalizes timing when the provider calls finalizeTiming', () => { + it('finalizes timing when the provider stream closes', async () => { const constructTime = 1_200 - vi.spyOn(Date, 'now').mockReturnValue(constructTime) + const drainTime = 4_500 + const nowMock = vi.spyOn(Date, 'now').mockReturnValue(constructTime) const result = createStreamingExecution({ model: 'no-finalize', @@ -155,13 +156,22 @@ describe('createStreamingExecution', () => { initialCost: { input: 0, output: 0, total: 0 }, createStream: ({ output }) => { output.content = 'no-timing-mutation' - return new ReadableStream() + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, }) + nowMock.mockReturnValue(drainTime) + await result.stream.getReader().read() + const timing = result.execution.output.providerTiming - expect(timing?.endTime).toBe(new Date(constructTime).toISOString()) - expect(timing?.duration).toBe(constructTime - providerStartTime) + expect(timing?.endTime).toBe(new Date(drainTime).toISOString()) + expect(timing?.duration).toBe(drainTime - providerStartTime) + expect(result.execution.metadata?.endTime).toBe(new Date(drainTime).toISOString()) + expect(result.execution.metadata?.duration).toBe(drainTime - providerStartTime) expect(result.execution.isStreaming).toBeUndefined() vi.restoreAllMocks() @@ -209,6 +219,35 @@ describe('createStreamingExecution', () => { vi.restoreAllMocks() }) + it('propagates cancellation and finalizes timing', async () => { + const constructTime = 1_100 + const cancelTime = 3_200 + const nowMock = vi.spyOn(Date, 'now').mockReturnValue(constructTime) + const onCancel = vi.fn() + + const result = createStreamingExecution({ + model: 'cancel-model', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'cancel-model' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: () => + new ReadableStream({ + cancel: onCancel, + }), + }) + + nowMock.mockReturnValue(cancelTime) + await result.stream.cancel('consumer disconnected') + + expect(onCancel).toHaveBeenCalledWith('consumer disconnected') + expect(result.execution.output.providerTiming?.duration).toBe(cancelTime - providerStartTime) + expect(result.execution.metadata?.duration).toBe(cancelTime - providerStartTime) + + vi.restoreAllMocks() + }) + it('defaults streamFormat to text and can attach agent-events-v1 object streams', async () => { const textResult = createStreamingExecution({ model: 'm', diff --git a/apps/sim/providers/streaming-execution.ts b/apps/sim/providers/streaming-execution.ts index ef020c5d328..9b8950090af 100644 --- a/apps/sim/providers/streaming-execution.ts +++ b/apps/sim/providers/streaming-execution.ts @@ -58,7 +58,11 @@ type StreamingTiming = SimpleTiming | AccumulatedTiming interface StreamFinalizer { /** Live output object — write final `content`/`tokens`/`cost` here on drain. */ output: NormalizedBlockOutput - /** Overwrites placeholder timing from the drain timestamp. Call once on drain. */ + /** + * Overwrites placeholder timing from the drain timestamp. Providers may call + * this after populating output; the factory also guarantees finalization when + * the stream closes, errors, or is cancelled. + */ finalizeTiming: () => void } @@ -156,12 +160,36 @@ export function createStreamingExecution( providerTiming, cost: initialCost, } + const executionMetadata = { + startTime: providerStartTimeISO, + endTime: nowISO, + duration, + } const timingKind = timing.kind - const stream = createStream({ + let timingFinalized = false + const finalizeTimingOnce = () => { + if (timingFinalized) return + timingFinalized = true + finalizeTiming(output, providerStartTime, timingKind) + const finalizedTiming = output.providerTiming + if (finalizedTiming) { + executionMetadata.endTime = finalizedTiming.endTime + executionMetadata.duration = finalizedTiming.duration + } + } + const providerStream = createStream({ output, - finalizeTiming: () => finalizeTiming(output, providerStartTime, timingKind), + finalizeTiming: finalizeTimingOnce, }) + const stream = timingFinalized + ? providerStream + : streamFormat === 'agent-events-v1' + ? withTimingFinalization( + providerStream as ReadableStream, + finalizeTimingOnce + ) + : withTimingFinalization(providerStream as ReadableStream, finalizeTimingOnce) return { stream, @@ -170,16 +198,50 @@ export function createStreamingExecution( success: true, output, logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: nowISO, - duration, - }, + metadata: executionMetadata, ...(isStreaming ? { isStreaming: true } : {}), }, } } +/** + * Finalizes provider timing exactly once when a stream settles while preserving + * backpressure and cancellation on the provider's original stream. + */ +function withTimingFinalization( + stream: ReadableStream, + finalize: () => void +): ReadableStream { + const reader = stream.getReader() + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read() + if (done) { + finalize() + reader.releaseLock() + controller.close() + return + } + controller.enqueue(value) + } catch (error) { + finalize() + reader.releaseLock() + controller.error(error) + } + }, + async cancel(reason) { + try { + await reader.cancel(reason) + } finally { + finalize() + reader.releaseLock() + } + }, + }) +} + /** * Overwrites the placeholder timing with the drain timestamp. For the simple * path the first time segment is also finalized; for the accumulated path only diff --git a/apps/sim/providers/streaming-tool-loop-shared.test.ts b/apps/sim/providers/streaming-tool-loop-shared.test.ts new file mode 100644 index 00000000000..563a0ac2d41 --- /dev/null +++ b/apps/sim/providers/streaming-tool-loop-shared.test.ts @@ -0,0 +1,26 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { parseToolArguments } from '@/providers/streaming-tool-loop-shared' + +describe('parseToolArguments', () => { + it('returns JSON objects', () => { + expect(parseToolArguments('{"query":"sim"}', 'search')).toEqual({ query: 'sim' }) + }) + + it.each(['null', '[]', '"text"', '0', 'false'])( + 'rejects non-object JSON arguments: %s', + (argumentsJson) => { + expect(() => parseToolArguments(argumentsJson, 'search')).toThrow( + 'Arguments for tool "search" must be a JSON object' + ) + } + ) + + it('rejects malformed JSON with the tool name', () => { + expect(() => parseToolArguments('{"query":', 'search')).toThrow( + 'Invalid JSON arguments for tool "search"' + ) + }) +}) diff --git a/apps/sim/providers/streaming-tool-loop-shared.ts b/apps/sim/providers/streaming-tool-loop-shared.ts index 8b4dc9bf11d..5ac0c49d777 100644 --- a/apps/sim/providers/streaming-tool-loop-shared.ts +++ b/apps/sim/providers/streaming-tool-loop-shared.ts @@ -6,16 +6,18 @@ * provider-agnostic contract they share. */ +import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { NormalizedBlockOutput } from '@/executor/types' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' /** - * Providers with a live streaming tool loop wired. The executor consults this - * (instead of hardcoding provider ids) when deciding whether an agent-events - * run can stream tool lifecycle; providers not listed simply ignore - * `streamToolCalls` and keep their legacy loop. + * Providers with a live streaming tool loop wired. Generated documentation and + * capability reporting consume this set; providers select their own internal + * loop from request shape. Event exposure is controlled separately. */ export const STREAMING_TOOL_CALL_PROVIDERS: ReadonlySet = new Set([ + 'openai', 'anthropic', 'azure-anthropic', 'groq', @@ -25,11 +27,6 @@ export const STREAMING_TOOL_CALL_PROVIDERS: ReadonlySet = new Set([ 'bedrock', ]) -/** Whether a provider has a live streaming tool loop wired. */ -export function supportsStreamingToolCalls(providerId: string): boolean { - return STREAMING_TOOL_CALL_PROVIDERS.has(providerId) -} - /** Aggregate result reported by a streaming tool loop when its stream closes. */ export interface StreamingToolLoopComplete { content: string @@ -49,6 +46,25 @@ export function isAbortError(error: unknown): boolean { return name === 'AbortError' || name === 'APIUserAbortError' } +/** Parse provider-supplied tool arguments without accepting non-object JSON values. */ +export function parseToolArguments( + argumentsJson: string, + toolName: string +): Record { + let parsed: unknown + try { + parsed = JSON.parse(argumentsJson) + } catch (error) { + throw new Error(`Invalid JSON arguments for tool "${toolName}"`, { cause: error }) + } + + if (!isRecordLike(parsed)) { + throw new Error(`Arguments for tool "${toolName}" must be a JSON object`) + } + + return parsed +} + /** * Settle every open tool with a terminal status and clear the tracking map. * Called when a loop aborts, errors, or drains with tools still running so no @@ -64,3 +80,49 @@ export function settleOpenTools( } openTools.clear() } + +interface TerminateToolLoopOptions { + controller: ReadableStreamDefaultController + /** Tools that emitted `tool_call_start` without a matching end. */ + openTools: Map + /** The loop's abort controller fired — request abort or consumer cancel. */ + aborted: boolean + /** The stream consumer called `cancel()`; the controller is already closed. */ + consumerCancelled: boolean + error: unknown + /** Invoked only for a genuine failure, before the stream is errored. */ + onUnexpectedError?: (error: unknown) => void +} + +/** + * Terminates a streaming tool loop that threw, settling any still-open tools. + * + * A consumer `cancel()` leaves the controller in the *closed* state, where + * `enqueue` and `close` both throw `TypeError: Invalid state`. That state is + * indistinguishable via `desiredSize` — it reports `0` when closed and `null` + * only when errored — so loops must track the cancel explicitly and stop + * writing. Every provider loop routes its catch block through here so the + * five of them cannot drift. + */ +export function terminateToolLoop({ + controller, + openTools, + aborted, + consumerCancelled, + error, + onUnexpectedError, +}: TerminateToolLoopOptions): void { + if (consumerCancelled) { + return + } + + if (aborted) { + settleOpenTools(controller, openTools, 'cancelled') + controller.close() + return + } + + settleOpenTools(controller, openTools, 'error') + onUnexpectedError?.(error) + controller.error(toError(error)) +} diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index 9d5386331cc..40624dad957 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/together/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,12 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = (assistant: { content?: string | null; reasoning?: string } = {}) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning !== undefined ? { reasoning: assistant.reasoning } : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -217,15 +221,54 @@ describe('togetherProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Together assistant content and reasoning on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await togetherProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await togetherProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await togetherProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index c52f383d2ff..d8f67bb1435 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { checkForForcedToolUsage, createReadableStreamFromOpenAIStream, @@ -263,10 +267,25 @@ export const togetherProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -284,6 +303,9 @@ export const togetherProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Together):', { error: toError(error).message, @@ -306,26 +328,21 @@ export const togetherProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -336,10 +353,12 @@ export const togetherProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -417,85 +436,61 @@ export const togetherProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'together' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'together', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - const streamingResult = createStreamingExecution({ - model: requestedModel, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - const streamCost = calculateCost( - requestedModel, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), - }) + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'together' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -551,6 +546,45 @@ export const togetherProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -568,7 +602,7 @@ export const togetherProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -591,6 +625,10 @@ export const togetherProvider: ProviderConfig = { } logger.error('Error in Together request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts index b69517a7bb0..caa82abf61e 100644 --- a/apps/sim/providers/trace-enrichment.ts +++ b/apps/sim/providers/trace-enrichment.ts @@ -1,5 +1,9 @@ import type { BlockTokens, IterationToolCall, ProviderTimingSegment } from '@/executor/types' -import { calculateCost } from '@/providers/utils' +import { LIST_PRICE_POLICY, priceModelUsage } from '@/providers/cost-policy' +import { + getOpenRouterReasoningDetailText, + type OpenRouterReasoningDetail, +} from '@/providers/openrouter/reasoning' /** * Minimal structural shape shared by OpenAI Chat Completions and every @@ -15,10 +19,7 @@ interface ChatCompletionLike { tool_calls?: Array | null reasoning_content?: string | null reasoning?: string | null - reasoning_details?: Array<{ - text?: string | null - summary?: string | null - } | null> | null + reasoning_details?: OpenRouterReasoningDetail[] | null } | null finish_reason?: string | null } | null> @@ -141,8 +142,8 @@ function extractChatCompletionsReasoning( } if (Array.isArray(message.reasoning_details)) { const joined = message.reasoning_details - .map((d) => d?.text ?? d?.summary ?? '') - .filter((s): s is string => typeof s === 'string' && s.length > 0) + .map(getOpenRouterReasoningDetailText) + .filter((text) => text.length > 0) .join('\n') if (joined.length > 0) return joined } @@ -193,7 +194,17 @@ export function enrichLastModelSegmentFromChatCompletions( let derivedCost = extras?.cost if (!derivedCost && extras?.model && promptTokens != null && completionTokens != null) { - const full = calculateCost(extras.model, promptTokens, completionTokens, cacheRead > 0) + // OpenAI-compatible vendors report cached tokens as a subset of the prompt + // total, so the uncached remainder is the subtraction. + const full = priceModelUsage( + extras.model, + { + input: Math.max(0, promptTokens - cacheRead), + output: completionTokens, + cacheRead, + }, + LIST_PRICE_POLICY + ) derivedCost = { input: full.input, output: full.output, total: full.total } } diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 9d810b7961c..2bb1a9d31c0 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -67,7 +67,7 @@ export interface FunctionCallResponse { startTime?: string endTime?: string duration?: number - result?: Record + result?: unknown output?: Record input?: Record success?: boolean @@ -84,9 +84,14 @@ export interface ProviderResponse { content: string model: string tokens?: { + /** Tokens billed at the base input rate, excluding cache reads and writes. */ input?: number output?: number total?: number + /** Input tokens served from the provider's prompt cache. */ + cacheRead?: number + /** Input tokens written to the provider's prompt cache. */ + cacheWrite?: number } toolCalls?: FunctionCallResponse[] toolResults?: Record[] @@ -168,17 +173,11 @@ export interface ProviderRequest { chatId?: string userId?: string stream?: boolean - /** - * Use the live streaming tool loop (tool lifecycle on the agent-events - * stream). Set by the executor only for agent-events runs on providers in - * {@link STREAMING_TOOL_CALL_PROVIDERS}; providers without a loop ignore it. - */ - streamToolCalls?: boolean /** * Run-level agent-events opt-in. Lets providers request streamable thinking - * (e.g. OpenAI reasoning summaries, Gemini thought summaries) for the run. - * Never changes answer content; when unset, provider requests are identical - * to the pre-agent-events wire shape. + * (e.g. OpenAI reasoning summaries, Gemini thought summaries) and lets opted-in + * consumers observe the event timeline. It does not select the internal tool + * loop and never changes answer content. */ agentEvents?: boolean environmentVariables?: Record @@ -197,6 +196,14 @@ export interface ProviderRequest { reasoningEffort?: string verbosity?: string thinkingLevel?: string + /** + * Opt in to caller-placed prompt-cache breakpoints on the static prefix. + * Only meaningful for models declaring `capabilities.promptCaching`; + * `sanitizeRequest` clears it otherwise. + */ + promptCaching?: boolean + /** Stable identity of the block issuing the request, used for cache routing. */ + blockId?: string isDeployedContext?: boolean callChain?: string[] /** diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index cf734280678..0b10c2bf511 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -393,7 +393,7 @@ describe('Model Capabilities', () => { expect(supportsThinking('claude-sonnet-4-5')).toBe(true) expect(supportsThinking('claude-haiku-4-5')).toBe(true) expect(supportsThinking('gemini-3-flash-preview')).toBe(true) - expect(supportsThinking('deepseek-chat')).toBe(true) + expect(supportsThinking('deepseek-v4-flash')).toBe(true) expect(supportsThinking('deepseek-reasoner')).toBe(true) expect(supportsThinking('groq/qwen/qwen3.6-27b')).toBe(true) }) @@ -402,6 +402,7 @@ describe('Model Capabilities', () => { expect(supportsThinking('gpt-4o')).toBe(false) expect(supportsThinking('gpt-5')).toBe(false) expect(supportsThinking('o3')).toBe(false) + expect(supportsThinking('deepseek-chat')).toBe(false) expect(supportsThinking('deepseek-v3')).toBe(false) expect(supportsThinking('unknown-model')).toBe(false) }) @@ -515,8 +516,9 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_THINKING).toContain('claude-haiku-4-5') - expect(MODELS_WITH_THINKING).toContain('deepseek-chat') + expect(MODELS_WITH_THINKING).toContain('deepseek-v4-flash') expect(MODELS_WITH_THINKING).toContain('deepseek-reasoner') + expect(MODELS_WITH_THINKING).not.toContain('deepseek-chat') expect(MODELS_WITH_THINKING).toContain('groq/qwen/qwen3.6-27b') expect(MODELS_WITH_THINKING).not.toContain('gpt-4o') @@ -703,8 +705,11 @@ describe('Max Output Tokens', () => { expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000) }) + it('should return published max for DeepSeek Reasoner', () => { + expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(384000) + }) + it('should return standard default for models without maxOutputTokens', () => { - expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096) expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index bb6ce810ff0..05d60e37a12 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -32,6 +32,7 @@ import { getModelPricing as getModelPricingFromDefinitions, getModelsWithDeepResearch, getModelsWithoutMemory, + getModelsWithPromptCaching, getModelsWithReasoningEffort, getModelsWithTemperatureRange, getModelsWithTemperatureSupport, @@ -1320,6 +1321,7 @@ export const MODELS_WITH_TEMPERATURE_SUPPORT = getModelsWithTemperatureSupport() export const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() export const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() export const MODELS_WITH_THINKING = getModelsWithThinking() +export const MODELS_WITH_PROMPT_CACHING = getModelsWithPromptCaching() export const MODELS_WITH_DEEP_RESEARCH = getModelsWithDeepResearch() export const MODELS_WITHOUT_MEMORY = getModelsWithoutMemory() export const PROVIDERS_WITH_TOOL_USAGE_CONTROL = getProvidersWithToolUsageControl() @@ -1340,6 +1342,11 @@ export function supportsThinking(model: string): boolean { return MODELS_WITH_THINKING.includes(model.toLowerCase()) } +/** Whether the model accepts caller-placed prompt-cache breakpoints. */ +export function supportsPromptCaching(model: string): boolean { + return MODELS_WITH_PROMPT_CACHING.includes(model.toLowerCase()) +} + export function isDeepResearchModel(model: string): boolean { return MODELS_WITH_DEEP_RESEARCH.includes(model.toLowerCase()) } diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index ed6fa3de8f1..342067d8022 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -75,6 +75,7 @@ vi.mock('@/stores/providers', () => ({ })) import { clearProviderClientCacheForTests } from '@/providers/client-cache' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderToolConfig } from '@/providers/types' import { vllmProvider } from '@/providers/vllm/index' @@ -84,9 +85,13 @@ interface ToolCall { function: { name: string; arguments: string } } -function chatResponse(content: string | null, toolCalls?: ToolCall[]) { +function chatResponse( + content: string | null, + toolCalls?: ToolCall[], + reasoning?: { reasoning?: string; reasoning_content?: string } +) { return { - choices: [{ message: { content, tool_calls: toolCalls } }], + choices: [{ message: { content, tool_calls: toolCalls, ...reasoning } }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, } } @@ -110,6 +115,16 @@ const toolCall = (id: string, name: string, args = '{}'): ToolCall => ({ /** Payload passed to the Nth `chat.completions.create` call. */ const createPayload = (callIndex: number) => mockCreate.mock.calls[callIndex][0] +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + afterAll(resetEnvMock) describe('vllmProvider', () => { @@ -281,6 +296,40 @@ describe('vllmProvider', () => { expect(result.toolResults).toHaveLength(1) }) + it('replays vLLM assistant content and emitted reasoning fields on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + chatResponse('I will use the tool.', [toolCall('call_1', 'myTool', '{"x":1}')], { + reasoning: 'Current vLLM reasoning.', + reasoning_content: 'Legacy vLLM reasoning.', + }) + ) + .mockResolvedValueOnce(chatResponse('final answer')) + + await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'use a tool' }], + tools: [makeTool('myTool')], + }) + + const assistant = createPayload(1).messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Current vLLM reasoning.', + reasoning_content: 'Legacy vLLM reasoning.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'myTool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without throwing', async () => { mockExecuteTool.mockResolvedValueOnce({ success: false, error: 'tool blew up' }) mockCreate @@ -353,19 +402,33 @@ describe('vllmProvider', () => { expect('stream' in result && 'execution' in result).toBe(true) }) - it('uses tool_choice "none" on the final streaming call after tool processing', async () => { - mockCreate.mockResolvedValueOnce(chatResponse('answer')).mockResolvedValueOnce({}) + it('projects the settled tool-loop answer without a final streaming call', async () => { + mockCreate + .mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool')])) + .mockResolvedValueOnce(chatResponse('answer')) - await vllmProvider.executeRequest({ + const result = await vllmProvider.executeRequest({ model: 'vllm/llama-3', messages: [{ role: 'user', content: 'hi' }], stream: true, tools: [makeTool('myTool')], }) - const streamingPayload = createPayload(1) - expect(streamingPayload.stream).toBe(true) - expect(streamingPayload.tool_choice).toBe('none') + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('answer') + expect(result.execution.output.tokens).toEqual({ input: 20, output: 10, total: 30 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'answer', turn: 'final' }]) }) it('throws a ProviderError carrying the vLLM error message on API failure', async () => { diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 34ee8d9f256..0f258b8bb22 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import { env } from '@/lib/core/config/env' @@ -9,7 +10,10 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getCachedProviderClient } from '@/providers/client-cache' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -359,10 +363,25 @@ export const vllmProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -380,6 +399,9 @@ export const vllmProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -399,26 +421,21 @@ export const vllmProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -429,10 +446,12 @@ export const vllmProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -535,26 +554,58 @@ export const vllmProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'vllm' } ) + + if (currentResponse.choices[0]?.message?.tool_calls?.length) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await vllm.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (content && request.responseFormat) { + content = content.replace(/```json\n?|\n?```/g, '').trim() + } + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'vllm' } + ) + } } if (request.stream) { - logger.info('Using streaming for final response after tool processing') + logger.info('Projecting settled response after tool processing') const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await vllm.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -563,7 +614,7 @@ export const vllmProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -574,7 +625,8 @@ export const vllmProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -584,36 +636,12 @@ export const vllmProvider: ProviderConfig = { } : undefined, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { - let cleanContent = content - if (cleanContent && request.responseFormat) { - cleanContent = cleanContent.replace(/```json\n?|\n?```/g, '').trim() - } - - output.content = cleanContent - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -633,7 +661,7 @@ export const vllmProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -662,6 +690,10 @@ export const vllmProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 974bbe87565..55c6ecdaecb 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -1,15 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { - ChatCompletionChunk, - ChatCompletionCreateParamsStreaming, -} from 'openai/resources/chat/completions' +import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -248,12 +249,25 @@ export const xAIProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { logger.warn('XAI Provider - Tool not found:', { toolName }) - return null + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) @@ -272,6 +286,9 @@ export const xAIProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('XAI Provider - Error processing tool call:', { error: toError(error).message, @@ -294,25 +311,21 @@ export const xAIProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -322,10 +335,12 @@ export const xAIProvider: ProviderConfig = { duration: duration, toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -453,52 +468,78 @@ export const xAIProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + pendingToolCalls, { model: request.model, provider: 'xai' } ) + + if (pendingToolCalls?.length) { + const finalPayload = request.responseFormat + ? createResponseFormatPayload( + basePayload, + allMessages, + request.responseFormat, + currentMessages + ) + : { + ...basePayload, + messages: currentMessages, + tools: preparedTools?.tools, + tool_choice: 'none', + } + const finalStartTime = Date.now() + const finalResponse = await xai.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration + + if (finalResponse.choices[0]?.message?.content) { + content = finalResponse.choices[0].message.content + } + if (finalResponse.usage) { + tokens.input += finalResponse.usage.prompt_tokens || 0 + tokens.output += finalResponse.usage.completion_tokens || 0 + tokens.total += finalResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'xai' } + ) + } } } catch (error) { logger.error('XAI Provider - Error in tool processing loop:', { error: toError(error).message, iterationCount, }) + throw error } if (request.stream) { - let finalStreamingPayload: any - - if (request.responseFormat) { - finalStreamingPayload = { - ...createResponseFormatPayload( - basePayload, - allMessages, - request.responseFormat, - currentMessages - ), - stream: true, - } - } else { - /** - * The regeneration exists purely to stream the settled answer as - * prose — streamed tool_calls are never executed on this path. - */ - finalStreamingPayload = { - ...basePayload, - messages: currentMessages, - tool_choice: 'none', - tools: preparedTools?.tools, - stream: true, - } - } - - const streamResponse = await xai.chat.completions.create( - finalStreamingPayload as any, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } const streamingResult = createStreamingExecution({ model: request.model, @@ -517,12 +558,7 @@ export const xAIProvider: ProviderConfig = { output: tokens.output, total: tokens.total, }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, + initialCost: finalCost, toolCalls: toolCalls.length > 0 ? { @@ -532,35 +568,13 @@ export const xAIProvider: ProviderConfig = { : undefined, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromXAIStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('xAI final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - } - ), + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -606,6 +620,10 @@ export const xAIProvider: ProviderConfig = { hasResponseFormat: !!request.responseFormat, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts index e8c960e1a81..39358ef774a 100644 --- a/apps/sim/providers/zai/index.ts +++ b/apps/sim/providers/zai/index.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -21,7 +25,6 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, - trackForcedToolUsage, } from '@/providers/utils' import { createReadableStreamFromZaiStream } from '@/providers/zai/utils' import { executeTool } from '@/tools' @@ -156,6 +159,7 @@ export const zaiProvider: ProviderConfig = { } const deferResponseFormat = !!responseFormatPayload && hasActiveTools + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload payload.messages = withSchemaGuidance( @@ -215,9 +219,6 @@ export const zaiProvider: ProviderConfig = { } const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] let currentResponse = await zai.chat.completions.create( payload, @@ -236,7 +237,6 @@ export const zaiProvider: ProviderConfig = { const toolResults: Record[] = [] const currentMessages = [...formattedMessages] let iterationCount = 0 - let hasUsedForcedTool = false let modelTime = firstResponseTime let toolsTime = 0 @@ -250,23 +250,6 @@ export const zaiProvider: ProviderConfig = { }, ] - if ( - typeof originalToolChoice === 'object' && - currentResponse.choices[0]?.message?.tool_calls - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - originalToolChoice, - logger, - 'openai', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - try { while (iterationCount < MAX_TOOL_ITERATIONS) { if (currentResponse.choices[0]?.message?.content) { @@ -293,7 +276,7 @@ export const zaiProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -329,6 +312,9 @@ export const zaiProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -348,26 +334,21 @@ export const zaiProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - currentMessages.push({ - role: 'assistant', - content: null, - tool_calls: toolCallsInResponse.map((tc) => ({ - id: tc.id, - type: 'function', - function: { - name: tc.function.name, - arguments: tc.function.arguments, - }, - })), - }) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -378,10 +359,12 @@ export const zaiProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -415,48 +398,12 @@ export const zaiProvider: ProviderConfig = { messages: currentMessages, } - if ( - typeof originalToolChoice === 'object' && - hasUsedForcedTool && - forcedTools.length > 0 - ) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - - if (remainingTools.length > 0) { - nextPayload.tool_choice = { - type: 'function', - function: { name: remainingTools[0] }, - } - logger.info(`Forcing next tool: ${remainingTools[0]}`) - } else { - nextPayload.tool_choice = 'auto' - logger.info('All forced tools have been used, switching to auto tool_choice') - } - } - const nextModelStartTime = Date.now() currentResponse = await zai.chat.completions.create( nextPayload, request.abortSignal ? { signal: request.abortSignal } : undefined ) - if ( - typeof nextPayload.tool_choice === 'object' && - currentResponse.choices[0]?.message?.tool_calls - ) { - const toolCallsResponse = currentResponse.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - nextPayload.tool_choice, - logger, - 'openai', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - const nextModelEndTime = Date.now() const thisModelTime = nextModelEndTime - nextModelStartTime @@ -484,108 +431,71 @@ export const zaiProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'zai' } ) - } - } catch (error) { - logger.error('Error in Z.ai request:', { error }) - throw error - } - - if (request.stream) { - logger.info('Using streaming for final Z.ai response after tool processing') - const streamingPayload: any = { - ...payload, - messages: currentMessages, - stream: true, - stream_options: { include_usage: true }, - } - streamingPayload.tools = undefined - streamingPayload.tool_choice = undefined - if (deferResponseFormat && responseFormatPayload) { - streamingPayload.response_format = responseFormatPayload - streamingPayload.messages = withSchemaGuidance( - streamingPayload.messages, - buildSchemaGuidance(request.responseFormat) - ) - } - - const streamResponse = await zai.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + } + finalPayload.tools = undefined + finalPayload.tool_choice = undefined + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.messages = withSchemaGuidance( + finalPayload.messages, + buildSchemaGuidance(request.responseFormat) + ) + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await zai.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - const streamingResult = createStreamingExecution({ - model: request.model, - providerStartTime, - providerStartTimeISO, - timing: { - kind: 'accumulated', - modelTime, - toolsTime, - firstResponseTime, - iterations: iterationCount + 1, - timeSegments, - }, - initialTokens: { - input: tokens.input, - output: tokens.output, - total: tokens.total, - }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromZaiStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - } - } - ), - }) + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'zai' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Z.ai request:', { error }) + throw error } - if (deferResponseFormat && responseFormatPayload) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred response_format after tool processing') const finalFormatStartTime = Date.now() @@ -634,6 +544,52 @@ export const zaiProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -651,7 +607,7 @@ export const zaiProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -665,6 +621,10 @@ export const zaiProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/tools/firecrawl/batch-scrape.ts b/apps/sim/tools/firecrawl/batch-scrape.ts index 933c37d22ae..d3b7df2090c 100644 --- a/apps/sim/tools/firecrawl/batch-scrape.ts +++ b/apps/sim/tools/firecrawl/batch-scrape.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { FirecrawlBatchScrapeParams, FirecrawlBatchScrapeResponse, @@ -101,33 +102,7 @@ export const batchScrapeTool: ToolConfig { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', @@ -214,7 +189,10 @@ export const batchScrapeTool: ToolConfig }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { url: 'https://api.firecrawl.dev/v2/crawl', @@ -181,7 +156,10 @@ export const crawlTool: ToolConfig result.output = { pages: crawlData.data || [], total: crawlData.total || 0, - creditsUsed: crawlData.creditsUsed || 0, + // Forwarded as-is: defaulting a missing count to 0 would look like + // a free crawl to the hosted-key pricing helper instead of the + // metering failure it is. + creditsUsed: crawlData.creditsUsed, } return result } diff --git a/apps/sim/tools/firecrawl/extract.ts b/apps/sim/tools/firecrawl/extract.ts index c51c19d1886..fd9c71e6a2d 100644 --- a/apps/sim/tools/firecrawl/extract.ts +++ b/apps/sim/tools/firecrawl/extract.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ExtractParams, ExtractResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -80,33 +81,7 @@ export const extractTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/hosting.test.ts b/apps/sim/tools/firecrawl/hosting.test.ts new file mode 100644 index 00000000000..6229f671be5 --- /dev/null +++ b/apps/sim/tools/firecrawl/hosting.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { batchScrapeTool } from '@/tools/firecrawl/batch-scrape' +import { crawlTool } from '@/tools/firecrawl/crawl' +import { extractTool } from '@/tools/firecrawl/extract' +import { FIRECRAWL_CREDIT_USD } from '@/tools/firecrawl/hosting' +import { mapTool } from '@/tools/firecrawl/map' +import { parseTool } from '@/tools/firecrawl/parse' +import { scrapeTool } from '@/tools/firecrawl/scrape' +import { searchTool } from '@/tools/firecrawl/search' + +/** The slice of a tool config this suite exercises, free of each tool's param type. */ +interface HostedTool { + hosting?: { + envKeyPrefix: string + apiKeyParam: string + byokProviderId?: string + rateLimit: { mode: string } + pricing: + | { type: 'per_request'; cost: number } + | { + type: 'custom' + getCost: ( + params: never, + output: Record + ) => number | { cost: number; metadata?: Record } + } + } +} + +const HOSTED_TOOLS: Array<[string, HostedTool]> = [ + ['scrape', scrapeTool], + ['search', searchTool], + ['crawl', crawlTool], + ['map', mapTool], + ['extract', extractTool], + ['parse', parseTool], + ['batchScrape', batchScrapeTool], +] + +function getCost(tool: HostedTool, output: Record) { + const pricing = tool.hosting?.pricing + if (pricing?.type !== 'custom') throw new Error('expected custom pricing') + return pricing.getCost(undefined as never, output) +} + +describe('firecrawl hosted-key config', () => { + it.each(HOSTED_TOOLS)('%s resolves a complete hosting config', (_name, tool) => { + expect(tool.hosting).toMatchObject({ + envKeyPrefix: 'FIRECRAWL_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'firecrawl', + rateLimit: { mode: 'per_request', requestsPerMinute: 100 }, + }) + expect(tool.hosting?.pricing.type).toBe('custom') + }) + + it('bills credits reported on the response envelope', () => { + expect(getCost(searchTool, { creditsUsed: 4 })).toEqual({ + cost: 4 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 4 }, + }) + }) + + it('bills credits reported on the returned document metadata', () => { + // Firecrawl reports per-page usage on the document rather than the + // envelope; reading only the envelope left every scrape and parse unbilled. + expect(getCost(scrapeTool, { metadata: { creditsUsed: 5 } })).toEqual({ + cost: 5 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 5 }, + }) + + expect(getCost(parseTool, { metadata: { creditsUsed: 12 } })).toEqual({ + cost: 12 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 12 }, + }) + }) + + it.each(HOSTED_TOOLS)('%s rejects a response with no usable credit count', (_name, tool) => { + expect(() => getCost(tool, {})).toThrow(/creditsUsed/) + expect(() => getCost(tool, { creditsUsed: 'many' })).toThrow(/non-numeric/) + }) +}) diff --git a/apps/sim/tools/firecrawl/hosting.ts b/apps/sim/tools/firecrawl/hosting.ts new file mode 100644 index 00000000000..fc8c5d0559b --- /dev/null +++ b/apps/sim/tools/firecrawl/hosting.ts @@ -0,0 +1,68 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** Env var prefix for Firecrawl hosted keys. */ +export const FIRECRAWL_API_KEY_PREFIX = 'FIRECRAWL_API_KEY' + +/** + * Dollar cost of a single Firecrawl credit on Sim's hosted plan. + * + * Firecrawl is subscription-priced rather than pay-as-you-go, so this is the + * effective per-credit rate of the plan the hosted keys belong to. + * + * Source: https://www.firecrawl.dev/pricing + */ +export const FIRECRAWL_CREDIT_USD = 0.001 + +/** + * Reads the credits a Firecrawl response reported. + * + * Firecrawl reports usage in two documented places: job- and search-style + * endpoints put `creditsUsed` on the response envelope, while per-page + * endpoints put it on the returned document's metadata. Both are checked so a + * tool can never be wired to the wrong one. + * + * Source: https://docs.firecrawl.dev/billing + */ +function readReportedCredits(output: Record): unknown { + const fromEnvelope = output.creditsUsed + if (fromEnvelope != null) return fromEnvelope + return (output.metadata as { creditsUsed?: unknown } | undefined)?.creditsUsed +} + +/** + * Builds the Firecrawl `hosting` config shared by every Firecrawl tool. + * + * All Firecrawl operations are usage-priced — option modifiers, page counts, + * and result bands each change the credit count — so the charge always comes + * from the credits the API reported rather than a fixed per-request rate. + */ +export function firecrawlHosting

(): ToolHostingConfig

{ + return { + envKeyPrefix: FIRECRAWL_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'firecrawl', + pricing: { + type: 'custom', + getCost: (_params, output) => { + const reported = readReportedCredits(output) + if (reported == null) { + throw new Error('Firecrawl response missing creditsUsed field') + } + + const creditsUsed = Number(reported) + if (!Number.isFinite(creditsUsed)) { + throw new Error('Firecrawl response returned a non-numeric creditsUsed field') + } + + return { + cost: creditsUsed * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed }, + } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 100, + }, + } +} diff --git a/apps/sim/tools/firecrawl/map.ts b/apps/sim/tools/firecrawl/map.ts index e5a507556e2..1fd8a6a0376 100644 --- a/apps/sim/tools/firecrawl/map.ts +++ b/apps/sim/tools/firecrawl/map.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { MapParams, MapResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -66,33 +67,7 @@ export const mapTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/parse.ts b/apps/sim/tools/firecrawl/parse.ts index 756c53b36a3..a19acebfb9c 100644 --- a/apps/sim/tools/firecrawl/parse.ts +++ b/apps/sim/tools/firecrawl/parse.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ParseParams, ParseResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -83,33 +84,7 @@ export const parseTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - const creditsUsed = (output.metadata as { creditsUsed?: number })?.creditsUsed - if (creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', @@ -168,6 +143,7 @@ export const parseTool: ToolConfig = { links: result.links ?? [], metadata: result.metadata ?? null, warning: result.warning ?? null, + creditsUsed: result.creditsUsed, }, } }, diff --git a/apps/sim/tools/firecrawl/scrape.ts b/apps/sim/tools/firecrawl/scrape.ts index bdd8ab9d6f4..8246bd770f6 100644 --- a/apps/sim/tools/firecrawl/scrape.ts +++ b/apps/sim/tools/firecrawl/scrape.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ScrapeParams, ScrapeResponse } from '@/tools/firecrawl/types' import { PAGE_METADATA_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types' import { safeAssign } from '@/tools/safe-assign' @@ -31,33 +32,7 @@ export const scrapeTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - const creditsUsed = (output.metadata as { creditsUsed?: number })?.creditsUsed - if (creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/search.ts b/apps/sim/tools/firecrawl/search.ts index 38f93955a23..b5cae84bc50 100644 --- a/apps/sim/tools/firecrawl/search.ts +++ b/apps/sim/tools/firecrawl/search.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { SearchParams, SearchResponse } from '@/tools/firecrawl/types' import { SEARCH_RESULT_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -23,33 +24,7 @@ export const searchTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/google_maps/distance_matrix.ts b/apps/sim/tools/google_maps/distance_matrix.ts index 19f981e8f4d..9b3b3f23f97 100644 --- a/apps/sim/tools/google_maps/distance_matrix.ts +++ b/apps/sim/tools/google_maps/distance_matrix.ts @@ -4,6 +4,14 @@ import type { } from '@/tools/google_maps/types' import type { ToolConfig } from '@/tools/types' +/** + * Google bills Distance Matrix per element (origins × destinations), not per + * request, at $5 per 1,000 elements. + * + * Source: https://developers.google.com/maps/billing-and-pricing/pricing#distance-matrix + */ +const DISTANCE_MATRIX_ELEMENT_USD = 0.005 + export const googleMapsDistanceMatrixTool: ToolConfig< GoogleMapsDistanceMatrixParams, GoogleMapsDistanceMatrixResponse @@ -63,8 +71,15 @@ export const googleMapsDistanceMatrixTool: ToolConfig< apiKeyParam: 'apiKey', byokProviderId: 'google_cloud', pricing: { - type: 'per_request', - cost: 0.005, + type: 'custom', + getCost: (_params, output) => { + const rows = (output.rows as Array<{ elements?: unknown[] }> | undefined) ?? [] + const elements = rows.reduce((total, row) => total + (row.elements?.length ?? 0), 0) + return { + cost: elements * DISTANCE_MATRIX_ELEMENT_USD, + metadata: { elements }, + } + }, }, rateLimit: { mode: 'per_request', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index c4fb4775447..62af30c8e1a 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -580,24 +580,37 @@ interface ToolCostResult { metadata?: Record } +/** + * Rejects a cost that cannot be billed. `NaN` would silently vanish from every + * downstream sum and `Infinity` would poison the ledger, so a pricing bug must + * surface as a metering failure instead of a corrupt charge. + */ +function assertBillableCost(cost: unknown, toolId: string): number { + if (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0) { + throw new Error(`Hosted-key pricing for ${toolId} produced an unusable cost: ${String(cost)}`) + } + return cost +} + /** * Calculate cost based on pricing model */ function calculateToolCost( pricing: ToolHostingPricing, params: Record, - response: Record + response: Record, + toolId: string ): ToolCostResult { switch (pricing.type) { case 'per_request': - return { cost: pricing.cost } + return { cost: assertBillableCost(pricing.cost, toolId) } case 'custom': { const result = pricing.getCost(params, response) if (typeof result === 'number') { - return { cost: result } + return { cost: assertBillableCost(result, toolId) } } - return result + return { ...result, cost: assertBillableCost(result.cost, toolId) } } default: { @@ -627,7 +640,7 @@ async function processHostedKeyCost( return { cost: 0 } } - const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response) + const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response, tool.id) if (cost <= 0) return { cost: 0 } @@ -730,16 +743,31 @@ async function applyHostedKeyCostToResult( ): Promise { await reportCustomDimensionUsage(tool, params, finalResult.output, executionContext, requestId) - const { cost: hostedKeyCost, metadata } = await processHostedKeyCost( - tool, - params, - finalResult.output, - executionContext, - requestId - ) - const provider = tool.hosting?.byokProviderId || tool.id const key = envVarName ?? 'unknown' + + let hostedKeyCost = 0 + let metadata: Record | undefined + + try { + ;({ cost: hostedKeyCost, metadata } = await processHostedKeyCost( + tool, + params, + finalResult.output, + executionContext, + requestId + )) + } catch (error) { + // The provider already ran and already charged Sim's key. Failing the + // execution here would destroy the caller's result without recovering the + // spend, so the run stands and the gap is raised for reconciliation. + logger.error( + `[${requestId}] Hosted-key metering failed for ${tool.id}; execution succeeded unbilled`, + { provider, error: getErrorMessage(error) } + ) + hostedKeyMetrics.recordFailed({ provider, tool: tool.id, key, reason: 'metering' }) + } + hostedKeyMetrics.recordUsed({ provider, tool: tool.id, key }) hostedKeyMetrics.recordCostCharged(hostedKeyCost, { provider, tool: tool.id }) diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index a1d9b3f76a4..e06daade15f 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { type ModelCost, resolveProxiedModelCost } from '@/providers/cost-policy' import { getProviderFromModel } from '@/providers/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' @@ -34,6 +35,7 @@ interface LLMChatResponse extends ToolResponse { completion?: number total?: number } + cost?: ModelCost } } @@ -175,6 +177,10 @@ export const llmChatTool: ToolConfig = { content: data.content, model: data.model, tokens: data.tokens, + // The provider proxy already applied the billing policy. Dropping its + // cost here would leave blocks built on this tool reporting tokens + // with no charge. + cost: resolveProxiedModelCost(data.cost), }, } }, @@ -183,5 +189,6 @@ export const llmChatTool: ToolConfig = { content: { type: 'string', description: 'The generated response content' }, model: { type: 'string', description: 'The model used for generation' }, tokens: { type: 'object', description: 'Token usage information' }, + cost: { type: 'object', description: 'Model cost for this call in dollars' }, }, } diff --git a/apps/sim/tools/parallel/deep_research.ts b/apps/sim/tools/parallel/deep_research.ts index cd7dac4b9eb..628d72a0c2b 100644 --- a/apps/sim/tools/parallel/deep_research.ts +++ b/apps/sim/tools/parallel/deep_research.ts @@ -6,6 +6,42 @@ import type { ToolConfig, ToolResponse } from '@/tools/types' const logger = createLogger('ParallelDeepResearchTool') +/** + * Dollar cost of one Parallel Task run per processor tier. + * + * Fast variants are priced identically to their standard counterparts, so both + * spellings are listed — the block exposes `pro-fast` and `ultra-fast`, and an + * unlisted tier would silently fall back to the cheapest rate. + * + * Source: https://docs.parallel.ai/getting-started/pricing + */ +const PROCESSOR_COST_USD: Record = { + lite: 0.005, + 'lite-fast': 0.005, + base: 0.01, + 'base-fast': 0.01, + core: 0.025, + 'core-fast': 0.025, + core2x: 0.05, + 'core2x-fast': 0.05, + pro: 0.1, + 'pro-fast': 0.1, + ultra: 0.3, + 'ultra-fast': 0.3, + ultra2x: 0.6, + 'ultra2x-fast': 0.6, + ultra4x: 1.2, + 'ultra4x-fast': 1.2, + ultra8x: 2.4, + 'ultra8x-fast': 2.4, +} + +/** + * Tier used when the caller does not pick one. Shared by the request body and + * the cost calculation so the tier Sim asks for is always the tier it charges. + */ +const DEFAULT_PROCESSOR = 'pro' + export const deepResearchTool: ToolConfig = { id: 'parallel_deep_research', name: 'Parallel AI Deep Research', @@ -20,34 +56,21 @@ export const deepResearchTool: ToolConfig { - // Parallel Task API: cost varies by processor - // https://docs.parallel.ai/resources/pricing - const processorCosts: Record = { - lite: 0.005, - base: 0.01, - core: 0.025, - core2x: 0.05, - pro: 0.1, - ultra: 0.3, - ultra2x: 0.6, - ultra4x: 1.2, - ultra8x: 2.4, - } - const processor = (params.processor as string) || 'base' - const DEFAULT_PROCESSOR_COST = processorCosts.base - const knownCost = processorCosts[processor] + const processor = (params.processor as string) || DEFAULT_PROCESSOR + const fallbackCost = PROCESSOR_COST_USD[DEFAULT_PROCESSOR] + const knownCost = PROCESSOR_COST_USD[processor] if (knownCost == null) { logger.warn( - `Unknown Parallel processor "${processor}", using default processor cost $${DEFAULT_PROCESSOR_COST}` + `Unknown Parallel processor "${processor}", using default processor cost $${fallbackCost}` ) PlatformEvents.hostedKeyUnknownModelCost({ toolId: 'parallel_deep_research', modelName: processor, - defaultCost: DEFAULT_PROCESSOR_COST, + defaultCost: fallbackCost, }) } - const cost = knownCost ?? DEFAULT_PROCESSOR_COST - return { cost, metadata: { processor, defaultProcessorCost: DEFAULT_PROCESSOR_COST } } + const cost = knownCost ?? fallbackCost + return { cost, metadata: { processor, defaultProcessorCost: fallbackCost } } }, }, rateLimit: { @@ -67,7 +90,7 @@ export const deepResearchTool: ToolConfig { const body: Record = { input: params.input, - processor: params.processor || 'pro', + processor: params.processor || DEFAULT_PROCESSOR, task_spec: { output_schema: 'auto', }, diff --git a/packages/db/migrations/0269_late_spencer_smythe.sql b/packages/db/migrations/0269_late_spencer_smythe.sql new file mode 100644 index 00000000000..a763e805791 --- /dev/null +++ b/packages/db/migrations/0269_late_spencer_smythe.sql @@ -0,0 +1 @@ +ALTER TABLE "chat" ADD COLUMN "include_tool_calls" boolean; \ No newline at end of file diff --git a/packages/db/migrations/meta/0269_snapshot.json b/packages/db/migrations/meta/0269_snapshot.json new file mode 100644 index 00000000000..c0dd5014352 --- /dev/null +++ b/packages/db/migrations/meta/0269_snapshot.json @@ -0,0 +1,17692 @@ +{ + "id": "dea711e3-b8c7-4279-bb8a-353bdaffdfe8", + "prevId": "e341f9c4-ac9a-4386-b6a6-4dfb31b952dd", + "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": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": 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": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "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": { + "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 + }, + "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_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": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "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": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS 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.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 + }, + "inputs": { + "name": "inputs", + "type": "json", + "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 + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_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 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_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.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "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": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "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 + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "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": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "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": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "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": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "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 + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "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_operation": { + "name": "workflow_deployment_operation", + "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": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_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": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "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": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "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 + }, + "deployment_operation_id": { + "name": "deployment_operation_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_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "organization_assigned_at": { + "name": "organization_assigned_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": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "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_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", + "workflow_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 c9e4db84853..cde0fe20a26 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1877,6 +1877,13 @@ "when": 1784861741441, "tag": "0268_sso_domain_verification", "breakpoints": true + }, + { + "idx": 269, + "version": "7", + "when": 1784911198876, + "tag": "0269_late_spencer_smythe", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 5f67654c9fd..a875f02059c 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1158,11 +1158,19 @@ export const chat = pgTable( outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects /** - * When true, public chat SSE may expose provider thinking/tool events if the + * When true, public chat SSE may expose provider thinking events if the * client also opts in via `X-Sim-Stream-Protocol: agent-events-v1`. * Default off — never derived from auth type or isSecureMode. */ includeThinking: boolean('include_thinking').notNull().default(false), + /** + * When true, public chat SSE may expose tool lifecycle events if the client + * also opts in via `X-Sim-Stream-Protocol: agent-events-v1`. + * + * Null preserves the pre-expand policy: readers fall back to includeThinking. + */ + // contract-pending(after the includeToolCalls expand release is fully deployed): backfill include_tool_calls from include_thinking, then set DEFAULT false and NOT NULL — all new-app chat writes persist an explicit value + includeToolCalls: boolean('include_tool_calls'), archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 7adad24619b..504c7bf3a94 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -449,6 +449,7 @@ export const schemaMock = { allowedEmails: 'allowedEmails', outputConfigs: 'outputConfigs', includeThinking: 'includeThinking', + includeToolCalls: 'includeToolCalls', archivedAt: 'archivedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', diff --git a/scripts/sync-agent-stream-docs.ts b/scripts/sync-agent-stream-docs.ts index 4936ade9dda..db11482dd99 100644 --- a/scripts/sync-agent-stream-docs.ts +++ b/scripts/sync-agent-stream-docs.ts @@ -121,7 +121,7 @@ function buildGeneratedContent(): { content: string; errors: string[] } { const lines: string[] = [] lines.push('') lines.push( - `Live tool-call chips stream for **${liveToolProviders.join(', ')}** models. Other providers run tools without live chips — tool results still appear in the block output when the run completes.` + `Live tool-call chips stream for **${liveToolProviders.join(', ')}** models. Other providers run tools without live chips and project the settled final answer when the run completes; they do not ask the model to regenerate that answer just to create a stream.` ) lines.push('') lines.push('| Provider | Streamed thinking | Models |') From d64739cf4fb69d6e12e5a223c07044ea6516054d Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 24 Jul 2026 16:12:22 -0700 Subject: [PATCH 29/32] fix(ci): unblock @next/swc, lockfile-keyed node_modules, per-image runner sizing (#5945) * fix(ci): key node_modules sticky disk on the lockfile hash * improvement(ci): per-image Blacksmith runner sizing + cold-build memory preflight * fix(deps): exclude @next/swc binaries from the release-age gate * fix(deps): pin @next/swc binaries so frozen installs get a compiler * docs(ci): explain ARM runner sizing rationale --- .github/workflows/ci.yml | 25 ++++++++++++-- .github/workflows/docs-embeddings.yml | 3 +- .github/workflows/test-build.yml | 49 +++++++++++++++++++-------- bun.lock | 14 ++++++++ bunfig.toml | 16 ++++++--- package.json | 6 ++++ 6 files changed, 90 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a18541e5751..0d0c66e6b72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: name: Build Dev ECR needs: [detect-version, migrate-dev] if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || matrix.gh_runner }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 permissions: contents: read @@ -115,18 +115,25 @@ jobs: include: # Only the app image needs the paid 8-core/32 GB runner: next build # exhausts the free 16 GB one (exit 137). The others build in <5 min. + # bs_runner mirrors that per-image sizing on Blacksmith — a single + # pinned tier put every image on 8 vCPU, where the non-app builds idle + # at 12-15% CPU and under 10% memory. - dockerfile: ./docker/app.Dockerfile ecr_repo_secret: ECR_APP gh_runner: linux-x64-8-core + bs_runner: blacksmith-8vcpu-ubuntu-2404 - dockerfile: ./docker/db.Dockerfile ecr_repo_secret: ECR_MIGRATIONS gh_runner: ubuntu-latest + bs_runner: blacksmith-2vcpu-ubuntu-2404 - dockerfile: ./docker/realtime.Dockerfile ecr_repo_secret: ECR_REALTIME gh_runner: ubuntu-latest + bs_runner: blacksmith-4vcpu-ubuntu-2404 - dockerfile: ./docker/pii.Dockerfile ecr_repo_secret: ECR_PII gh_runner: ubuntu-latest + bs_runner: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -215,7 +222,7 @@ jobs: if: >- github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging') - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || matrix.gh_runner }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 permissions: contents: read @@ -229,18 +236,22 @@ jobs: ghcr_image: ghcr.io/simstudioai/simstudio ecr_repo_secret: ECR_APP gh_runner: linux-x64-8-core + bs_runner: blacksmith-8vcpu-ubuntu-2404 - dockerfile: ./docker/db.Dockerfile ghcr_image: ghcr.io/simstudioai/migrations ecr_repo_secret: ECR_MIGRATIONS gh_runner: ubuntu-latest + bs_runner: blacksmith-2vcpu-ubuntu-2404 - dockerfile: ./docker/realtime.Dockerfile ghcr_image: ghcr.io/simstudioai/realtime ecr_repo_secret: ECR_REALTIME gh_runner: ubuntu-latest + bs_runner: blacksmith-4vcpu-ubuntu-2404 - dockerfile: ./docker/pii.Dockerfile ghcr_image: ghcr.io/simstudioai/pii ecr_repo_secret: ECR_PII gh_runner: ubuntu-latest + bs_runner: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -387,7 +398,7 @@ jobs: # never moves a documented tag. build-ghcr-arm64: name: Build ARM64 (GHCR Only) - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404-arm' || matrix.gh_runner }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || matrix.gh_runner }} timeout-minutes: 30 if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: @@ -396,19 +407,27 @@ jobs: strategy: fail-fast: false matrix: + # Non-app images sit at 4 vCPU rather than the finer x64 split: the ARM + # sizing data is job-level (8 -> 4 for the whole matrix), not per-image, + # and this job only runs on push to main — an unprovisioned label would + # hang a release in `queued` rather than fail a PR. include: - dockerfile: ./docker/app.Dockerfile image: ghcr.io/simstudioai/simstudio gh_runner: linux-arm64-8-core + bs_runner: blacksmith-8vcpu-ubuntu-2404-arm - dockerfile: ./docker/db.Dockerfile image: ghcr.io/simstudioai/migrations gh_runner: ubuntu-24.04-arm + bs_runner: blacksmith-4vcpu-ubuntu-2404-arm - dockerfile: ./docker/realtime.Dockerfile image: ghcr.io/simstudioai/realtime gh_runner: ubuntu-24.04-arm + bs_runner: blacksmith-4vcpu-ubuntu-2404-arm - dockerfile: ./docker/pii.Dockerfile image: ghcr.io/simstudioai/pii gh_runner: ubuntu-24.04-arm + bs_runner: blacksmith-4vcpu-ubuntu-2404-arm steps: - name: Checkout code diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index b1eb9a28f49..f67ad2ea61c 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -10,7 +10,8 @@ permissions: jobs: process-docs-embeddings: name: Process Documentation Embeddings - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} + # Network-bound on the embeddings API: ~9% CPU and ~3% peak memory on 8 vCPU. + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 127815cfb25..1d1fa2d2f62 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -31,6 +31,13 @@ jobs: # namespace on top: untrusted fork runs must never share a cache with # push runs (whose caches feed production image builds) or with trusted # internal-PR runs. + # + # node_modules also keys on the lockfile hash: a sticky disk is a mutable + # volume, and `bun install --frozen-lockfile` adds what the lockfile needs + # without pruning what it dropped, so branches on different lockfiles were + # contaminating each other (a stale @next/swc 16.2.6 outlived the 16.2.11 + # bump). The bun and Turbo caches are content/hash-addressed, so they stay + # shared — that is what keeps a fresh node_modules disk cheap to fill. - name: Mount Bun cache uses: ./.github/actions/cache-mount with: @@ -42,7 +49,7 @@ jobs: uses: ./.github/actions/cache-mount with: provider: ${{ vars.CI_PROVIDER }} - key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} + key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ hashFiles('bun.lock') }} path: ./node_modules - name: Mount Turbo cache @@ -193,19 +200,16 @@ jobs: # Next.js production build, in parallel with lint + tests. Sticky disks are # cloned from the last committed snapshot per job and committed last-writer- # wins, so concurrent mounts are safe. The bun/node_modules disks are shared - # with test-build (identical content from the same lockfile — LWW loss is - # harmless), but the Turbo cache gets its own key: with a shared key, only - # the last committer's new entries survive each run, so the test and build - # Turbo entries would evict each other nondeterministically. - # Same next build as the app image, so it needs a large runner. Peak RSS - # tracks Turbo/Turbopack cache warmth, and it is the COLD case that sizes the - # runner: warm peaks ~12 GB, partial ~28 GB, and a cold full rebuild peaked - # 51 GB. The 8vcpu tier only has 30.4 GB, so cold-cache runs OOM-killed the VM - # (oom_count 1, memory p100 ~30.5 GB, ~99% of the tier) — the job burned 8-15 - # min and died, while warm runs finished under 8 min. NODE_OPTIONS' - # --max-old-space-size only caps Node's JS heap; the bulk is native Turbopack - # worker memory, so the cap cannot prevent this. 16vcpu = 60.8 GB fits the - # 51 GB cold peak with headroom. Keep the test job on 8vcpu; its memory is fine. + # with test-build (the lockfile-hashed key means they only ever share when the + # dependency tree really is identical, so LWW loss is harmless), but the Turbo + # cache gets its own key: with a shared key, only the last committer's new + # entries survive each run, so the test and build Turbo entries would evict + # each other nondeterministically. + # + # Runner is sized for the COLD-cache build, which is what OOM-killed the 8vcpu + # tier (23 kills / 1074 runs at 98% of its 30.4 GB): warm peaks ~12 GB, cold + # peaked 51 GB. NODE_OPTIONS' --max-old-space-size caps only Node's JS heap, + # not the native Turbopack workers that dominate, so it cannot prevent this. build: name: Build App runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }} @@ -236,7 +240,7 @@ jobs: uses: ./.github/actions/cache-mount with: provider: ${{ vars.CI_PROVIDER }} - key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} + key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ hashFiles('bun.lock') }} path: ./node_modules - name: Mount Turbo cache @@ -258,6 +262,21 @@ jobs: key: ${{ github.repository }}-nextjs-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./apps/sim/.next/cache + # Running out of RAM kills the whole VM and surfaces only as "the runner + # has received a shutdown signal" — no mention of memory, ~12 min in. Warn + # with the real numbers so that failure is a one-line diagnosis instead of + # a mystery. Warn, never fail: a warm build peaks ~12 GB and a partial one + # ~28 GB, so a 32 GB runner still completes plenty of builds, and the + # GitHub fallback is the break-glass path — degrading it to a guaranteed + # failure would be worse than the risk this flags. + - name: Check runner memory headroom + run: | + TOTAL_GB=$(awk '/MemTotal/ {printf "%d", $2/1048576}' /proc/meminfo) + echo "Runner memory: ${TOTAL_GB} GB" + if [ "$TOTAL_GB" -lt 40 ]; then + echo "::warning::Runner has ${TOTAL_GB} GB. A cold-cache build peaks ~51 GB, so this run may be OOM-killed (reported only as 'the runner has received a shutdown signal'). Warm/partial builds should still fit." + fi + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/bun.lock b/bun.lock index 272fa46bc3b..945b4faf210 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,12 @@ "turbo": "2.9.14", "yaml": "^2.8.1", }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.11", + "@next/swc-darwin-x64": "16.2.11", + "@next/swc-linux-arm64-gnu": "16.2.11", + "@next/swc-linux-x64-gnu": "16.2.11", + }, }, "apps/docs": { "name": "docs", @@ -1230,6 +1236,14 @@ "@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w=="], + "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], diff --git a/bunfig.toml b/bunfig.toml index 70e95c399d7..74e468cd7e8 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -7,10 +7,14 @@ minimumReleaseAge = 604800 # dev builds, so every version is structurally younger than any age gate. # The exactly pinned Pi 0.80.10 packages were vetted for the cloud-review SDK # migration; they age out of the gate on 2026-07-24 — drop these four entries then. -# next@16.2.11 and @next/env@16.2.11 are the official Vercel security patch for the -# July 2026 advisories (SSRF, cache confusion, DoS, middleware bypass — GHSA-89xv-2m56-2m9x -# et al.); published 2026-07-21, they age out of the gate on 2026-07-28 — drop these two -# entries then. +# next@16.2.11, @next/env@16.2.11 and the @next/swc-* binaries are the official Vercel +# security patch for the July 2026 advisories (SSRF, cache confusion, DoS, middleware +# bypass — GHSA-89xv-2m56-2m9x et al.); published 2026-07-21, they age out of the gate on +# 2026-07-28 — drop these entries then. The swc binaries ship in lockstep with next and +# MUST be excluded alongside it: they are next's platform-gated optionalDependencies, so +# gating them out leaves them absent from bun.lock entirely, and `bun install +# --frozen-lockfile` then installs no compiler at all — next falls back to downloading one +# at build time, which fails in CI. # @anthropic-ai/sdk is exactly pinned to 0.114.0, vetted for the agent-events # streaming work (adaptive thinking display types + transform-json-schema); # published 2026-07-23, it ages out of the gate on 2026-07-30 — drop this entry then. @@ -22,6 +26,10 @@ minimumReleaseAgeExcludes = [ "@earendil-works/pi-tui", "next", "@next/env", + "@next/swc-darwin-arm64", + "@next/swc-darwin-x64", + "@next/swc-linux-arm64-gnu", + "@next/swc-linux-x64-gnu", "@anthropic-ai/sdk", ] diff --git a/package.json b/package.json index a25f2333a94..1689f6e38d5 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,12 @@ "mermaid": "11.15.0", "zod": "4.3.6" }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.11", + "@next/swc-darwin-x64": "16.2.11", + "@next/swc-linux-arm64-gnu": "16.2.11", + "@next/swc-linux-x64-gnu": "16.2.11" + }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", "@octokit/rest": "^21.0.0", From fe184d369583536c7fdef8fb73600c5fd5198fc3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 24 Jul 2026 16:14:27 -0700 Subject: [PATCH 30/32] improvement(whatsapp): validate + improve integration skill for file inputs/outputs (#5942) * improvement(whatsapp): validate + improve integration skill for file inputs/outputs * fix lint * add whatsapp subblock migration --- .agents/skills/add-block/SKILL.md | 82 ++- .agents/skills/add-integration/SKILL.md | 15 +- .claude/commands/add-block.md | 82 ++- .claude/commands/add-integration.md | 15 +- .claude/rules/sim-integrations.md | 3 +- .cursor/commands/add-block.md | 82 ++- .cursor/commands/add-integration.md | 15 +- .../content/docs/en/integrations/whatsapp.mdx | 166 +++++- .../app/api/tools/whatsapp/get-media/route.ts | 217 +++++++ .../api/tools/whatsapp/send-media/route.ts | 125 ++++ .../api/tools/whatsapp/upload-media/route.ts | 73 +++ .../app/api/tools/whatsapp/upload.server.ts | 148 +++++ apps/sim/blocks/blocks/buffer.ts | 42 +- apps/sim/blocks/blocks/elevenlabs.ts | 12 + apps/sim/blocks/blocks/telegram.test.ts | 85 +++ apps/sim/blocks/blocks/telegram.ts | 179 ++++-- apps/sim/blocks/blocks/whatsapp.ts | 372 ++++++++++-- apps/sim/lib/api/contracts/tools/whatsapp.ts | 136 +++++ .../lib/webhooks/providers/whatsapp.test.ts | 84 +++ apps/sim/lib/webhooks/providers/whatsapp.ts | 43 ++ .../whatsapp-interactive-type.test.ts | 201 +++++++ .../migrations/whatsapp-interactive-type.ts | 91 +++ apps/sim/lib/workflows/persistence/utils.ts | 11 +- apps/sim/tools/registry.ts | 4 + apps/sim/tools/whatsapp/get_media.ts | 77 +++ apps/sim/tools/whatsapp/index.ts | 2 + apps/sim/tools/whatsapp/mark_read.ts | 35 +- apps/sim/tools/whatsapp/send_interactive.ts | 14 +- apps/sim/tools/whatsapp/send_media.ts | 96 ++-- apps/sim/tools/whatsapp/send_message.ts | 121 +--- apps/sim/tools/whatsapp/send_reaction.ts | 3 +- apps/sim/tools/whatsapp/send_template.ts | 2 +- apps/sim/tools/whatsapp/types.ts | 44 +- apps/sim/tools/whatsapp/upload_media.ts | 61 ++ apps/sim/tools/whatsapp/utils.ts | 138 ++++- apps/sim/tools/whatsapp/whatsapp.test.ts | 535 ++++++++++++++++++ apps/sim/triggers/whatsapp/webhook.ts | 15 +- scripts/check-api-validation-contracts.ts | 4 +- 38 files changed, 3015 insertions(+), 415 deletions(-) create mode 100644 apps/sim/app/api/tools/whatsapp/get-media/route.ts create mode 100644 apps/sim/app/api/tools/whatsapp/send-media/route.ts create mode 100644 apps/sim/app/api/tools/whatsapp/upload-media/route.ts create mode 100644 apps/sim/app/api/tools/whatsapp/upload.server.ts create mode 100644 apps/sim/blocks/blocks/telegram.test.ts create mode 100644 apps/sim/lib/api/contracts/tools/whatsapp.ts create mode 100644 apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts create mode 100644 apps/sim/lib/workflows/migrations/whatsapp-interactive-type.ts create mode 100644 apps/sim/tools/whatsapp/get_media.ts create mode 100644 apps/sim/tools/whatsapp/upload_media.ts create mode 100644 apps/sim/tools/whatsapp/whatsapp.test.ts diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index eaf22ab3ed1..695975eba2b 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -257,13 +257,43 @@ When your block accepts file uploads, use the basic/advanced mode pattern with ` }, ``` +**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to +a file from a previous block. Gmail attachments are the reference implementation +(`apps/sim/blocks/blocks/gmail.ts` — `attachmentFiles` / `attachments`). + +Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a +path). A subblock whose meaning changes based on what the string looks like is impossible to reason +about, forces the params function to sniff the value, and makes the field's type meaningless. Give +each alternative its own subblock outside the pair: + +```typescript +// ✓ Good — the pair is "a file"; other sources are their own fields +{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' }, +{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' }, +{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept +{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept + +// ✗ Bad — one field meaning three things, resolved by guessing +{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced', + placeholder: 'File reference, media ID, or public URL' }, +``` + +When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce +"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the +other paths ever get a chance to supply the value. + **Critical constraints:** - `canonicalParamId` must NOT match any subblock's `id` in the same block -- Values are stored under subblock `id`, not `canonicalParamId` +- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by + `canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations + that each need a file pair need two distinct `canonicalParamId` values. +- All members of a group must share the same `required` status ### Normalizing File Input in tools.config -Use `normalizeFileInput` to handle all input variants: +Put the normalization in `tools.config.params`, never in `tools.config.tool` — `tool` runs at +serialization, before variable resolution, so a `` file reference is not yet a value +there. ```typescript import { normalizeFileInput } from '@/blocks/utils' @@ -271,34 +301,50 @@ import { normalizeFileInput } from '@/blocks/utils' tools: { access: ['service_upload'], config: { - tool: (params) => { - // Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile + tool: (params) => `service_${params.operation}`, + params: (params) => { + // Read the CANONICAL id, not the subblock ids + const { file: fileParam, ...rest } = params + const file = normalizeFileInput(fileParam, { single: true }) + return { + ...rest, + ...(file ? { file } : {}), } - return `service_${params.operation}` }, }, } ``` -**Why this pattern?** -- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs) -- `canonicalParamId` only controls UI/schema mapping, not runtime values -- `normalizeFileInput` handles JSON strings from advanced mode template resolution +**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value, +but it is not what the params function receives. `extractBlockParams` +(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time: + +```typescript +const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) +sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted +if (chosen !== undefined) params[group.canonicalId] = chosen +``` + +So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`), +`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a +subblock id there yields `undefined` and silently sends no file. + +Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode +and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can +never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution +produces. + +Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so +omitting a key from the returned object does not strip it from what the tool receives. Tools simply +ignore params they do not declare. ### File Input Types in `inputs` -Use `type: 'json'` for file inputs: +Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`: ```typescript inputs: { - uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, - fileRef: { type: 'json', description: 'File reference from previous block' }, + file: { type: 'json', description: 'File to upload (UserFile or reference)' }, // Legacy field for backwards compatibility fileContent: { type: 'string', description: 'Legacy: base64 encoded content' }, } diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index 7014bd93aa6..84689e6be03 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -213,7 +213,7 @@ export const {Service}Block: BlockConfig = { ```typescript // Basic: Visual selector { - id: 'channel', + id: 'channelSelector', type: 'channel-selector', mode: 'basic', canonicalParamId: 'channel', @@ -228,10 +228,19 @@ export const {Service}Block: BlockConfig = { } ``` +Note neither subblock `id` is `channel` — the canonical id is a third name that both members map +onto, and it is the only one that survives serialization. + **Critical Canonical Param Rules:** - `canonicalParamId` must NOT match any subblock's `id` in the block -- `canonicalParamId` must be unique per operation/condition context -- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter +- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys + groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two + operations that each need their own pair must use two different canonical ids +- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter. + A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as + in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate + identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the + mutually exclusive sources `required: false`, and enforce "exactly one" at execution - `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent - Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions - **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes) diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 882da919fdc..600184d1067 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -256,13 +256,43 @@ When your block accepts file uploads, use the basic/advanced mode pattern with ` }, ``` +**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to +a file from a previous block. Gmail attachments are the reference implementation +(`apps/sim/blocks/blocks/gmail.ts` — `attachmentFiles` / `attachments`). + +Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a +path). A subblock whose meaning changes based on what the string looks like is impossible to reason +about, forces the params function to sniff the value, and makes the field's type meaningless. Give +each alternative its own subblock outside the pair: + +```typescript +// ✓ Good — the pair is "a file"; other sources are their own fields +{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' }, +{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' }, +{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept +{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept + +// ✗ Bad — one field meaning three things, resolved by guessing +{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced', + placeholder: 'File reference, media ID, or public URL' }, +``` + +When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce +"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the +other paths ever get a chance to supply the value. + **Critical constraints:** - `canonicalParamId` must NOT match any subblock's `id` in the same block -- Values are stored under subblock `id`, not `canonicalParamId` +- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by + `canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations + that each need a file pair need two distinct `canonicalParamId` values. +- All members of a group must share the same `required` status ### Normalizing File Input in tools.config -Use `normalizeFileInput` to handle all input variants: +Put the normalization in `tools.config.params`, never in `tools.config.tool` — `tool` runs at +serialization, before variable resolution, so a `` file reference is not yet a value +there. ```typescript import { normalizeFileInput } from '@/blocks/utils' @@ -270,34 +300,50 @@ import { normalizeFileInput } from '@/blocks/utils' tools: { access: ['service_upload'], config: { - tool: (params) => { - // Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile + tool: (params) => `service_${params.operation}`, + params: (params) => { + // Read the CANONICAL id, not the subblock ids + const { file: fileParam, ...rest } = params + const file = normalizeFileInput(fileParam, { single: true }) + return { + ...rest, + ...(file ? { file } : {}), } - return `service_${params.operation}` }, }, } ``` -**Why this pattern?** -- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs) -- `canonicalParamId` only controls UI/schema mapping, not runtime values -- `normalizeFileInput` handles JSON strings from advanced mode template resolution +**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value, +but it is not what the params function receives. `extractBlockParams` +(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time: + +```typescript +const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) +sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted +if (chosen !== undefined) params[group.canonicalId] = chosen +``` + +So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`), +`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a +subblock id there yields `undefined` and silently sends no file. + +Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode +and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can +never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution +produces. + +Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so +omitting a key from the returned object does not strip it from what the tool receives. Tools simply +ignore params they do not declare. ### File Input Types in `inputs` -Use `type: 'json'` for file inputs: +Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`: ```typescript inputs: { - uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, - fileRef: { type: 'json', description: 'File reference from previous block' }, + file: { type: 'json', description: 'File to upload (UserFile or reference)' }, // Legacy field for backwards compatibility fileContent: { type: 'string', description: 'Legacy: base64 encoded content' }, } diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index 8edb6627576..91218c3b139 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -212,7 +212,7 @@ export const {Service}Block: BlockConfig = { ```typescript // Basic: Visual selector { - id: 'channel', + id: 'channelSelector', type: 'channel-selector', mode: 'basic', canonicalParamId: 'channel', @@ -227,10 +227,19 @@ export const {Service}Block: BlockConfig = { } ``` +Note neither subblock `id` is `channel` — the canonical id is a third name that both members map +onto, and it is the only one that survives serialization. + **Critical Canonical Param Rules:** - `canonicalParamId` must NOT match any subblock's `id` in the block -- `canonicalParamId` must be unique per operation/condition context -- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter +- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys + groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two + operations that each need their own pair must use two different canonical ids +- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter. + A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as + in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate + identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the + mutually exclusive sources `required: false`, and enforce "exactly one" at execution - `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent - Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions - **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes) diff --git a/.claude/rules/sim-integrations.md b/.claude/rules/sim-integrations.md index 0eb3c8f6b4f..0ac54ab9194 100644 --- a/.claude/rules/sim-integrations.md +++ b/.claude/rules/sim-integrations.md @@ -15,5 +15,6 @@ The full authoring instructions — tool/block/icon/trigger scaffolding, SubBloc - Tool IDs are `snake_case` (`service_action`). Register tools in `tools/registry.ts`, blocks in `blocks/registry-maps.ts` (the `BLOCK_REGISTRY` config map + `BLOCK_META_REGISTRY` catalog-meta map, alphabetically — `blocks/registry.ts` holds only the accessor functions), triggers in `triggers/registry.ts`. - Type coercions (`Number()`, etc.) belong in `tools.config.params` (runs at execution, after variable resolution) — never in `tools.config.tool` (runs at serialization; coercing there destroys dynamic `` references). -- `canonicalParamId` must NOT match any subblock's `id`, must be unique per operation/condition context, and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs. +- `canonicalParamId` must NOT match any subblock's `id`, must be unique **block-wide** (groups are keyed by canonical id across every subblock and hold exactly one `basicId`, so two operations that each need a pair need two different canonical ids), and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs — the serializer deletes the subblock IDs and republishes the active member's value under the canonical ID. +- A canonical pair carries ONE concept. For files that is upload (basic) + file reference (advanced), as in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate identifiers (URL, provider asset ID) — give those their own subblocks, mark mutually exclusive sources `required: false`, and enforce "exactly one" at execution. - Blocks must also set the catalog/UI metadata fields `integrationType`, `tags`, `authMode`, `docsLink`, and export a `{Service}BlockMeta` — see the `/add-block` skill's BlockMeta section for details. diff --git a/.cursor/commands/add-block.md b/.cursor/commands/add-block.md index 875da1a0bdf..fbfe5b4c957 100644 --- a/.cursor/commands/add-block.md +++ b/.cursor/commands/add-block.md @@ -251,13 +251,43 @@ When your block accepts file uploads, use the basic/advanced mode pattern with ` }, ``` +**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to +a file from a previous block. Gmail attachments are the reference implementation +(`apps/sim/blocks/blocks/gmail.ts` — `attachmentFiles` / `attachments`). + +Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a +path). A subblock whose meaning changes based on what the string looks like is impossible to reason +about, forces the params function to sniff the value, and makes the field's type meaningless. Give +each alternative its own subblock outside the pair: + +```typescript +// ✓ Good — the pair is "a file"; other sources are their own fields +{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' }, +{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' }, +{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept +{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept + +// ✗ Bad — one field meaning three things, resolved by guessing +{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced', + placeholder: 'File reference, media ID, or public URL' }, +``` + +When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce +"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the +other paths ever get a chance to supply the value. + **Critical constraints:** - `canonicalParamId` must NOT match any subblock's `id` in the same block -- Values are stored under subblock `id`, not `canonicalParamId` +- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by + `canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations + that each need a file pair need two distinct `canonicalParamId` values. +- All members of a group must share the same `required` status ### Normalizing File Input in tools.config -Use `normalizeFileInput` to handle all input variants: +Put the normalization in `tools.config.params`, never in `tools.config.tool` — `tool` runs at +serialization, before variable resolution, so a `` file reference is not yet a value +there. ```typescript import { normalizeFileInput } from '@/blocks/utils' @@ -265,34 +295,50 @@ import { normalizeFileInput } from '@/blocks/utils' tools: { access: ['service_upload'], config: { - tool: (params) => { - // Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy) - const normalizedFile = normalizeFileInput( - params.uploadFile || params.fileRef || params.fileContent, - { single: true } - ) - if (normalizedFile) { - params.file = normalizedFile + tool: (params) => `service_${params.operation}`, + params: (params) => { + // Read the CANONICAL id, not the subblock ids + const { file: fileParam, ...rest } = params + const file = normalizeFileInput(fileParam, { single: true }) + return { + ...rest, + ...(file ? { file } : {}), } - return `service_${params.operation}` }, }, } ``` -**Why this pattern?** -- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs) -- `canonicalParamId` only controls UI/schema mapping, not runtime values -- `normalizeFileInput` handles JSON strings from advanced mode template resolution +**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value, +but it is not what the params function receives. `extractBlockParams` +(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time: + +```typescript +const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) +sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted +if (chosen !== undefined) params[group.canonicalId] = chosen +``` + +So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`), +`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a +subblock id there yields `undefined` and silently sends no file. + +Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode +and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can +never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution +produces. + +Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so +omitting a key from the returned object does not strip it from what the tool receives. Tools simply +ignore params they do not declare. ### File Input Types in `inputs` -Use `type: 'json'` for file inputs: +Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`: ```typescript inputs: { - uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' }, - fileRef: { type: 'json', description: 'File reference from previous block' }, + file: { type: 'json', description: 'File to upload (UserFile or reference)' }, // Legacy field for backwards compatibility fileContent: { type: 'string', description: 'Legacy: base64 encoded content' }, } diff --git a/.cursor/commands/add-integration.md b/.cursor/commands/add-integration.md index 62eb2de67a8..6267a08b17d 100644 --- a/.cursor/commands/add-integration.md +++ b/.cursor/commands/add-integration.md @@ -207,7 +207,7 @@ export const {Service}Block: BlockConfig = { ```typescript // Basic: Visual selector { - id: 'channel', + id: 'channelSelector', type: 'channel-selector', mode: 'basic', canonicalParamId: 'channel', @@ -222,10 +222,19 @@ export const {Service}Block: BlockConfig = { } ``` +Note neither subblock `id` is `channel` — the canonical id is a third name that both members map +onto, and it is the only one that survives serialization. + **Critical Canonical Param Rules:** - `canonicalParamId` must NOT match any subblock's `id` in the block -- `canonicalParamId` must be unique per operation/condition context -- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter +- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys + groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two + operations that each need their own pair must use two different canonical ids +- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter. + A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as + in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate + identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the + mutually exclusive sources `required: false`, and enforce "exactly one" at execution - `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent - Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions - **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes) diff --git a/apps/docs/content/docs/en/integrations/whatsapp.mdx b/apps/docs/content/docs/en/integrations/whatsapp.mdx index d6c758ec6ec..32d8ad5c094 100644 --- a/apps/docs/content/docs/en/integrations/whatsapp.mdx +++ b/apps/docs/content/docs/en/integrations/whatsapp.mdx @@ -26,7 +26,7 @@ In Sim, the WhatsApp integration enables your agents to leverage these messaging ## Usage Instructions -Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API. +Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API. Free-form messages only reach a recipient within 24 hours of their last message to you — outside that window WhatsApp requires a pre-approved template. @@ -34,14 +34,14 @@ Integrate WhatsApp into the workflow. Send text, template, media, and interactiv ### `whatsapp_send_message` -Send a text message through the WhatsApp Cloud API. +Send a free-form text message through the WhatsApp Cloud API. Only works inside the 24-hour customer service window — use Send Template to start a conversation. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `message` | string | Yes | Plain text message content to send | +| `message` | string | Yes | Plain text message content to send \(max 4096 characters\) | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | | `previewUrl` | boolean | No | Whether WhatsApp should try to render a link preview for the first URL in the message | @@ -49,19 +49,43 @@ Send a text message through the WhatsApp Cloud API. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | WhatsApp message send success status | -| `messageId` | string | Unique WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the API | -| `messagingProduct` | string | Messaging product returned by the API | -| `inputPhoneNumber` | string | Recipient phone number echoed back by WhatsApp | -| `whatsappUserId` | string | WhatsApp user ID resolved for the recipient | -| `contacts` | array | Recipient contact records returned by WhatsApp | -| ↳ `input` | string | Input phone number sent to the API | -| ↳ `wa_id` | string | WhatsApp user ID associated with the recipient | +| `success` | boolean | Send success status | +| `messageId` | string | WhatsApp message identifier | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | +| `messagingProduct` | string | Messaging product returned by the send API | +| `inputPhoneNumber` | string | Recipient phone number echoed by the send API | +| `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | +| `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | +| `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | +| `from` | string | Sender phone number from the first incoming message | +| `recipientId` | string | Recipient phone number from the first status update in the batch | +| `phoneNumberId` | string | Business phone number ID from the first message or status item in the batch | +| `displayPhoneNumber` | string | Business display phone number from the first message or status item in the batch | +| `text` | string | Text body from the first incoming text message | +| `timestamp` | string | Timestamp from the first message or status item in the batch | +| `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | +| `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | +| `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | +| `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | +| `statuses` | json | All message status objects from the webhook batch, flattened across entries/changes | +| `webhookContacts` | json | All sender contact profiles from the webhook batch | +| `conversation` | json | Conversation metadata from the first status update in the batch \(id, expiration_timestamp, origin.type\) | +| `pricing` | json | Pricing metadata from the first status update in the batch \(billable, pricing_model, category\) | +| `raw` | json | Full structured WhatsApp webhook payload | +| `error` | string | Error information if sending fails | ### `whatsapp_send_template` -Send a pre-approved WhatsApp template message with a language and optional variable components. +Send a pre-approved WhatsApp template message. Required to start a conversation or to message a user outside the 24-hour customer service window. #### Input @@ -79,11 +103,18 @@ Send a pre-approved WhatsApp template message with a language and optional varia | --------- | ---- | ----------- | | `success` | boolean | Send success status | | `messageId` | string | WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | | `messagingProduct` | string | Messaging product returned by the send API | | `inputPhoneNumber` | string | Recipient phone number echoed by the send API | | `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | | `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | | `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | | `from` | string | Sender phone number from the first incoming message | | `recipientId` | string | Recipient phone number from the first status update in the batch | @@ -92,6 +123,8 @@ Send a pre-approved WhatsApp template message with a language and optional varia | `text` | string | Text body from the first incoming text message | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | | `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | | `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | @@ -104,17 +137,18 @@ Send a pre-approved WhatsApp template message with a language and optional varia ### `whatsapp_send_media` -Send an image, document, video, or audio message via a public link or an uploaded media ID. +Send an image, document, video, audio, or sticker message. Accepts an uploaded file, a media ID, or a public link. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `mediaType` | string | Yes | Type of media to send: image, document, video, or audio | -| `mediaLink` | string | No | Public HTTPS URL of the media \(provide this or mediaId\) | -| `mediaId` | string | No | ID of media previously uploaded to WhatsApp \(provide this or mediaLink\) | -| `caption` | string | No | Optional caption for image, video, or document media | +| `mediaType` | string | Yes | Type of media to send: image, document, video, audio, or sticker | +| `file` | file | No | File to send. Uploaded to WhatsApp first, then sent. Use Upload Media instead when sending the same file repeatedly. | +| `mediaId` | string | No | ID of media previously uploaded to WhatsApp. Alternative to file or mediaLink. | +| `mediaLink` | string | No | Public HTTPS URL of the media. Alternative to file or mediaId. | +| `caption` | string | No | Optional caption for image, video, or document media \(max 1024 characters\). Ignored for audio and sticker. | | `filename` | string | No | Optional file name shown to the recipient for document media | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | @@ -124,11 +158,18 @@ Send an image, document, video, or audio message via a public link or an uploade | --------- | ---- | ----------- | | `success` | boolean | Send success status | | `messageId` | string | WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | | `messagingProduct` | string | Messaging product returned by the send API | | `inputPhoneNumber` | string | Recipient phone number echoed by the send API | | `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | | `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | | `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | | `from` | string | Sender phone number from the first incoming message | | `recipientId` | string | Recipient phone number from the first status update in the batch | @@ -137,6 +178,8 @@ Send an image, document, video, or audio message via a public link or an uploade | `text` | string | Text body from the first incoming text message | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | | `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | | `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | @@ -156,12 +199,12 @@ Send an interactive WhatsApp message with reply buttons or a selectable list. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `bodyText` | string | Yes | Main body text of the interactive message | -| `headerText` | string | No | Optional plain-text header shown above the body | -| `footerText` | string | No | Optional footer text shown below the body | -| `buttons` | json | No | Reply buttons array \(max 3\), each item: \{ "type": "reply", "reply": \{ "id": "...", "title": "..." \} \}. Provide buttons or sections. | -| `listButtonText` | string | No | Label for the menu button that opens the list \(required when sending a list\) | -| `sections` | json | No | List sections array, each item: \{ "title": "...", "rows": \[\{ "id": "...", "title": "...", "description": "..." \}\] \}. Provide sections or buttons. | +| `bodyText` | string | Yes | Main body text of the interactive message \(max 1024 characters with buttons, 4096 with a list\) | +| `headerText` | string | No | Optional plain-text header shown above the body \(max 60 characters\) | +| `footerText` | string | No | Optional footer text shown below the body \(max 60 characters\) | +| `buttons` | json | No | Reply buttons array \(max 3\), each item: \{ "type": "reply", "reply": \{ "id": "...", "title": "..." \} \}. Button title max 20 characters, id max 256. Provide buttons or sections. | +| `listButtonText` | string | No | Label for the menu button that opens the list, max 20 characters \(required when sending a list\) | +| `sections` | json | No | List sections array \(max 10 sections, 10 rows total\), each item: \{ "title": "...", "rows": \[\{ "id": "...", "title": "...", "description": "..." \}\] \}. Section and row titles max 24 characters, row description max 72. Provide sections or buttons. | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | #### Output @@ -170,11 +213,18 @@ Send an interactive WhatsApp message with reply buttons or a selectable list. | --------- | ---- | ----------- | | `success` | boolean | Send success status | | `messageId` | string | WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | | `messagingProduct` | string | Messaging product returned by the send API | | `inputPhoneNumber` | string | Recipient phone number echoed by the send API | | `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | | `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | | `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | | `from` | string | Sender phone number from the first incoming message | | `recipientId` | string | Recipient phone number from the first status update in the batch | @@ -183,6 +233,8 @@ Send an interactive WhatsApp message with reply buttons or a selectable list. | `text` | string | Text body from the first incoming text message | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | | `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | | `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | @@ -202,7 +254,7 @@ React to a WhatsApp message with an emoji. Send an empty emoji to remove an exis | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `messageId` | string | Yes | ID \(wamid\) of the message to react to | +| `messageId` | string | Yes | ID \(wamid\) of the message to react to. Not delivered if the message is over 30 days old, deleted, not in this chat thread, or itself a reaction. | | `emoji` | string | No | Emoji to react with. Leave empty to remove an existing reaction. | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | @@ -212,11 +264,18 @@ React to a WhatsApp message with an emoji. Send an empty emoji to remove an exis | --------- | ---- | ----------- | | `success` | boolean | Send success status | | `messageId` | string | WhatsApp message identifier | -| `messageStatus` | string | Initial delivery state returned by the send API, such as accepted or paused | +| `messageStatus` | string | Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status. | | `messagingProduct` | string | Messaging product returned by the send API | | `inputPhoneNumber` | string | Recipient phone number echoed by the send API | | `whatsappUserId` | string | Resolved WhatsApp user ID for the recipient | | `contacts` | array | Recipient contacts returned by the send API \(each item includes input and wa_id\) | +| `mediaId` | string | Media asset ID from Upload Media, or the ID downloaded by Download Media | +| `file` | file | Media downloaded by Download Media, stored as a workflow file | +| `fileName` | string | Name of the file uploaded by Upload Media | +| `mimeType` | string | MIME type of the uploaded or downloaded media | +| `size` | number | Size in bytes of the file uploaded by Upload Media | +| `fileSize` | number | Size in bytes of the downloaded media | +| `sha256` | string | SHA-256 hash WhatsApp reported for the downloaded media | | `eventType` | string | Webhook classification such as incoming_message, message_status, or mixed | | `from` | string | Sender phone number from the first incoming message | | `recipientId` | string | Recipient phone number from the first status update in the batch | @@ -225,6 +284,8 @@ React to a WhatsApp message with an emoji. Send an empty emoji to remove an exis | `text` | string | Text body from the first incoming text message | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch, such as text, image, or system | +| `mediaMimeType` | string | MIME type of the first incoming media message in the webhook batch | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, or read | | `contact` | json | First sender contact in the webhook batch \(wa_id, profile.name\) | | `messages` | json | All incoming message objects from the webhook batch, flattened across entries/changes | @@ -237,13 +298,14 @@ React to a WhatsApp message with an emoji. Send an empty emoji to remove an exis ### `whatsapp_mark_read` -Mark a received WhatsApp message as read so the sender sees blue checkmarks. +Mark a received WhatsApp message as read so the sender sees blue checkmarks, optionally showing a typing indicator. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `messageId` | string | Yes | ID \(wamid\) of the incoming message to mark as read | +| `showTypingIndicator` | boolean | No | Show a typing indicator to the sender while a reply is composed. Dismissed once you respond or after 25 seconds, whichever comes first. | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | #### Output @@ -252,6 +314,47 @@ Mark a received WhatsApp message as read so the sender sees blue checkmarks. | --------- | ---- | ----------- | | `success` | boolean | Whether the message was successfully marked as read | +### `whatsapp_upload_media` + +Upload a file to WhatsApp and get a media ID for sending. Uploaded media is retained for 30 days and can back many sends. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `file` | file | Yes | File to upload. WhatsApp limits: images 5 MB, video and audio 16 MB, documents 100 MB, stickers 500 KB. | +| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `mediaId` | string | WhatsApp media ID. Pass this to Send Media to attach the uploaded file. | +| `fileName` | string | Name of the uploaded file | +| `mimeType` | string | MIME type WhatsApp received the file as | +| `size` | number | Size of the uploaded file in bytes | + +### `whatsapp_get_media` + +Download media a customer sent you. Takes the media ID from an incoming WhatsApp message and stores the file in the workflow. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `mediaId` | string | Yes | Media asset ID from an incoming message, e.g. <whatsapp.messages\[0\].mediaId>. This is not the message ID \(wamid\). | +| `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded media stored as a workflow file | +| `mediaId` | string | WhatsApp media ID that was downloaded | +| `mimeType` | string | MIME type reported by WhatsApp | +| `fileSize` | number | Size of the downloaded media in bytes | +| `sha256` | string | SHA-256 hash WhatsApp reported for the media, for integrity checks | + ## Triggers @@ -282,6 +385,9 @@ Trigger workflow from WhatsApp incoming messages and message status webhooks | `text` | string | Text body from the first incoming text message in the batch | | `timestamp` | string | Timestamp from the first message or status item in the batch | | `messageType` | string | Type of the first incoming message in the batch \(text, image, system, etc.\) | +| `mediaId` | string | Media asset ID from the first incoming media message. Pass to the Download Media operation to fetch the file. Expires after 7 days. | +| `mediaMimeType` | string | MIME type of the first incoming media message | +| `caption` | string | Caption on the first incoming image, video, or document message | | `status` | string | First outgoing message status in the batch, such as sent, delivered, read, or failed | | `contact` | json | First sender contact in the batch \(wa_id, profile.name\) | | `webhookContacts` | json | All sender contact profiles from the webhook batch | diff --git a/apps/sim/app/api/tools/whatsapp/get-media/route.ts b/apps/sim/app/api/tools/whatsapp/get-media/route.ts new file mode 100644 index 00000000000..30433c71f4c --- /dev/null +++ b/apps/sim/app/api/tools/whatsapp/get-media/route.ts @@ -0,0 +1,217 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type WhatsAppGetMediaRouteResponse, + whatsappGetMediaContract, + whatsappGetMediaOutputSchema, +} from '@/lib/api/contracts/tools/whatsapp' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readResponseJsonWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' +import { sanitizeFileName } from '@/executor/constants' +import type { UserFile } from '@/executor/types' +import { + buildMediaUrl, + extractWhatsAppErrorMessage, + WHATSAPP_MEDIA_MAX_BYTES, +} from '@/tools/whatsapp/utils' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const logger = createLogger('WhatsAppGetMediaAPI') + +const MAX_GRAPH_METADATA_BYTES = 256 * 1024 + +/** + * Meta's CDN is reported to reject requests without a conventional User-Agent. + * This is not documented behavior, so it is sent defensively rather than relied upon. + */ +const DOWNLOAD_USER_AGENT = 'SimWhatsAppMedia/1.0' + +function failureResponse(error: string, status: number) { + const body = { success: false, error } satisfies WhatsAppGetMediaRouteResponse + return NextResponse.json(body, { status }) +} + +interface WhatsAppMediaMetadata { + url: string + mimeType: string + fileSize: number | null + sha256: string | null + id: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized WhatsApp media download attempt: ${authResult.error}`) + return failureResponse(authResult.error || 'Authentication required', 401) + } + + const parsed = await parseRequest( + whatsappGetMediaContract, + request, + {}, + { + validationErrorResponse: (error) => + failureResponse(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { accessToken, mediaId, phoneNumberId, workspaceId, workflowId, executionId } = + parsed.data.body + const authorization = `Bearer ${accessToken.trim()}` + + try { + const metadataResponse = await fetch(buildMediaUrl(mediaId, phoneNumberId), { + headers: { Authorization: authorization }, + signal: request.signal, + }) + + const metadataBody = await readResponseJsonWithLimit>( + metadataResponse, + { + maxBytes: MAX_GRAPH_METADATA_BYTES, + label: `WhatsApp media ${mediaId} metadata`, + signal: request.signal, + } + ) + + if (!metadataResponse.ok) { + const message = extractWhatsAppErrorMessage(metadataBody, metadataResponse.status) + logger.error(`[${requestId}] WhatsApp media lookup failed`, { + status: metadataResponse.status, + }) + return failureResponse( + message, + metadataResponse.status >= 400 && metadataResponse.status < 500 + ? metadataResponse.status + : 502 + ) + } + + const url = typeof metadataBody.url === 'string' ? metadataBody.url : undefined + if (!url) { + return failureResponse('WhatsApp media metadata did not include a download URL', 502) + } + + // file_size comes back as a string in some responses, so coerce before comparing. + const parsedSize = Number(metadataBody.file_size) + const metadata: WhatsAppMediaMetadata = { + url, + mimeType: + typeof metadataBody.mime_type === 'string' && metadataBody.mime_type.length > 0 + ? metadataBody.mime_type + : 'application/octet-stream', + fileSize: Number.isFinite(parsedSize) ? parsedSize : null, + sha256: typeof metadataBody.sha256 === 'string' ? metadataBody.sha256 : null, + id: typeof metadataBody.id === 'string' ? metadataBody.id : mediaId, + } + + // Reject oversized media from the declared size before spending bandwidth on it. + if (metadata.fileSize !== null && metadata.fileSize > WHATSAPP_MEDIA_MAX_BYTES) { + return failureResponse( + `WhatsApp media is ${(metadata.fileSize / (1024 * 1024)).toFixed(2)} MB, which exceeds the 100 MB download limit`, + 413 + ) + } + + // The media URL points at Meta's CDN and still requires the bearer token. + const urlValidation = await validateUrlWithDNS(metadata.url, 'mediaUrl') + if (!urlValidation.isValid) { + return failureResponse(`Invalid WhatsApp media URL: ${urlValidation.error}`, 502) + } + + const mediaResponse = await secureFetchWithPinnedIP(metadata.url, urlValidation.resolvedIP!, { + method: 'GET', + headers: { + Authorization: authorization, + 'User-Agent': DOWNLOAD_USER_AGENT, + }, + maxResponseBytes: WHATSAPP_MEDIA_MAX_BYTES, + stripAuthOnRedirect: true, + signal: request.signal, + }) + + if (!mediaResponse.ok) { + logger.error(`[${requestId}] WhatsApp media download failed`, { + status: mediaResponse.status, + }) + return failureResponse( + mediaResponse.status === 404 + ? 'WhatsApp media not found or its download URL expired (URLs are valid for 5 minutes)' + : `Failed to download WhatsApp media (${mediaResponse.status})`, + mediaResponse.status >= 400 && mediaResponse.status < 500 ? mediaResponse.status : 502 + ) + } + + const buffer = await readResponseToBufferWithLimit(mediaResponse, { + maxBytes: WHATSAPP_MEDIA_MAX_BYTES, + label: 'WhatsApp media download', + }) + + const extension = getExtensionFromMimeType(metadata.mimeType) ?? 'bin' + const fileName = sanitizeFileName(`whatsapp-${metadata.id}.${extension}`) + + const file: UserFile = + workspaceId && workflowId && executionId + ? await uploadExecutionFile( + { workspaceId, workflowId, executionId }, + buffer, + fileName, + metadata.mimeType, + authResult.userId + ) + : await uploadCopilotFile({ + buffer, + fileName, + contentType: metadata.mimeType, + userId: authResult.userId, + }) + + logger.info(`[${requestId}] WhatsApp media downloaded`, { + mediaId: metadata.id, + mimeType: metadata.mimeType, + size: buffer.length, + }) + + const output = whatsappGetMediaOutputSchema.parse({ + file, + mediaId: metadata.id, + mimeType: metadata.mimeType, + fileSize: buffer.length, + sha256: metadata.sha256, + }) + + return NextResponse.json({ + success: true, + output, + } satisfies WhatsAppGetMediaRouteResponse) + } catch (error) { + logger.error(`[${requestId}] WhatsApp media download failed`, { error }) + + if (isPayloadSizeLimitError(error)) { + return failureResponse('WhatsApp media exceeds the 100 MB download limit', 413) + } + + return failureResponse(getErrorMessage(error, 'Failed to download WhatsApp media'), 500) + } +}) diff --git a/apps/sim/app/api/tools/whatsapp/send-media/route.ts b/apps/sim/app/api/tools/whatsapp/send-media/route.ts new file mode 100644 index 00000000000..cd611dc809c --- /dev/null +++ b/apps/sim/app/api/tools/whatsapp/send-media/route.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type WhatsAppSendMediaRouteResponse, + whatsappSendMediaContract, +} from '@/lib/api/contracts/tools/whatsapp' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +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 { uploadWhatsAppMedia } from '@/app/api/tools/whatsapp/upload.server' +import { + buildAuthHeaders, + buildMediaMessageBody, + buildMessagesUrl, + transformWhatsAppSendResponse, +} from '@/tools/whatsapp/utils' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const logger = createLogger('WhatsAppSendMediaAPI') + +function failureResponse(error: string, status: number) { + const body = { success: false, error } satisfies WhatsAppSendMediaRouteResponse + return NextResponse.json(body, { status }) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized WhatsApp media send attempt: ${authResult.error}`) + return failureResponse(authResult.error || 'Authentication required', 401) + } + + const parsed = await parseRequest( + whatsappSendMediaContract, + request, + {}, + { + validationErrorResponse: (error) => + failureResponse(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const body = parsed.data.body + const suppliedSources = [body.file, body.mediaId, body.mediaLink].filter(Boolean).length + if (suppliedSources === 0) { + return failureResponse('Provide a file, a media ID, or a media link', 400) + } + if (suppliedSources > 1) { + return failureResponse('Provide only one of file, media ID, or media link', 400) + } + + try { + // A dropped file has no WhatsApp identity yet, so upload it first and send the + // resulting media ID. Media persists 30 days, so the ID is returned for reuse. + let uploadedMediaId: string | undefined + let filename = body.filename ?? undefined + + if (body.file) { + const uploaded = await uploadWhatsAppMedia({ + file: body.file, + accessToken: body.accessToken, + phoneNumberId: body.phoneNumberId, + userId: authResult.userId, + requestId, + logger, + signal: request.signal, + }) + + if (!uploaded.ok) { + return 'response' in uploaded + ? uploaded.response + : failureResponse(uploaded.error, uploaded.status) + } + + uploadedMediaId = uploaded.media.mediaId + filename = filename ?? uploaded.media.fileName + } + + const messageBody = buildMediaMessageBody({ + phoneNumber: body.phoneNumber, + mediaType: body.mediaType, + mediaId: uploadedMediaId ?? body.mediaId ?? undefined, + mediaLink: body.mediaLink ?? undefined, + caption: body.caption ?? undefined, + filename, + }) + + const response = await fetch(buildMessagesUrl(body.phoneNumberId), { + method: 'POST', + headers: buildAuthHeaders(body.accessToken), + body: JSON.stringify(messageBody), + signal: request.signal, + }) + + const sendResult = await transformWhatsAppSendResponse(response) + + // transformWhatsAppSendResponse throws on a non-OK send, so reaching here means success. + return NextResponse.json({ + success: true, + output: { + ...sendResult.output, + success: true, + messageId: sendResult.output.messageId ?? '', + inputPhoneNumber: sendResult.output.inputPhoneNumber ?? null, + whatsappUserId: sendResult.output.whatsappUserId ?? null, + contacts: sendResult.output.contacts ?? [], + ...(uploadedMediaId ? { mediaId: uploadedMediaId } : {}), + }, + } satisfies WhatsAppSendMediaRouteResponse) + } catch (error) { + logger.error(`[${requestId}] WhatsApp media send failed`, { error }) + return failureResponse( + getErrorMessage(error, 'Failed to send WhatsApp media'), + isPayloadSizeLimitError(error) ? 413 : 500 + ) + } +}) diff --git a/apps/sim/app/api/tools/whatsapp/upload-media/route.ts b/apps/sim/app/api/tools/whatsapp/upload-media/route.ts new file mode 100644 index 00000000000..befa0f34d22 --- /dev/null +++ b/apps/sim/app/api/tools/whatsapp/upload-media/route.ts @@ -0,0 +1,73 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type WhatsAppUploadMediaRouteResponse, + whatsappUploadMediaContract, +} from '@/lib/api/contracts/tools/whatsapp' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +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 { uploadWhatsAppMedia } from '@/app/api/tools/whatsapp/upload.server' + +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const logger = createLogger('WhatsAppUploadMediaAPI') + +function failureResponse(error: string, status: number) { + const body = { success: false, error } satisfies WhatsAppUploadMediaRouteResponse + return NextResponse.json(body, { status }) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized WhatsApp media upload attempt: ${authResult.error}`) + return failureResponse(authResult.error || 'Authentication required', 401) + } + + const parsed = await parseRequest( + whatsappUploadMediaContract, + request, + {}, + { + validationErrorResponse: (error) => + failureResponse(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const { accessToken, phoneNumberId, file } = parsed.data.body + + try { + const result = await uploadWhatsAppMedia({ + file, + accessToken, + phoneNumberId, + userId: authResult.userId, + requestId, + logger, + signal: request.signal, + }) + + if (!result.ok) { + return 'response' in result ? result.response : failureResponse(result.error, result.status) + } + + return NextResponse.json({ + success: true, + output: result.media, + } satisfies WhatsAppUploadMediaRouteResponse) + } catch (error) { + logger.error(`[${requestId}] WhatsApp media upload failed`, { error }) + return failureResponse( + getErrorMessage(error, 'Failed to upload media to WhatsApp'), + isPayloadSizeLimitError(error) ? 413 : 500 + ) + } +}) diff --git a/apps/sim/app/api/tools/whatsapp/upload.server.ts b/apps/sim/app/api/tools/whatsapp/upload.server.ts new file mode 100644 index 00000000000..ea84d2d5ab5 --- /dev/null +++ b/apps/sim/app/api/tools/whatsapp/upload.server.ts @@ -0,0 +1,148 @@ +import type { Logger } from '@sim/logger' +import type { NextResponse } from 'next/server' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { RawFileInput } from '@/lib/uploads/utils/file-utils' +import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + buildMediaUploadUrl, + extractWhatsAppErrorMessage, + whatsappMediaLimitFor, +} from '@/tools/whatsapp/utils' + +/** WhatsApp error and upload envelopes are small; cap the read so a hostile body cannot balloon memory. */ +const MAX_GRAPH_RESPONSE_BYTES = 256 * 1024 + +export interface UploadedWhatsAppMedia { + mediaId: string + fileName: string + mimeType: string + size: number +} + +export type UploadWhatsAppMediaResult = + | { ok: true; media: UploadedWhatsAppMedia } + | { ok: false; error: string; status: number } + | { ok: false; response: NextResponse } + +/** + * Pull a stored user file, enforce WhatsApp's documented per-type size ceiling, and + * upload it to `/{phone-number-id}/media`. Shared by the standalone Upload Media + * operation and the file path of Send Media. + */ +export async function uploadWhatsAppMedia({ + file, + accessToken, + phoneNumberId, + userId, + requestId, + logger, + signal, +}: { + file: RawFileInput + accessToken: string + phoneNumberId: string + userId: string + requestId: string + logger: Logger + signal?: AbortSignal +}): Promise { + const userFile = processSingleFileToUserFile(file, requestId, logger) + if (!userFile) { + return { ok: false, error: 'No valid file provided for upload', status: 400 } + } + + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return { ok: false, response: denied } + + // Check the declared size against WhatsApp's ceiling before pulling bytes, then cap + // the storage read itself so a mis-declared size cannot blow past it. + const declaredMimeType = userFile.type || 'application/octet-stream' + const declaredLimit = whatsappMediaLimitFor(declaredMimeType) + if (userFile.size > declaredLimit.maxBytes) { + return { + ok: false, + error: `${userFile.name} is ${(userFile.size / (1024 * 1024)).toFixed(2)} MB, which exceeds WhatsApp's limit for ${declaredLimit.label}`, + status: 413, + } + } + + let buffer: Buffer + let contentType: string + try { + const downloaded = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: declaredLimit.maxBytes, + signal, + }) + buffer = downloaded.buffer + contentType = downloaded.contentType + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return { ok: false, response: notReady } + throw error + } + + // downloadServableFileFromStorage can swap an AI-generated doc for its compiled + // artifact, so re-resolve the limit against the type actually being sent. + const resolvedMimeType = contentType || declaredMimeType + const resolvedLimit = whatsappMediaLimitFor(resolvedMimeType) + if (buffer.length > resolvedLimit.maxBytes) { + return { + ok: false, + error: `${userFile.name} is ${(buffer.length / (1024 * 1024)).toFixed(2)} MB, which exceeds WhatsApp's limit for ${resolvedLimit.label}`, + status: 413, + } + } + + const formData = new FormData() + formData.append('messaging_product', 'whatsapp') + formData.append('type', resolvedMimeType) + formData.append( + 'file', + new Blob([new Uint8Array(buffer)], { type: resolvedMimeType }), + userFile.name + ) + + logger.info(`[${requestId}] Uploading media to WhatsApp`, { + fileName: userFile.name, + mimeType: resolvedMimeType, + size: buffer.length, + }) + + // Content-Type is intentionally omitted so fetch sets the multipart boundary. + const response = await fetch(buildMediaUploadUrl(phoneNumberId), { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken.trim()}` }, + body: formData, + signal, + }) + + const data = await readResponseJsonWithLimit>(response, { + maxBytes: MAX_GRAPH_RESPONSE_BYTES, + label: 'WhatsApp media upload response', + signal, + }) + + if (!response.ok) { + logger.error(`[${requestId}] WhatsApp media upload failed`, { status: response.status }) + return { + ok: false, + error: extractWhatsAppErrorMessage(data, response.status), + status: response.status >= 400 && response.status < 500 ? response.status : 502, + } + } + + const mediaId = typeof data.id === 'string' ? data.id : undefined + if (!mediaId) { + return { ok: false, error: 'WhatsApp upload response did not include a media ID', status: 502 } + } + + logger.info(`[${requestId}] WhatsApp media uploaded`, { mediaId }) + + return { + ok: true, + media: { mediaId, fileName: userFile.name, mimeType: resolvedMimeType, size: buffer.length }, + } +} diff --git a/apps/sim/blocks/blocks/buffer.ts b/apps/sim/blocks/blocks/buffer.ts index 5a7f76a86d9..761ed4f258b 100644 --- a/apps/sim/blocks/blocks/buffer.ts +++ b/apps/sim/blocks/blocks/buffer.ts @@ -125,7 +125,7 @@ export const BufferBlock: BlockConfig = { id: 'mediaUpload', title: 'Media', type: 'file-upload', - canonicalParamId: 'media', + canonicalParamId: 'mediaSource', acceptedTypes: 'image/png,image/jpeg,image/gif,image/webp,video/mp4,video/quicktime', mode: 'basic', multiple: false, @@ -135,8 +135,17 @@ export const BufferBlock: BlockConfig = { id: 'mediaRef', title: 'Media', type: 'short-input', - canonicalParamId: 'media', - placeholder: 'Public image/video URL or a file reference from a previous block', + canonicalParamId: 'mediaSource', + placeholder: 'Reference a file from a previous block', + mode: 'advanced', + condition: { field: 'operation', value: POST_EDIT_OPS }, + }, + { + id: 'mediaUrl', + title: 'Media URL', + type: 'short-input', + placeholder: 'Public image or video URL', + description: 'Alternative to Media.', mode: 'advanced', condition: { field: 'operation', value: POST_EDIT_OPS }, }, @@ -285,7 +294,8 @@ export const BufferBlock: BlockConfig = { params: (params) => { const result: Record = {} for (const [key, value] of Object.entries(params)) { - if (key === 'media') continue + // Media is resolved below into the single `media` param the tools declare. + if (key === 'mediaSource' || key === 'mediaUrl') continue if (value === undefined || value === null || value === '') continue if (key === 'limit') { const limit = Number(value) @@ -295,23 +305,14 @@ export const BufferBlock: BlockConfig = { result[key] = value } - // Collapse basic/advanced media inputs into a single file reference, - // passing plain URL strings (advanced mode) through untouched. JSON-ish - // strings that normalize to nothing (e.g. "[]" from an empty file - // reference) are dropped rather than treated as URLs. - const media = params.media - const normalizedMedia = normalizeFileInput(media, { single: true }) + // An uploaded or referenced file wins; otherwise fall back to the separate URL + // field. `mediaSource` only ever holds a file, so no value sniffing is needed. + const normalizedMedia = normalizeFileInput(params.mediaSource, { single: true }) + const mediaUrl = typeof params.mediaUrl === 'string' ? params.mediaUrl.trim() : '' if (normalizedMedia) { result.media = normalizedMedia - } else if (typeof media === 'string' && media.trim() !== '') { - const trimmed = media.trim() - let parsesAsJson = true - try { - JSON.parse(trimmed) - } catch { - parsesAsJson = false - } - if (!parsesAsJson) result.media = trimmed + } else if (mediaUrl) { + result.media = mediaUrl } return result @@ -333,7 +334,8 @@ export const BufferBlock: BlockConfig = { schedulingType: { type: 'string', description: 'Scheduling type (automatic or notification)' }, dueAt: { type: 'string', description: 'Publish time (ISO 8601)' }, saveToDraft: { type: 'boolean', description: 'Save the post as a draft' }, - media: { type: 'string', description: 'Image or video attachment (file or public URL)' }, + mediaSource: { type: 'json', description: 'Image or video file to attach' }, + mediaUrl: { type: 'string', description: 'Public image or video URL to attach' }, mediaType: { type: 'string', description: 'Attachment type override: auto, image, or video', diff --git a/apps/sim/blocks/blocks/elevenlabs.ts b/apps/sim/blocks/blocks/elevenlabs.ts index cfe4e0da9b3..67c2f2fcc10 100644 --- a/apps/sim/blocks/blocks/elevenlabs.ts +++ b/apps/sim/blocks/blocks/elevenlabs.ts @@ -88,12 +88,24 @@ export const ElevenLabsBlock: BlockConfig = { id: 'audioFile', title: 'Audio File', type: 'file-upload', + canonicalParamId: 'audioFile', placeholder: 'Upload an audio file', + mode: 'basic', multiple: false, acceptedTypes: '.mp3,.m4a,.wav,.webm,.ogg,.flac,.aac,.opus', condition: { field: 'operation', value: AUDIO_INPUT_OPERATIONS }, required: { field: 'operation', value: AUDIO_INPUT_OPERATIONS }, }, + { + id: 'audioFileRef', + title: 'Audio File', + type: 'short-input', + canonicalParamId: 'audioFile', + placeholder: 'Reference a file from a previous block', + mode: 'advanced', + condition: { field: 'operation', value: AUDIO_INPUT_OPERATIONS }, + required: { field: 'operation', value: AUDIO_INPUT_OPERATIONS }, + }, { id: 'modelId', diff --git a/apps/sim/blocks/blocks/telegram.test.ts b/apps/sim/blocks/blocks/telegram.test.ts new file mode 100644 index 00000000000..43ec34667a0 --- /dev/null +++ b/apps/sim/blocks/blocks/telegram.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { TelegramBlock } from '@/blocks/blocks/telegram' + +const MEDIA_OPS = [ + { operation: 'telegram_send_photo', key: 'photo', label: 'Photo' }, + { operation: 'telegram_send_video', key: 'video', label: 'Video' }, + { operation: 'telegram_send_audio', key: 'audio', label: 'Audio' }, + { operation: 'telegram_send_animation', key: 'animation', label: 'Animation' }, +] as const + +const base = { botToken: 'bot-token', chatId: '12345' } + +function mapParams(params: Record) { + return TelegramBlock.tools.config!.params!({ ...base, ...params }) as Record +} + +const userFile = { + id: 'f1', + name: 'pic.jpg', + url: 'https://storage.example/pic.jpg', + size: 1024, + type: 'image/jpeg', + key: 'execution/ws/wf/ex/pic.jpg', +} + +describe('Telegram media params', () => { + /** + * These tools POST JSON, and Telegram's media field is a String — a file_id, or an HTTP URL + * Telegram fetches itself. An uploaded file therefore has to become its https URL; sending + * the UserFile object would put a JSON object where Telegram requires a string. + */ + for (const { operation, key, label } of MEDIA_OPS) { + describe(operation, () => { + it('sends an uploaded file as its https URL, not as an object', () => { + const mapped = mapParams({ operation, [`${key}Source`]: userFile }) + expect(mapped[key]).toBe('https://storage.example/pic.jpg') + }) + + it('sends a file reference resolved from advanced mode as its https URL', () => { + const mapped = mapParams({ operation, [`${key}Source`]: JSON.stringify(userFile) }) + expect(mapped[key]).toBe('https://storage.example/pic.jpg') + }) + + it('passes a file_id through untouched', () => { + const mapped = mapParams({ operation, [key]: ' AgACAgIAAxkBAAI ' }) + expect(mapped[key]).toBe('AgACAgIAAxkBAAI') + }) + + it('passes a public HTTP URL through untouched', () => { + const mapped = mapParams({ operation, [key]: 'https://example.com/a.jpg' }) + expect(mapped[key]).toBe('https://example.com/a.jpg') + }) + + it('still accepts a file reference left in the legacy field', () => { + // Before the upload pair existed this field held either a reference or a string. + const mapped = mapParams({ operation, [key]: JSON.stringify(userFile) }) + expect(mapped[key]).toBe('https://storage.example/pic.jpg') + }) + + it('reports a clear error when the file has no https URL to fetch', () => { + expect(() => + mapParams({ operation, [`${key}Source`]: { ...userFile, url: '/api/files/serve/local' } }) + ).toThrow(/https URL for Telegram to fetch/) + }) + + it(`throws when no ${key} is supplied`, () => { + expect(() => mapParams({ operation })).toThrow(`${label} is required.`) + }) + }) + } + + it('keeps each media pair to a file upload plus a file reference', () => { + for (const { key } of MEDIA_OPS) { + const members = TelegramBlock.subBlocks.filter((s) => s.canonicalParamId === `${key}Source`) + expect(members.map((s) => s.type)).toEqual(['file-upload', 'short-input']) + expect(members.map((s) => s.mode)).toEqual(['basic', 'advanced']) + // The file_id/URL field is deliberately outside the pair. + const direct = TelegramBlock.subBlocks.find((s) => s.id === key) + expect(direct?.canonicalParamId).toBeUndefined() + } + }) +}) diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 296d818cbef..c0c3df40ce4 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -1,10 +1,38 @@ import { TelegramIcon } from '@/components/icons' +import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import type { TelegramResponse } from '@/tools/telegram/types' import { getTrigger } from '@/triggers' +/** + * Telegram media params take a single string — a `file_id`, an HTTP URL Telegram fetches + * itself, or a multipart upload. These tools post JSON, so an uploaded file is sent as its + * https URL rather than as an object, which Telegram would reject. + * + * `direct` also accepts a file reference for workflows built before the upload pair existed, + * when that field held either shape. `normalizeFileInput` parses it rather than guessing. + */ +function resolveTelegramMedia(fileSource: unknown, direct: unknown, label: string): string { + const file = normalizeFileInput(fileSource ?? direct, { single: true }) + if (file) { + const url = resolveHttpsUrlFromFileInput(file) + if (!url) { + throw new Error( + `${label} must be reachable at an https URL for Telegram to fetch. Configure cloud storage, or pass a file_id or public URL instead.` + ) + } + return url + } + + const value = typeof direct === 'string' ? direct.trim() : '' + if (!value) { + throw new Error(`${label} is required.`) + } + return value +} + export const TelegramBlock: BlockConfig = { type: 'telegram', name: 'Telegram', @@ -83,88 +111,136 @@ export const TelegramBlock: BlockConfig = { id: 'photoFile', title: 'Photo', type: 'file-upload', - canonicalParamId: 'photo', + canonicalParamId: 'photoSource', placeholder: 'Upload photo', mode: 'basic', multiple: false, - required: true, + required: false, + requiresCloudStorage: true, + maxSize: 5, acceptedTypes: '.jpg,.jpeg,.png,.gif,.webp', condition: { field: 'operation', value: 'telegram_send_photo' }, }, { - id: 'photo', + id: 'photoRef', title: 'Photo', type: 'short-input', - canonicalParamId: 'photo', - placeholder: 'Reference photo from previous blocks or enter URL/file_id', + canonicalParamId: 'photoSource', + placeholder: 'Reference a file from a previous block', mode: 'advanced', - required: true, + required: false, + condition: { field: 'operation', value: 'telegram_send_photo' }, + }, + { + id: 'photo', + title: 'Photo ID or URL', + type: 'short-input', + placeholder: 'Telegram file_id or public HTTP URL', + description: 'Alternative to Photo. Telegram resolves this string itself.', + mode: 'advanced', + required: false, condition: { field: 'operation', value: 'telegram_send_photo' }, }, { id: 'videoFile', title: 'Video', type: 'file-upload', - canonicalParamId: 'video', + canonicalParamId: 'videoSource', placeholder: 'Upload video', mode: 'basic', multiple: false, - required: true, + required: false, + requiresCloudStorage: true, + maxSize: 20, acceptedTypes: '.mp4,.mov,.avi,.mkv,.webm', condition: { field: 'operation', value: 'telegram_send_video' }, }, { - id: 'video', + id: 'videoRef', title: 'Video', type: 'short-input', - canonicalParamId: 'video', - placeholder: 'Reference video from previous blocks or enter URL/file_id', + canonicalParamId: 'videoSource', + placeholder: 'Reference a file from a previous block', mode: 'advanced', - required: true, + required: false, + condition: { field: 'operation', value: 'telegram_send_video' }, + }, + { + id: 'video', + title: 'Video ID or URL', + type: 'short-input', + placeholder: 'Telegram file_id or public HTTP URL', + description: 'Alternative to Video. Telegram resolves this string itself.', + mode: 'advanced', + required: false, condition: { field: 'operation', value: 'telegram_send_video' }, }, { id: 'audioFile', title: 'Audio', type: 'file-upload', - canonicalParamId: 'audio', + canonicalParamId: 'audioSource', placeholder: 'Upload audio', mode: 'basic', multiple: false, - required: true, + required: false, + requiresCloudStorage: true, + maxSize: 20, acceptedTypes: '.mp3,.m4a,.wav,.ogg,.flac', condition: { field: 'operation', value: 'telegram_send_audio' }, }, { - id: 'audio', + id: 'audioRef', title: 'Audio', type: 'short-input', - canonicalParamId: 'audio', - placeholder: 'Reference audio from previous blocks or enter URL/file_id', + canonicalParamId: 'audioSource', + placeholder: 'Reference a file from a previous block', mode: 'advanced', - required: true, + required: false, + condition: { field: 'operation', value: 'telegram_send_audio' }, + }, + { + id: 'audio', + title: 'Audio ID or URL', + type: 'short-input', + placeholder: 'Telegram file_id or public HTTP URL', + description: 'Alternative to Audio. Telegram resolves this string itself.', + mode: 'advanced', + required: false, condition: { field: 'operation', value: 'telegram_send_audio' }, }, { id: 'animationFile', title: 'Animation', type: 'file-upload', - canonicalParamId: 'animation', + canonicalParamId: 'animationSource', placeholder: 'Upload animation (GIF)', mode: 'basic', multiple: false, - required: true, + required: false, + requiresCloudStorage: true, + maxSize: 20, acceptedTypes: '.gif,.mp4', condition: { field: 'operation', value: 'telegram_send_animation' }, }, { - id: 'animation', + id: 'animationRef', title: 'Animation', type: 'short-input', - canonicalParamId: 'animation', - placeholder: 'Reference animation from previous blocks or enter URL/file_id', + canonicalParamId: 'animationSource', + placeholder: 'Reference a file from a previous block', mode: 'advanced', - required: true, + required: false, + condition: { field: 'operation', value: 'telegram_send_animation' }, + }, + { + id: 'animation', + title: 'Animation ID or URL', + type: 'short-input', + placeholder: 'Telegram file_id or public HTTP URL', + description: 'Alternative to Animation. Telegram resolves this string itself.', + mode: 'advanced', + required: false, condition: { field: 'operation', value: 'telegram_send_animation' }, }, // File upload (basic mode) for Send Document @@ -467,58 +543,34 @@ export const TelegramBlock: BlockConfig = { messageId: requireNumber(params.messageId, 'Message ID'), } case 'telegram_send_photo': { - // photo is the canonical param for both basic (photoFile) and advanced modes - const photoSource = normalizeFileInput(params.photo, { - single: true, - }) - if (!photoSource) { - throw new Error('Photo is required.') - } return { ...commonParams, - photo: photoSource, + photo: resolveTelegramMedia(params.photoSource, params.photo, 'Photo'), caption: params.caption, } } case 'telegram_send_video': { - // video is the canonical param for both basic (videoFile) and advanced modes - const videoSource = normalizeFileInput(params.video, { - single: true, - }) - if (!videoSource) { - throw new Error('Video is required.') - } return { ...commonParams, - video: videoSource, + video: resolveTelegramMedia(params.videoSource, params.video, 'Video'), caption: params.caption, } } case 'telegram_send_audio': { - // audio is the canonical param for both basic (audioFile) and advanced modes - const audioSource = normalizeFileInput(params.audio, { - single: true, - }) - if (!audioSource) { - throw new Error('Audio is required.') - } return { ...commonParams, - audio: audioSource, + audio: resolveTelegramMedia(params.audioSource, params.audio, 'Audio'), caption: params.caption, } } case 'telegram_send_animation': { - // animation is the canonical param for both basic (animationFile) and advanced modes - const animationSource = normalizeFileInput(params.animation, { - single: true, - }) - if (!animationSource) { - throw new Error('Animation is required.') - } return { ...commonParams, - animation: animationSource, + animation: resolveTelegramMedia( + params.animationSource, + params.animation, + 'Animation' + ), caption: params.caption, } } @@ -653,10 +705,17 @@ export const TelegramBlock: BlockConfig = { botToken: { type: 'string', description: 'Telegram bot token' }, chatId: { type: 'string', description: 'Chat identifier' }, text: { type: 'string', description: 'Message text' }, - photo: { type: 'json', description: 'Photo (UserFile or URL/file_id)' }, - video: { type: 'json', description: 'Video (UserFile or URL/file_id)' }, - audio: { type: 'json', description: 'Audio (UserFile or URL/file_id)' }, - animation: { type: 'json', description: 'Animation (UserFile or URL/file_id)' }, + photoSource: { type: 'json', description: 'Photo file to send' }, + photo: { type: 'string', description: 'Telegram file_id or public HTTP URL for the photo' }, + videoSource: { type: 'json', description: 'Video file to send' }, + video: { type: 'string', description: 'Telegram file_id or public HTTP URL for the video' }, + audioSource: { type: 'json', description: 'Audio file to send' }, + audio: { type: 'string', description: 'Telegram file_id or public HTTP URL for the audio' }, + animationSource: { type: 'json', description: 'Animation file to send' }, + animation: { + type: 'string', + description: 'Telegram file_id or public HTTP URL for the animation', + }, files: { type: 'array', description: 'Files to attach (UserFile array)' }, caption: { type: 'string', description: 'Caption for media' }, messageId: { type: 'string', description: 'Target message ID' }, diff --git a/apps/sim/blocks/blocks/whatsapp.ts b/apps/sim/blocks/blocks/whatsapp.ts index 16eba080700..ded94bedff3 100644 --- a/apps/sim/blocks/blocks/whatsapp.ts +++ b/apps/sim/blocks/blocks/whatsapp.ts @@ -1,16 +1,24 @@ import { WhatsAppIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' import type { WhatsAppResponse } from '@/tools/whatsapp/types' import { getTrigger } from '@/triggers' +/** Yes/No dropdowns serialize as `'true'`/`'false'` strings; the tools expect booleans. */ +function toOptionalBoolean(value: unknown): boolean | undefined { + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + return undefined +} + export const WhatsAppBlock: BlockConfig = { type: 'whatsapp', name: 'WhatsApp', description: 'Send WhatsApp messages', authMode: AuthMode.ApiKey, longDescription: - 'Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API.', + 'Integrate WhatsApp into the workflow. Send text, template, media, and interactive messages, react to messages, and mark messages as read through the WhatsApp Cloud API. Free-form messages only reach a recipient within 24 hours of their last message to you — outside that window WhatsApp requires a pre-approved template.', docsLink: 'https://docs.sim.ai/integrations/whatsapp', category: 'tools', integrationType: IntegrationType.Communication, @@ -30,6 +38,8 @@ export const WhatsAppBlock: BlockConfig = { { label: 'Send Interactive', id: 'send_interactive' }, { label: 'Send Reaction', id: 'send_reaction' }, { label: 'Mark As Read', id: 'mark_read' }, + { label: 'Upload Media', id: 'upload_media' }, + { label: 'Download Media', id: 'get_media' }, ], defaultValue: 'send_message', }, @@ -38,14 +48,25 @@ export const WhatsAppBlock: BlockConfig = { title: 'Recipient Phone Number', type: 'short-input', placeholder: 'Enter phone number with country code (e.g., +1234567890)', - condition: { field: 'operation', value: 'mark_read', not: true }, - required: { field: 'operation', value: 'mark_read', not: true }, + description: 'Include the plus sign and country calling code.', + condition: { + field: 'operation', + value: ['mark_read', 'upload_media', 'get_media'], + not: true, + }, + required: { + field: 'operation', + value: ['mark_read', 'upload_media', 'get_media'], + not: true, + }, }, { id: 'message', title: 'Message', type: 'long-input', placeholder: 'Enter your message', + description: + 'Free-form text (max 4096 characters). WhatsApp only delivers this within 24 hours of the recipient last messaging you — outside that window use Send Template.', condition: { field: 'operation', value: 'send_message' }, required: { field: 'operation', value: 'send_message' }, }, @@ -90,6 +111,44 @@ export const WhatsAppBlock: BlockConfig = { condition: { field: 'operation', value: 'send_template' }, required: false, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a WhatsApp template components JSON array based on the user's description. +Each component fills the variables of one part of an approved template. + +Current components: {context} + +Format: +[ + { + "type": "header", + "parameters": [{ "type": "text", "text": "value" }] + }, + { + "type": "body", + "parameters": [ + { "type": "text", "text": "value" }, + { "type": "currency", "currency": { "fallback_value": "$100", "code": "USD", "amount_1000": 100000 } }, + { "type": "date_time", "date_time": { "fallback_value": "February 25, 2026" } } + ] + }, + { + "type": "button", + "sub_type": "url", + "index": "0", + "parameters": [{ "type": "text", "text": "suffix" }] + } +] + +RULES: +1. Parameters are positional - they fill {{1}}, {{2}}, ... in template order +2. Only include the components the template actually declares +3. "button" components require both "sub_type" and a string "index" + +Return ONLY the valid JSON array - no explanations, no extra text.`, + placeholder: 'Describe the template variables to fill...', + generationType: 'json-object', + }, }, { id: 'mediaType', @@ -100,35 +159,68 @@ export const WhatsAppBlock: BlockConfig = { { label: 'Document', id: 'document' }, { label: 'Video', id: 'video' }, { label: 'Audio', id: 'audio' }, + { label: 'Sticker', id: 'sticker' }, ], defaultValue: 'image', condition: { field: 'operation', value: 'send_media' }, required: { field: 'operation', value: 'send_media' }, }, { - id: 'mediaLink', - title: 'Media Link', - type: 'short-input', - placeholder: 'Public HTTPS URL of the media', + id: 'sendMediaFile', + title: 'Media', + type: 'file-upload', + canonicalParamId: 'mediaSource', + placeholder: 'Upload media to send', + description: + 'Uploaded to WhatsApp, then sent. Use Upload Media when sending the same file repeatedly.', + mode: 'basic', + multiple: false, + required: false, + acceptedTypes: + '.jpg,.jpeg,.png,.webp,.mp4,.3gp,.aac,.amr,.mp3,.m4a,.ogg,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt', condition: { field: 'operation', value: 'send_media' }, + }, + { + id: 'sendMediaFileRef', + title: 'Media', + type: 'short-input', + canonicalParamId: 'mediaSource', + placeholder: 'Reference a file from a previous block', + mode: 'advanced', required: false, + condition: { field: 'operation', value: 'send_media' }, }, { id: 'mediaId', title: 'Media ID', type: 'short-input', - placeholder: 'ID of media uploaded to WhatsApp', - description: 'Provide a Media Link or a Media ID.', - condition: { field: 'operation', value: 'send_media' }, + placeholder: 'ID of media already uploaded to WhatsApp', + description: 'Alternative to Media. Reuses an upload for 30 days.', + mode: 'advanced', required: false, + condition: { field: 'operation', value: 'send_media' }, + }, + { + id: 'mediaLink', + title: 'Media Link', + type: 'short-input', + placeholder: 'Public HTTPS URL of the media', + description: 'Alternative to Media. WhatsApp fetches and caches this URL for 10 minutes.', mode: 'advanced', + required: false, + condition: { field: 'operation', value: 'send_media' }, }, { id: 'caption', title: 'Caption', type: 'long-input', placeholder: 'Optional caption (image, video, or document)', - condition: { field: 'operation', value: 'send_media' }, + description: 'Max 1024 characters. Not supported for audio or sticker media.', + condition: { + field: 'operation', + value: 'send_media', + and: { field: 'mediaType', value: ['image', 'video', 'document'] }, + }, required: false, mode: 'advanced', }, @@ -137,15 +229,32 @@ export const WhatsAppBlock: BlockConfig = { title: 'File Name', type: 'short-input', placeholder: 'Optional file name for documents', - condition: { field: 'operation', value: 'send_media' }, + condition: { + field: 'operation', + value: 'send_media', + and: { field: 'mediaType', value: 'document' }, + }, required: false, mode: 'advanced', }, + { + id: 'interactiveType', + title: 'Interactive Type', + type: 'dropdown', + options: [ + { label: 'Reply Buttons', id: 'button' }, + { label: 'List', id: 'list' }, + ], + defaultValue: 'button', + condition: { field: 'operation', value: 'send_interactive' }, + required: { field: 'operation', value: 'send_interactive' }, + }, { id: 'bodyText', title: 'Body Text', type: 'long-input', placeholder: 'Main message body', + description: 'Max 1024 characters with reply buttons, 4096 with a list.', condition: { field: 'operation', value: 'send_interactive' }, required: { field: 'operation', value: 'send_interactive' }, }, @@ -154,34 +263,104 @@ export const WhatsAppBlock: BlockConfig = { title: 'Reply Buttons', type: 'long-input', placeholder: '[{"type":"reply","reply":{"id":"yes","title":"Yes"}}]', - description: 'JSON array of reply buttons (max 3). Provide buttons or sections.', - condition: { field: 'operation', value: 'send_interactive' }, - required: false, + description: 'JSON array of reply buttons (max 3, title max 20 characters).', + condition: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'button' }, + }, + required: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'button' }, + }, + wandConfig: { + enabled: true, + prompt: `Generate a WhatsApp interactive reply buttons JSON array based on the user's description. + +Current buttons: {context} + +Format: +[ + { "type": "reply", "reply": { "id": "unique_id", "title": "Button Label" } } +] + +RULES: +1. Maximum 3 buttons +2. "title" must be 20 characters or fewer and must be unique across the buttons +3. "id" must be unique, 256 characters or fewer, and is what the webhook reports back when tapped + +Return ONLY the valid JSON array - no explanations, no extra text.`, + placeholder: 'Describe the reply buttons to offer...', + generationType: 'json-object', + }, }, { id: 'listButtonText', title: 'List Button Text', type: 'short-input', placeholder: 'e.g., Menu', - description: 'Label for the button that opens the list. Required when sending a list.', - condition: { field: 'operation', value: 'send_interactive' }, - required: false, + description: 'Label for the button that opens the list (max 20 characters).', + condition: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'list' }, + }, + required: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'list' }, + }, }, { id: 'sections', title: 'List Sections', type: 'long-input', placeholder: '[{"title":"Section","rows":[{"id":"r1","title":"Row 1"}]}]', - description: 'JSON array of list sections. Provide sections or buttons.', - condition: { field: 'operation', value: 'send_interactive' }, - required: false, - mode: 'advanced', + description: 'JSON array of list sections (max 10 sections, 10 rows total).', + condition: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'list' }, + }, + required: { + field: 'operation', + value: 'send_interactive', + and: { field: 'interactiveType', value: 'list' }, + }, + wandConfig: { + enabled: true, + prompt: `Generate a WhatsApp interactive list sections JSON array based on the user's description. + +Current sections: {context} + +Format: +[ + { + "title": "Section Title", + "rows": [ + { "id": "unique_id", "title": "Row Title", "description": "Optional detail" } + ] + } +] + +RULES: +1. Maximum 10 sections and 10 rows total across all sections +2. Section "title" and row "title" must be 24 characters or fewer +3. Row "description" is optional and must be 72 characters or fewer +4. Row "id" must be unique across all sections, 200 characters or fewer, and is what the webhook reports back when selected + +Return ONLY the valid JSON array - no explanations, no extra text.`, + placeholder: 'Describe the list options to offer...', + generationType: 'json-object', + }, }, { id: 'headerText', title: 'Header Text', type: 'short-input', placeholder: 'Optional plain-text header', + description: 'Max 60 characters.', condition: { field: 'operation', value: 'send_interactive' }, required: false, mode: 'advanced', @@ -191,6 +370,7 @@ export const WhatsAppBlock: BlockConfig = { title: 'Footer Text', type: 'short-input', placeholder: 'Optional footer text', + description: 'Max 60 characters.', condition: { field: 'operation', value: 'send_interactive' }, required: false, mode: 'advanced', @@ -211,6 +391,56 @@ export const WhatsAppBlock: BlockConfig = { condition: { field: 'operation', value: 'send_reaction' }, required: false, }, + { + id: 'uploadFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'file', + placeholder: 'Upload a file', + description: + 'WhatsApp limits: images 5 MB, video and audio 16 MB, documents 100 MB, stickers 500 KB.', + mode: 'basic', + multiple: false, + required: true, + acceptedTypes: + '.jpg,.jpeg,.png,.webp,.mp4,.3gp,.aac,.amr,.mp3,.m4a,.ogg,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt', + condition: { field: 'operation', value: 'upload_media' }, + }, + { + id: 'uploadFileRef', + title: 'File', + type: 'short-input', + canonicalParamId: 'file', + placeholder: 'Reference a file from a previous block', + mode: 'advanced', + required: true, + condition: { field: 'operation', value: 'upload_media' }, + }, + { + id: 'downloadMediaId', + title: 'Media ID', + type: 'short-input', + placeholder: 'Media ID from an incoming message', + description: + 'The media asset ID on an incoming message, not the message ID (wamid). Incoming media IDs expire after 7 days.', + condition: { field: 'operation', value: 'get_media' }, + required: { field: 'operation', value: 'get_media' }, + }, + { + id: 'showTypingIndicator', + title: 'Show Typing Indicator', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + defaultValue: 'false', + description: + 'Display a typing indicator to the sender. Dismissed once you reply or after 25 seconds.', + condition: { field: 'operation', value: 'mark_read' }, + required: false, + mode: 'advanced', + }, { id: 'phoneNumberId', title: 'WhatsApp Phone Number ID', @@ -236,29 +466,54 @@ export const WhatsAppBlock: BlockConfig = { 'whatsapp_send_interactive', 'whatsapp_send_reaction', 'whatsapp_mark_read', + 'whatsapp_upload_media', + 'whatsapp_get_media', ], config: { tool: (params) => `whatsapp_${params.operation || 'send_message'}`, - params: (params) => ({ - ...params, - previewUrl: - params.previewUrl === 'true' ? true : params.previewUrl === 'false' ? false : undefined, - }), + params: (params) => { + const { + interactiveType, + file: uploadedFile, + mediaSource, + downloadMediaId, + ...rest + } = params + // Both file canonical pairs resolve to the tool's single `file` param; only one + // operation is ever active, so they cannot collide. + const file = normalizeFileInput(uploadedFile ?? mediaSource, { single: true }) + + return { + ...rest, + previewUrl: toOptionalBoolean(params.previewUrl), + showTypingIndicator: toOptionalBoolean(params.showTypingIndicator), + ...(file ? { file } : {}), + ...(params.operation === 'get_media' ? { mediaId: downloadMediaId } : {}), + } + }, }, }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, phoneNumber: { type: 'string', description: 'Recipient phone number' }, message: { type: 'string', description: 'Message text' }, - previewUrl: { type: 'boolean', description: 'Whether to render a preview for the first URL' }, + previewUrl: { type: 'string', description: 'Whether to render a preview for the first URL' }, templateName: { type: 'string', description: 'Approved template name' }, languageCode: { type: 'string', description: 'Template language code (e.g., en_US)' }, components: { type: 'json', description: 'Template components with variable parameters' }, - mediaType: { type: 'string', description: 'Media type: image, document, video, or audio' }, + mediaType: { + type: 'string', + description: 'Media type: image, document, video, audio, or sticker', + }, + mediaSource: { type: 'json', description: 'Media file to send' }, + mediaId: { type: 'string', description: 'ID of media already uploaded to WhatsApp' }, mediaLink: { type: 'string', description: 'Public HTTPS URL of the media' }, - mediaId: { type: 'string', description: 'ID of media uploaded to WhatsApp' }, caption: { type: 'string', description: 'Caption for image, video, or document media' }, filename: { type: 'string', description: 'File name for document media' }, + interactiveType: { + type: 'string', + description: 'Interactive message variant: button or list', + }, bodyText: { type: 'string', description: 'Interactive message body text' }, headerText: { type: 'string', description: 'Interactive message header text' }, footerText: { type: 'string', description: 'Interactive message footer text' }, @@ -267,6 +522,12 @@ export const WhatsAppBlock: BlockConfig = { sections: { type: 'json', description: 'List sections for an interactive message' }, messageId: { type: 'string', description: 'Target message ID (wamid)' }, emoji: { type: 'string', description: 'Reaction emoji (empty to remove)' }, + showTypingIndicator: { + type: 'string', + description: 'Whether to show a typing indicator when marking a message as read', + }, + file: { type: 'json', description: 'File to upload to WhatsApp' }, + downloadMediaId: { type: 'string', description: 'Media asset ID to download' }, phoneNumberId: { type: 'string', description: 'WhatsApp phone number ID' }, accessToken: { type: 'string', description: 'WhatsApp access token' }, }, @@ -275,7 +536,8 @@ export const WhatsAppBlock: BlockConfig = { messageId: { type: 'string', description: 'WhatsApp message identifier' }, messageStatus: { type: 'string', - description: 'Initial delivery state returned by the send API, such as accepted or paused', + description: + 'Pacing status from the send API: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — use the webhook trigger for delivery status.', }, messagingProduct: { type: 'string', @@ -294,6 +556,22 @@ export const WhatsAppBlock: BlockConfig = { description: 'Recipient contacts returned by the send API (each item includes input and wa_id)', }, + mediaId: { + type: 'string', + description: 'Media asset ID from Upload Media, or the ID downloaded by Download Media', + }, + file: { + type: 'file', + description: 'Media downloaded by Download Media, stored as a workflow file', + }, + fileName: { type: 'string', description: 'Name of the file uploaded by Upload Media' }, + mimeType: { type: 'string', description: 'MIME type of the uploaded or downloaded media' }, + size: { type: 'number', description: 'Size in bytes of the file uploaded by Upload Media' }, + fileSize: { type: 'number', description: 'Size in bytes of the downloaded media' }, + sha256: { + type: 'string', + description: 'SHA-256 hash WhatsApp reported for the downloaded media', + }, eventType: { type: 'string', description: 'Webhook classification such as incoming_message, message_status, or mixed', @@ -322,6 +600,14 @@ export const WhatsAppBlock: BlockConfig = { description: 'Type of the first incoming message in the batch, such as text, image, or system', }, + mediaMimeType: { + type: 'string', + description: 'MIME type of the first incoming media message in the webhook batch', + }, + caption: { + type: 'string', + description: 'Caption on the first incoming image, video, or document message', + }, status: { type: 'string', description: 'First outgoing message status in the batch, such as sent, delivered, or read', @@ -443,23 +729,37 @@ export const WhatsAppBlockMeta = { { name: 'send-appointment-reminder', description: - 'Send a WhatsApp message reminding a contact of an upcoming appointment or booking.', + 'Send a WhatsApp template message reminding a contact of an upcoming appointment or booking.', content: - '# Send a WhatsApp Appointment Reminder\n\nNotify a contact about an upcoming appointment over WhatsApp.\n\n## Steps\n1. Gather the recipient phone number in full international format (country code, no plus sign or spaces as the API expects).\n2. Compose a short, clear message with the date, time, location or link, and any action the contact should take.\n3. Send the message with the WhatsApp send operation.\n4. Capture the returned message ID and delivery state.\n\n## Output\nConfirm the recipient, the message sent, and the message ID. If the send was rejected, report the reason rather than retrying blindly.', + '# Send a WhatsApp Appointment Reminder\n\nNotify a contact about an upcoming appointment over WhatsApp.\n\nA reminder is business-initiated, so it almost always falls outside the 24-hour customer service window. Use the Send Template operation with a pre-approved template — a free-form text message will be rejected unless the contact messaged you within the last 24 hours.\n\n## Steps\n1. Gather the recipient phone number with the plus sign and country calling code (e.g. +14155552671).\n2. Pick the approved appointment-reminder template and its language code (e.g. en_US).\n3. Fill the template components positionally — the parameters map to {{1}}, {{2}}, ... in the order the template declares them (date, time, location or link).\n4. Send with Send Template and capture the returned message ID.\n\n## Output\nConfirm the recipient, the template used, and the message ID. If the send was rejected, report the WhatsApp error code and message rather than retrying blindly — a template rejection usually means the template name, language, or parameter count is wrong.', }, { name: 'send-order-update', description: 'Notify a customer over WhatsApp about an order status change such as shipment or delivery.', content: - '# Send a WhatsApp Order Update\n\nKeep a customer informed about their order via WhatsApp.\n\n## Steps\n1. Collect the customer phone number in full international format and the order details (number, status, tracking, ETA).\n2. Write a concise update that states what changed and includes the tracking link if available.\n3. Send the message and record the message ID and delivery state.\n\n## Output\nReport which customer was notified, the order referenced, and the message ID. Flag any number that could not be reached.', + '# Send a WhatsApp Order Update\n\nKeep a customer informed about their order via WhatsApp.\n\n## Steps\n1. Collect the customer phone number with the plus sign and country calling code, plus the order details (number, status, tracking, ETA).\n2. Choose the operation by conversation state: if the customer messaged you within the last 24 hours, Send Message works; otherwise use Send Template with an approved shipping-update template.\n3. State what changed and include the tracking link if available.\n4. Send and record the message ID.\n\n## Output\nReport which customer was notified, the order referenced, and the message ID. Flag any number that could not be reached, including the WhatsApp error code.', }, { name: 'broadcast-to-segment', description: - 'Send a personalized WhatsApp message to each contact in an audience list, one at a time.', + 'Send a personalized WhatsApp template message to each contact in an audience list, one at a time.', + content: + "# Broadcast a WhatsApp Message to a Segment\n\nDeliver a personalized message to every contact in a list.\n\nBroadcasts are business-initiated, so every send must use the Send Template operation with a pre-approved marketing or utility template. Free-form text will fail for any contact who has not messaged you in the last 24 hours.\n\n## Steps\n1. Read the audience list, each row holding a phone number and any personalization fields.\n2. For each contact, build the template components by filling the positional parameters with that row's values.\n3. Send one template message per contact, pacing them to stay within the account throughput limit.\n4. Track which sends succeeded and which failed.\n\n## Output\nReturn counts of messages sent and failed, plus a short list of failed recipients with the WhatsApp error code for each.", + }, + { + name: 'send-interactive-menu', + description: + 'Ask a WhatsApp contact to choose from reply buttons or a selectable list, then act on the reply.', + content: + '# Send a WhatsApp Interactive Menu\n\nOffer a contact a set of tappable choices instead of asking them to type a reply.\n\n## Steps\n1. Decide the variant: reply buttons for up to 3 short choices, a list for up to 10 options grouped into sections.\n2. Write the body text and give every choice a stable id — the webhook reports that id back when the contact taps it, so it is what your workflow branches on.\n3. Respect the limits: button titles 20 characters, list row titles 24, row descriptions 72, list menu button 20.\n4. Send with Send Interactive and record the message ID.\n5. Handle the reply in a workflow triggered by the WhatsApp webhook, matching on the returned id.\n\n## Output\nConfirm the choices offered and the message ID. When a reply arrives, report which option the contact selected.', + }, + { + name: 'acknowledge-incoming-message', + description: + 'Mark an incoming WhatsApp message as read, show a typing indicator, and react to it.', content: - '# Broadcast a WhatsApp Message to a Segment\n\nDeliver a personalized message to every contact in a list.\n\n## Steps\n1. Read the audience list, each row holding a phone number and any personalization fields.\n2. For each contact, build the message by filling in their name and relevant details.\n3. Send messages one per contact, pacing them to stay within WhatsApp rate and template limits.\n4. Track which sends succeeded and which failed.\n\n## Output\nReturn counts of messages sent and failed, plus a short list of failed recipients with the failure reason.', + '# Acknowledge an Incoming WhatsApp Message\n\nGive a contact immediate feedback that their message landed while a longer reply is being prepared.\n\n## Steps\n1. Take the message ID (wamid) from the WhatsApp webhook trigger payload.\n2. Call Mark As Read with that message ID so the sender sees blue checkmarks. Enable the typing indicator when a reply is coming — it clears once you respond or after 25 seconds.\n3. Optionally use Send Reaction with an emoji to acknowledge the message inline. Reactions are not delivered for messages over 30 days old, deleted messages, or other reactions.\n4. Send the substantive reply with Send Message — the incoming message opened the 24-hour window, so free-form text is allowed here.\n\n## Output\nConfirm the message was marked read and report the reply that was sent.', }, ], } as const satisfies BlockMeta diff --git a/apps/sim/lib/api/contracts/tools/whatsapp.ts b/apps/sim/lib/api/contracts/tools/whatsapp.ts new file mode 100644 index 00000000000..18465dd3c3d --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/whatsapp.ts @@ -0,0 +1,136 @@ +import { z } from 'zod' +import { + nonEmptyIdSchema, + userFileSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' +import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' + +const MAX_ACCESS_TOKEN_LENGTH = 8192 +const MAX_GRAPH_ID_LENGTH = 256 + +const whatsappAccessTokenSchema = z + .string() + .min(1, 'Access token is required') + .max(MAX_ACCESS_TOKEN_LENGTH, 'Access token is too long') + +const whatsappPhoneNumberIdSchema = z + .string() + .trim() + .min(1, 'Phone Number ID is required') + .max(MAX_GRAPH_ID_LENGTH, 'Phone Number ID is too long') + +const whatsappMediaIdSchema = z + .string() + .trim() + .min(1, 'Media ID is required') + .max(MAX_GRAPH_ID_LENGTH, 'Media ID is too long') + +const executionContextShape = { + workspaceId: workspaceIdSchema.optional(), + workflowId: workflowIdSchema.optional(), + executionId: nonEmptyIdSchema.optional(), +} + +export const whatsappUploadMediaBodySchema = z.object({ + accessToken: whatsappAccessTokenSchema, + phoneNumberId: whatsappPhoneNumberIdSchema, + file: RawFileInputSchema, +}) + +export const whatsappUploadMediaOutputSchema = z.object({ + mediaId: z.string().min(1).max(MAX_GRAPH_ID_LENGTH), + fileName: z.string().min(1), + mimeType: z.string().min(1), + size: z.number().int().nonnegative(), +}) + +export const whatsappSendMediaBodySchema = z.object({ + accessToken: whatsappAccessTokenSchema, + phoneNumberId: whatsappPhoneNumberIdSchema, + phoneNumber: z.string().trim().min(1, 'Recipient phone number is required').max(64), + mediaType: z.enum(['image', 'document', 'video', 'audio', 'sticker']), + /** Exactly one of file, mediaId, or mediaLink must be supplied. */ + file: RawFileInputSchema.optional().nullable(), + mediaId: whatsappMediaIdSchema.optional().nullable(), + mediaLink: z.string().trim().max(8192).optional().nullable(), + caption: z.string().max(1024, 'Caption cannot exceed 1024 characters').optional().nullable(), + filename: z.string().max(1024).optional().nullable(), +}) + +export const whatsappSendMediaOutputSchema = z.object({ + success: z.literal(true), + messageId: z.string().min(1), + messageStatus: z.string().optional(), + messagingProduct: z.string().optional(), + inputPhoneNumber: z.string().nullable(), + whatsappUserId: z.string().nullable(), + contacts: z.array(z.object({ input: z.string(), wa_id: z.string().nullable() })), + /** Set only when a file was uploaded as part of this send. */ + mediaId: z.string().optional(), +}) + +export const whatsappGetMediaBodySchema = z.object({ + accessToken: whatsappAccessTokenSchema, + mediaId: whatsappMediaIdSchema, + /** Optional ownership scoping check accepted by `GET /{media-id}`. */ + phoneNumberId: whatsappPhoneNumberIdSchema.optional(), + ...executionContextShape, +}) + +export const whatsappGetMediaOutputSchema = z.object({ + file: userFileSchema, + mediaId: z.string().min(1).max(MAX_GRAPH_ID_LENGTH), + mimeType: z.string().min(1), + fileSize: z.number().int().nonnegative(), + sha256: z.string().nullable(), +}) + +const whatsappRouteResponseSchema = (output: TOutput) => + z.discriminatedUnion('success', [ + z.object({ success: z.literal(true), output }), + z.object({ success: z.literal(false), error: z.string().min(1) }), + ]) + +export const whatsappUploadMediaResponseSchema = whatsappRouteResponseSchema( + whatsappUploadMediaOutputSchema +) +export const whatsappSendMediaResponseSchema = whatsappRouteResponseSchema( + whatsappSendMediaOutputSchema +) +export const whatsappGetMediaResponseSchema = whatsappRouteResponseSchema( + whatsappGetMediaOutputSchema +) + +export const whatsappUploadMediaContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/whatsapp/upload-media', + body: whatsappUploadMediaBodySchema, + response: { mode: 'json', schema: whatsappUploadMediaResponseSchema }, +}) + +export const whatsappSendMediaContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/whatsapp/send-media', + body: whatsappSendMediaBodySchema, + response: { mode: 'json', schema: whatsappSendMediaResponseSchema }, +}) + +export const whatsappGetMediaContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/whatsapp/get-media', + body: whatsappGetMediaBodySchema, + response: { mode: 'json', schema: whatsappGetMediaResponseSchema }, +}) + +export type WhatsAppUploadMediaBody = ContractBodyInput +export type WhatsAppSendMediaBody = ContractBodyInput +export type WhatsAppGetMediaBody = ContractBodyInput +export type WhatsAppUploadMediaRouteResponse = ContractJsonResponse< + typeof whatsappUploadMediaContract +> +export type WhatsAppSendMediaRouteResponse = ContractJsonResponse +export type WhatsAppGetMediaRouteResponse = ContractJsonResponse diff --git a/apps/sim/lib/webhooks/providers/whatsapp.test.ts b/apps/sim/lib/webhooks/providers/whatsapp.test.ts index e356365c051..c26763dea02 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.test.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.test.ts @@ -192,4 +192,88 @@ describe('WhatsApp webhook provider', () => { }, ]) }) + + async function formatMediaMessage(message: Record) { + const result = await whatsappHandler.formatInput!({ + webhook: { id: 'wh_media', providerConfig: {} }, + workflow: { id: 'wf_media', userId: 'user_media' }, + body: { + object: 'whatsapp_business_account', + entry: [ + { + changes: [ + { + field: 'messages', + value: { metadata: { phone_number_id: '12345' }, messages: [message] }, + }, + ], + }, + ], + }, + headers: {}, + requestId: 'wa-format-media', + }) + + return result.input as Record + } + + it('surfaces the media asset ID from an incoming image message', async () => { + const input = await formatMediaMessage({ + id: 'wamid.image.1', + from: '15550101', + timestamp: '1700000000', + type: 'image', + image: { + caption: 'Taj Mahal', + mime_type: 'image/jpeg', + sha256: 'abc123', + id: '1003383421387256', + }, + }) + + // The media asset ID is a different value from the wamid message ID. + expect(input.messageId).toBe('wamid.image.1') + expect(input.mediaId).toBe('1003383421387256') + expect(input.mediaMimeType).toBe('image/jpeg') + expect(input.caption).toBe('Taj Mahal') + }) + + it('surfaces the filename on an incoming document message', async () => { + const input = await formatMediaMessage({ + id: 'wamid.doc.1', + from: '15550101', + timestamp: '1700000000', + type: 'document', + document: { filename: 'receipt.pdf', mime_type: 'application/pdf', id: '999' }, + }) + + expect(input.mediaId).toBe('999') + expect((input.messages as Array>)[0].mediaFilename).toBe('receipt.pdf') + }) + + it('leaves media fields unset for a text message', async () => { + const input = await formatMediaMessage({ + id: 'wamid.text.1', + from: '15550101', + timestamp: '1700000000', + type: 'text', + text: { body: 'hello' }, + }) + + expect(input.mediaId).toBeUndefined() + expect(input.mediaMimeType).toBeUndefined() + expect(input.caption).toBeUndefined() + }) + + it('ignores a media type whose payload object is missing', async () => { + const input = await formatMediaMessage({ + id: 'wamid.image.2', + from: '15550101', + timestamp: '1700000000', + type: 'image', + }) + + expect(input.messageId).toBe('wamid.image.2') + expect(input.mediaId).toBeUndefined() + }) }) diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 46df92e3996..5bde7ef5de7 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -60,11 +60,41 @@ function normalizeWhatsAppContact(contact: Record) { } } +/** Message types whose payload carries a downloadable media asset. */ +const WHATSAPP_MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker']) + +/** + * Pull the media asset off a typed media message. WhatsApp nests it under a key + * matching the message `type` — `{ type: 'image', image: { id, mime_type, ... } }`. + * Note `media.id` is the media asset ID passed to Download Media, which is a + * different value from `message.id` (the `wamid.` message identifier). + */ +function extractWhatsAppMedia(message: Record) { + const type = typeof message.type === 'string' ? message.type : undefined + if (!type || !WHATSAPP_MEDIA_TYPES.has(type)) { + return undefined + } + + const media = isRecord(message[type]) ? (message[type] as Record) : undefined + if (!media) { + return undefined + } + + return { + mediaId: typeof media.id === 'string' ? media.id : undefined, + mediaMimeType: typeof media.mime_type === 'string' ? media.mime_type : undefined, + mediaSha256: typeof media.sha256 === 'string' ? media.sha256 : undefined, + mediaFilename: typeof media.filename === 'string' ? media.filename : undefined, + caption: typeof media.caption === 'string' ? media.caption : undefined, + } +} + function normalizeWhatsAppMessage( message: Record, metadata?: Record ) { const text = isRecord(message.text) ? message.text : undefined + const media = extractWhatsAppMedia(message) return { messageId: typeof message.id === 'string' ? message.id : undefined, @@ -78,6 +108,11 @@ function normalizeWhatsAppMessage( text: typeof text?.body === 'string' ? text.body : undefined, timestamp: typeof message.timestamp === 'string' ? message.timestamp : undefined, messageType: typeof message.type === 'string' ? message.type : undefined, + mediaId: media?.mediaId, + mediaMimeType: media?.mediaMimeType, + mediaSha256: media?.mediaSha256, + mediaFilename: media?.mediaFilename, + caption: media?.caption, raw: message, } } @@ -284,6 +319,11 @@ export const whatsappHandler: WebhookProviderHandler = { text?: string timestamp?: string messageType?: string + mediaId?: string + mediaMimeType?: string + mediaSha256?: string + mediaFilename?: string + caption?: string raw: Record }> = [] const statuses: Array<{ @@ -355,6 +395,9 @@ export const whatsappHandler: WebhookProviderHandler = { text: firstMessage?.text, timestamp: firstMessage?.timestamp ?? firstStatus?.timestamp, messageType: firstMessage?.messageType, + mediaId: firstMessage?.mediaId, + mediaMimeType: firstMessage?.mediaMimeType, + caption: firstMessage?.caption, status: firstStatus?.status, contact: contacts[0], webhookContacts: contacts, diff --git a/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts new file mode 100644 index 00000000000..87056804a6b --- /dev/null +++ b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.test.ts @@ -0,0 +1,201 @@ +/** + * @vitest-environment node + */ +import { afterAll, describe, expect, it, vi } from 'vitest' +import type { BlockState } from '@/stores/workflows/workflow/types' + +vi.unmock('@/blocks/registry') + +import * as blocksBarrel from '@/blocks' +import { getBlock as getRealBlock } from '@/blocks/registry' +import { backfillWhatsAppInteractiveType } from './whatsapp-interactive-type' + +/** + * Under `isolate: false` the module under test may already be cached from an + * earlier test file, bound to the global `@/blocks/registry` mock through the + * `@/blocks` barrel. `vi.unmock` alone cannot rebind that cached instance, so + * route the barrel's `getBlock` to the real registry via a spy on the shared + * barrel namespace — it patches whichever instance the cached module reads. + */ +const getBlockSpy = vi.spyOn(blocksBarrel, 'getBlock').mockImplementation(getRealBlock) + +afterAll(() => { + getBlockSpy.mockRestore() +}) + +const BUTTONS = '[{"type":"reply","reply":{"id":"yes","title":"Yes"}}]' +const SECTIONS = '[{"title":"Menu","rows":[{"id":"r1","title":"Row 1"}]}]' + +function whatsappBlock(values: Record): BlockState { + return { + id: 'block-1', + type: 'whatsapp', + name: 'WhatsApp', + position: { x: 0, y: 0 }, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) + ), + outputs: {}, + enabled: true, + } as BlockState +} + +function interactiveTypeOf(block: BlockState): unknown { + return block.subBlocks.interactiveType?.value +} + +describe('backfillWhatsAppInteractiveType', () => { + it('resolves a legacy list block to list so its sections survive serialization', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + bodyText: 'Pick one', + listButtonText: 'Menu', + sections: SECTIONS, + buttons: '', + }), + }) + + expect(migrated).toBe(true) + expect(interactiveTypeOf(blocks.b1)).toBe('list') + }) + + it('resolves a legacy reply-buttons block to button', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + bodyText: 'Pick one', + buttons: BUTTONS, + sections: '', + }), + }) + + expect(migrated).toBe(true) + expect(interactiveTypeOf(blocks.b1)).toBe('button') + }) + + it('writes a well-formed subblock entry using the configured control type', () => { + const { blocks } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ operation: 'send_interactive', buttons: BUTTONS }), + }) + + expect(blocks.b1.subBlocks.interactiveType).toEqual({ + id: 'interactiveType', + type: 'dropdown', + value: 'button', + }) + }) + + it('treats an unresolved block reference in sections as a configured list', () => { + const { blocks } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + sections: '', + buttons: '', + }), + }) + + expect(interactiveTypeOf(blocks.b1)).toBe('list') + }) + + it('treats an emptied array literal as unconfigured', () => { + const { blocks } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + sections: '[]', + buttons: BUTTONS, + }), + }) + + expect(interactiveTypeOf(blocks.b1)).toBe('button') + }) + + it('falls back to button when neither variant was configured', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ operation: 'send_interactive', bodyText: 'Pick one' }), + }) + + expect(migrated).toBe(true) + expect(interactiveTypeOf(blocks.b1)).toBe('button') + }) + + it('prefers button when both variants somehow hold a value, matching the tool', () => { + const { blocks } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ + operation: 'send_interactive', + buttons: BUTTONS, + sections: SECTIONS, + }), + }) + + expect(interactiveTypeOf(blocks.b1)).toBe('button') + }) + + it('is idempotent — a block that already carries a value is untouched', () => { + const input = { + b1: whatsappBlock({ + operation: 'send_interactive', + interactiveType: 'list', + sections: SECTIONS, + }), + } + + const { blocks, migrated } = backfillWhatsAppInteractiveType(input) + + expect(migrated).toBe(false) + expect(blocks.b1).toBe(input.b1) + }) + + it('skips WhatsApp blocks on other operations', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ operation: 'send_message', message: 'hi' }), + }) + + expect(migrated).toBe(false) + expect(interactiveTypeOf(blocks.b1)).toBeUndefined() + }) + + it('skips blocks of other types', () => { + const input: Record = { + b1: { + id: 'b1', + type: 'function', + name: 'Function', + position: { x: 0, y: 0 }, + subBlocks: { code: { id: 'code', type: 'code', value: 'return 1' } }, + outputs: {}, + enabled: true, + } as BlockState, + } + + const { blocks, migrated } = backfillWhatsAppInteractiveType(input) + + expect(migrated).toBe(false) + expect(blocks.b1).toBe(input.b1) + }) + + it('does not mutate the input blocks', () => { + const input = { + b1: whatsappBlock({ operation: 'send_interactive', sections: SECTIONS }), + } + + const { blocks } = backfillWhatsAppInteractiveType(input) + + expect(interactiveTypeOf(input.b1)).toBeUndefined() + expect(interactiveTypeOf(blocks.b1)).toBe('list') + expect(blocks).not.toBe(input) + }) + + it('migrates every affected block in one pass', () => { + const { blocks, migrated } = backfillWhatsAppInteractiveType({ + b1: whatsappBlock({ operation: 'send_interactive', sections: SECTIONS }), + b2: whatsappBlock({ operation: 'send_interactive', buttons: BUTTONS }), + b3: whatsappBlock({ operation: 'send_message', message: 'hi' }), + }) + + expect(migrated).toBe(true) + expect(interactiveTypeOf(blocks.b1)).toBe('list') + expect(interactiveTypeOf(blocks.b2)).toBe('button') + expect(interactiveTypeOf(blocks.b3)).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.ts b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.ts new file mode 100644 index 00000000000..cce163bd6f6 --- /dev/null +++ b/apps/sim/lib/workflows/migrations/whatsapp-interactive-type.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import { getBlock } from '@/blocks' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('WhatsAppInteractiveTypeMigration') + +const WHATSAPP_BLOCK_TYPE = 'whatsapp' +const INTERACTIVE_TYPE_ID = 'interactiveType' +const SEND_INTERACTIVE_OPERATION = 'send_interactive' + +/** + * A stored subblock value counts as configured when the user put something in it — + * either a JSON array with entries or a raw string (which may be an unresolved + * `` reference, so it must not be JSON-parsed to be recognized). + * An empty array literal is what the editor leaves behind after clearing a field, + * so it reads as unconfigured. + */ +function isConfigured(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0 + if (typeof value !== 'string') return false + const trimmed = value.trim() + return trimmed.length > 0 && trimmed !== '[]' +} + +/** + * Backfills `interactiveType` on WhatsApp `send_interactive` blocks saved before that + * discriminator existed. + * + * `buttons` and `sections` are now gated on `interactiveType`, but subblock defaults are + * only seeded when a block is first added to a workflow — never backfilled onto saved + * state. So a pre-existing block carries no value, both conditions evaluate false, and the + * serializer drops the configured payload: the tool then fails with "Provide either buttons + * (reply buttons) or sections (list)". Worse, opening such a block in the editor lets the + * dropdown seed `button` (it writes its default whenever the stored value is empty), which + * silently hides a list block's sections. This runs at load — ahead of that seeding, and + * across both live and deployed state — so the stored payload decides the variant instead. + * + * Precedence mirrors the tool's own `buttons ? 'button' : 'list'`: a block that somehow has + * both configured (which the tool rejected then and now) resolves to `button`. + */ +export function backfillWhatsAppInteractiveType(blocks: Record): { + blocks: Record + migrated: boolean +} { + const subBlockType = getBlock(WHATSAPP_BLOCK_TYPE)?.subBlocks?.find( + (config) => config.id === INTERACTIVE_TYPE_ID + )?.type + + if (!subBlockType) return { blocks, migrated: false } + + let anyMigrated = false + const result: Record = {} + + for (const [blockId, block] of Object.entries(blocks)) { + const subBlocks = block.subBlocks + if ( + block.type !== WHATSAPP_BLOCK_TYPE || + !subBlocks || + subBlocks.operation?.value !== SEND_INTERACTIVE_OPERATION || + isConfigured(subBlocks[INTERACTIVE_TYPE_ID]?.value) + ) { + result[blockId] = block + continue + } + + const resolved = + isConfigured(subBlocks.sections?.value) && !isConfigured(subBlocks.buttons?.value) + ? 'list' + : 'button' + + anyMigrated = true + result[blockId] = { + ...block, + subBlocks: { + ...subBlocks, + [INTERACTIVE_TYPE_ID]: { + id: INTERACTIVE_TYPE_ID, + type: subBlockType, + value: resolved, + }, + }, + } + + logger.info('Backfilled WhatsApp interactiveType', { + blockId: block.id, + interactiveType: resolved, + }) + } + + return { blocks: result, migrated: anyMigrated } +} diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 0fff85f8fd7..584d96cc566 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -28,6 +28,7 @@ import { backfillCanonicalModes, migrateSubblockIds, } from '@/lib/workflows/migrations/subblock-migrations' +import { backfillWhatsAppInteractiveType } from '@/lib/workflows/migrations/whatsapp-interactive-type' import { supersedeInFlightDeploymentOperations } from '@/lib/workflows/persistence/deployment-operations' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' @@ -277,6 +278,11 @@ const applyBlockMigrations = createMigrationPipeline([ return { ...ctx, blocks, migrated: ctx.migrated || migrated } }, + (ctx) => { + const { blocks, migrated } = backfillWhatsAppInteractiveType(ctx.blocks) + return { ...ctx, blocks, migrated: ctx.migrated || migrated } + }, + async (ctx) => { const { blocks, migrated } = await migrateCredentialIds( ctx.blocks, @@ -460,8 +466,9 @@ async function migrateCredentialIds( /** * Load workflow from normalized tables and apply all block migrations * (credential ID rewrites, agent message migration, subblock ID migrations, - * canonical-mode backfill, tool sanitization). Returns null if the workflow - * has not been migrated to normalized tables yet. + * WhatsApp interactive-type backfill, canonical-mode backfill, tool + * sanitization). Returns null if the workflow has not been migrated to + * normalized tables yet. */ export async function loadWorkflowFromNormalizedTables( workflowId: string, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 296a2bae42d..9dc641b5cdb 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -4530,12 +4530,14 @@ import { webflowUpdateItemTool, } from '@/tools/webflow' import { + whatsappGetMediaTool, whatsappMarkReadTool, whatsappSendInteractiveTool, whatsappSendMediaTool, whatsappSendMessageTool, whatsappSendReactionTool, whatsappSendTemplateTool, + whatsappUploadMediaTool, } from '@/tools/whatsapp' import { wikipediaPageContentTool, @@ -5928,6 +5930,8 @@ export const tools: Record = { whatsapp_send_interactive: whatsappSendInteractiveTool, whatsapp_send_reaction: whatsappSendReactionTool, whatsapp_mark_read: whatsappMarkReadTool, + whatsapp_upload_media: whatsappUploadMediaTool, + whatsapp_get_media: whatsappGetMediaTool, x_write: xWriteTool, x_read: xReadTool, x_search: xSearchTool, diff --git a/apps/sim/tools/whatsapp/get_media.ts b/apps/sim/tools/whatsapp/get_media.ts new file mode 100644 index 00000000000..506a77439c2 --- /dev/null +++ b/apps/sim/tools/whatsapp/get_media.ts @@ -0,0 +1,77 @@ +import type { ToolConfig } from '@/tools/types' +import type { WhatsAppGetMediaParams, WhatsAppGetMediaResponse } from '@/tools/whatsapp/types' + +function getExecutionContext(params: WhatsAppGetMediaParams): { + workspaceId?: string + workflowId?: string + executionId?: string +} { + const context = params._context + return { + workspaceId: typeof context?.workspaceId === 'string' ? context.workspaceId : undefined, + workflowId: typeof context?.workflowId === 'string' ? context.workflowId : undefined, + executionId: typeof context?.executionId === 'string' ? context.executionId : undefined, + } +} + +export const getMediaTool: ToolConfig = { + id: 'whatsapp_get_media', + name: 'WhatsApp Download Media', + description: + 'Download media a customer sent you. Takes the media ID from an incoming WhatsApp message and stores the file in the workflow.', + version: '1.0.0', + + params: { + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Media asset ID from an incoming message, e.g. . This is not the message ID (wamid).', + }, + phoneNumberId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'WhatsApp Business Phone Number ID (from Meta Business Suite)', + }, + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'WhatsApp Business API Access Token (from Meta Developer Portal)', + }, + }, + + request: { + url: '/api/tools/whatsapp/get-media', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + accessToken: params.accessToken, + mediaId: params.mediaId, + phoneNumberId: params.phoneNumberId, + ...getExecutionContext(params), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to download WhatsApp media') + } + return { success: true, output: data.output } + }, + + outputs: { + file: { type: 'file', description: 'Downloaded media stored as a workflow file' }, + mediaId: { type: 'string', description: 'WhatsApp media ID that was downloaded' }, + mimeType: { type: 'string', description: 'MIME type reported by WhatsApp' }, + fileSize: { type: 'number', description: 'Size of the downloaded media in bytes' }, + sha256: { + type: 'string', + description: 'SHA-256 hash WhatsApp reported for the media, for integrity checks', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/whatsapp/index.ts b/apps/sim/tools/whatsapp/index.ts index 50316221728..1fbc540a85c 100644 --- a/apps/sim/tools/whatsapp/index.ts +++ b/apps/sim/tools/whatsapp/index.ts @@ -1,3 +1,4 @@ +export { getMediaTool as whatsappGetMediaTool } from '@/tools/whatsapp/get_media' export { markReadTool as whatsappMarkReadTool } from '@/tools/whatsapp/mark_read' export { sendInteractiveTool as whatsappSendInteractiveTool } from '@/tools/whatsapp/send_interactive' export { sendMediaTool as whatsappSendMediaTool } from '@/tools/whatsapp/send_media' @@ -5,3 +6,4 @@ export { sendMessageTool as whatsappSendMessageTool } from '@/tools/whatsapp/sen export { sendReactionTool as whatsappSendReactionTool } from '@/tools/whatsapp/send_reaction' export { sendTemplateTool as whatsappSendTemplateTool } from '@/tools/whatsapp/send_template' export * from '@/tools/whatsapp/types' +export { uploadMediaTool as whatsappUploadMediaTool } from '@/tools/whatsapp/upload_media' diff --git a/apps/sim/tools/whatsapp/mark_read.ts b/apps/sim/tools/whatsapp/mark_read.ts index 585580ec47f..d4bcd23cc1c 100644 --- a/apps/sim/tools/whatsapp/mark_read.ts +++ b/apps/sim/tools/whatsapp/mark_read.ts @@ -1,11 +1,17 @@ import type { ToolConfig } from '@/tools/types' import type { WhatsAppMarkReadParams, WhatsAppMarkReadResponse } from '@/tools/whatsapp/types' -import { buildAuthHeaders, buildMessagesUrl, isRecord } from '@/tools/whatsapp/utils' +import { + buildAuthHeaders, + buildMessagesUrl, + extractWhatsAppErrorMessage, + parseWhatsAppResponse, +} from '@/tools/whatsapp/utils' export const markReadTool: ToolConfig = { id: 'whatsapp_mark_read', name: 'WhatsApp Mark As Read', - description: 'Mark a received WhatsApp message as read so the sender sees blue checkmarks.', + description: + 'Mark a received WhatsApp message as read so the sender sees blue checkmarks, optionally showing a typing indicator.', version: '1.0.0', params: { @@ -15,6 +21,13 @@ export const markReadTool: ToolConfig = { messaging_product: 'whatsapp', status: 'read', message_id: params.messageId.trim(), } + if (params.showTypingIndicator) { + body.typing_indicator = { type: 'text' } + } + return body }, }, transformResponse: async (response: Response) => { - const responseText = await response.text() - const parsed = responseText ? (JSON.parse(responseText) as unknown) : {} - const data = isRecord(parsed) ? parsed : {} - const error = isRecord(data.error) ? data.error : undefined + const data = await parseWhatsAppResponse(response) if (!response.ok) { - const errorMessage = - (typeof error?.message === 'string' ? error.message : undefined) || - (typeof error?.error_user_msg === 'string' ? error.error_user_msg : undefined) || - `WhatsApp API error (${response.status})` - throw new Error(errorMessage) + throw new Error(extractWhatsAppErrorMessage(data, response.status)) } return { diff --git a/apps/sim/tools/whatsapp/send_interactive.ts b/apps/sim/tools/whatsapp/send_interactive.ts index 16d2e76aa8b..5da33c438f9 100644 --- a/apps/sim/tools/whatsapp/send_interactive.ts +++ b/apps/sim/tools/whatsapp/send_interactive.ts @@ -34,39 +34,41 @@ export const sendInteractiveTool: ToolConfig = new Set(['image', 'video', 'document']) +import type { WhatsAppSendMediaParams, WhatsAppSendResponse } from '@/tools/whatsapp/types' +import { whatsappSendOutputs } from '@/tools/whatsapp/utils' export const sendMediaTool: ToolConfig = { id: 'whatsapp_send_media', name: 'WhatsApp Send Media', description: - 'Send an image, document, video, or audio message via a public link or an uploaded media ID.', + 'Send an image, document, video, audio, or sticker message. Accepts an uploaded file, a media ID, or a public link.', version: '1.0.0', params: { @@ -33,25 +20,33 @@ export const sendMediaTool: ToolConfig buildMessagesUrl(params.phoneNumberId), + url: '/api/tools/whatsapp/send-media', method: 'POST', - headers: (params) => buildAuthHeaders(params.accessToken), - body: (params) => { - if (!params.phoneNumber) { - throw new Error('Phone number is required but was not provided') - } - - const mediaType = params.mediaType?.trim() as WhatsAppMediaType - if (!MEDIA_TYPES.includes(mediaType)) { - throw new Error(`Media type must be one of: ${MEDIA_TYPES.join(', ')}`) - } - - const link = params.mediaLink?.trim() - const id = params.mediaId?.trim() - if (!link && !id) { - throw new Error('Either mediaLink or mediaId is required') - } - - const media: Record = id ? { id } : { link: link as string } - if (params.caption && CAPTION_TYPES.has(mediaType)) { - media.caption = params.caption - } - if (params.filename && mediaType === 'document') { - media.filename = params.filename - } - - return { - messaging_product: 'whatsapp', - recipient_type: 'individual', - to: params.phoneNumber.trim(), - type: mediaType, - [mediaType]: media, - } - }, + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + accessToken: params.accessToken, + phoneNumberId: params.phoneNumberId, + phoneNumber: params.phoneNumber, + mediaType: params.mediaType, + file: params.file, + mediaId: params.mediaId, + mediaLink: params.mediaLink, + caption: params.caption, + filename: params.filename, + }), }, - transformResponse: transformWhatsAppSendResponse, + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to send WhatsApp media') + } + return { success: true, output: data.output } + }, outputs: whatsappSendOutputs, } diff --git a/apps/sim/tools/whatsapp/send_message.ts b/apps/sim/tools/whatsapp/send_message.ts index fa8b3aabe29..b96125ba122 100644 --- a/apps/sim/tools/whatsapp/send_message.ts +++ b/apps/sim/tools/whatsapp/send_message.ts @@ -1,14 +1,17 @@ import type { ToolConfig } from '@/tools/types' import type { WhatsAppResponse, WhatsAppSendMessageParams } from '@/tools/whatsapp/types' - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} +import { + buildAuthHeaders, + buildMessagesUrl, + transformWhatsAppSendResponse, + whatsappSendOutputs, +} from '@/tools/whatsapp/utils' export const sendMessageTool: ToolConfig = { id: 'whatsapp_send_message', name: 'WhatsApp Send Message', - description: 'Send a text message through the WhatsApp Cloud API.', + description: + 'Send a free-form text message through the WhatsApp Cloud API. Only works inside the 24-hour customer service window — use Send Template to start a conversation.', version: '1.0.0', params: { @@ -22,7 +25,7 @@ export const sendMessageTool: ToolConfig { - if (!params.phoneNumberId) { - throw new Error('WhatsApp Phone Number ID is required') - } - return `https://graph.facebook.com/v25.0/${params.phoneNumberId.trim()}/messages` - }, + url: (params) => buildMessagesUrl(params.phoneNumberId), method: 'POST', - headers: (params) => { - if (!params.accessToken) { - throw new Error('WhatsApp Access Token is required') - } - return { - Authorization: `Bearer ${params.accessToken.trim()}`, - 'Content-Type': 'application/json', - } - }, + headers: (params) => buildAuthHeaders(params.accessToken), body: (params) => { if (!params.phoneNumber) { throw new Error('Phone number is required but was not provided') @@ -89,92 +79,7 @@ export const sendMessageTool: ToolConfig { - const responseText = await response.text() - const parsed = responseText ? (JSON.parse(responseText) as unknown) : {} - const data = isRecord(parsed) ? parsed : {} - const error = isRecord(data.error) ? data.error : undefined - - if (!response.ok) { - const errorMessage = - (typeof error?.message === 'string' ? error.message : undefined) || - (typeof error?.error_user_msg === 'string' ? error.error_user_msg : undefined) || - (isRecord(error?.error_data) && typeof error.error_data.details === 'string' - ? error.error_data.details - : undefined) || - `WhatsApp API error (${response.status})` - throw new Error(errorMessage) - } + transformResponse: transformWhatsAppSendResponse, - const contacts = Array.isArray(data.contacts) - ? data.contacts.filter(isRecord).map((contact) => ({ - input: typeof contact.input === 'string' ? contact.input : '', - wa_id: typeof contact.wa_id === 'string' ? contact.wa_id : null, - })) - : [] - const firstMessage = - Array.isArray(data.messages) && isRecord(data.messages[0]) ? data.messages[0] : undefined - const messageId = typeof firstMessage?.id === 'string' ? firstMessage.id : undefined - const messageStatus = - typeof firstMessage?.message_status === 'string' ? firstMessage.message_status : undefined - - if (!messageId) { - throw new Error('WhatsApp API response did not include a message ID') - } - - return { - success: true, - output: { - success: true, - messageId, - messageStatus, - messagingProduct: - typeof data.messaging_product === 'string' ? data.messaging_product : undefined, - inputPhoneNumber: contacts[0]?.input ?? null, - whatsappUserId: contacts[0]?.wa_id ?? null, - contacts, - }, - } - }, - - outputs: { - success: { type: 'boolean', description: 'WhatsApp message send success status' }, - messageId: { type: 'string', description: 'Unique WhatsApp message identifier' }, - messageStatus: { - type: 'string', - description: 'Initial delivery state returned by the API', - optional: true, - }, - messagingProduct: { - type: 'string', - description: 'Messaging product returned by the API', - optional: true, - }, - inputPhoneNumber: { - type: 'string', - description: 'Recipient phone number echoed back by WhatsApp', - optional: true, - }, - whatsappUserId: { - type: 'string', - description: 'WhatsApp user ID resolved for the recipient', - optional: true, - }, - contacts: { - type: 'array', - description: 'Recipient contact records returned by WhatsApp', - optional: true, - items: { - type: 'object', - properties: { - input: { type: 'string', description: 'Input phone number sent to the API' }, - wa_id: { - type: 'string', - description: 'WhatsApp user ID associated with the recipient', - optional: true, - }, - }, - }, - }, - }, + outputs: whatsappSendOutputs, } diff --git a/apps/sim/tools/whatsapp/send_reaction.ts b/apps/sim/tools/whatsapp/send_reaction.ts index 4664ab69d3c..65b87876742 100644 --- a/apps/sim/tools/whatsapp/send_reaction.ts +++ b/apps/sim/tools/whatsapp/send_reaction.ts @@ -25,7 +25,8 @@ export const sendReactionTool: ToolConfig = { + id: 'whatsapp_upload_media', + name: 'WhatsApp Upload Media', + description: + 'Upload a file to WhatsApp and get a media ID for sending. Uploaded media is retained for 30 days and can back many sends.', + version: '1.0.0', + + params: { + file: { + type: 'file', + required: true, + visibility: 'user-only', + description: + 'File to upload. WhatsApp limits: images 5 MB, video and audio 16 MB, documents 100 MB, stickers 500 KB.', + }, + phoneNumberId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'WhatsApp Business Phone Number ID (from Meta Business Suite)', + }, + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'WhatsApp Business API Access Token (from Meta Developer Portal)', + }, + }, + + request: { + url: '/api/tools/whatsapp/upload-media', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + accessToken: params.accessToken, + phoneNumberId: params.phoneNumberId, + file: params.file, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to upload media to WhatsApp') + } + return { success: true, output: data.output } + }, + + outputs: { + mediaId: { + type: 'string', + description: 'WhatsApp media ID. Pass this to Send Media to attach the uploaded file.', + }, + fileName: { type: 'string', description: 'Name of the uploaded file' }, + mimeType: { type: 'string', description: 'MIME type WhatsApp received the file as' }, + size: { type: 'number', description: 'Size of the uploaded file in bytes' }, + }, +} diff --git a/apps/sim/tools/whatsapp/utils.ts b/apps/sim/tools/whatsapp/utils.ts index 1a5b6c509f0..8b176422ebe 100644 --- a/apps/sim/tools/whatsapp/utils.ts +++ b/apps/sim/tools/whatsapp/utils.ts @@ -1,4 +1,4 @@ -import type { WhatsAppSendResponse } from '@/tools/whatsapp/types' +import type { WhatsAppMediaType, WhatsAppSendResponse } from '@/tools/whatsapp/types' /** WhatsApp Cloud API Graph version used by every outbound tool. */ export const WHATSAPP_GRAPH_VERSION = 'v25.0' @@ -11,6 +11,49 @@ export function buildMessagesUrl(phoneNumberId: string | undefined): string { return `https://graph.facebook.com/${WHATSAPP_GRAPH_VERSION}/${phoneNumberId.trim()}/messages` } +/** Build the media upload endpoint for a given business phone number ID. */ +export function buildMediaUploadUrl(phoneNumberId: string): string { + return `https://graph.facebook.com/${WHATSAPP_GRAPH_VERSION}/${encodeURIComponent(phoneNumberId.trim())}/media` +} + +/** Build the media metadata endpoint for a media ID, optionally scoped to a phone number. */ +export function buildMediaUrl(mediaId: string, phoneNumberId?: string): string { + const base = `https://graph.facebook.com/${WHATSAPP_GRAPH_VERSION}/${encodeURIComponent(mediaId.trim())}` + return phoneNumberId + ? `${base}?phone_number_id=${encodeURIComponent(phoneNumberId.trim())}` + : base +} + +/** + * Per-type upload ceilings documented in the WhatsApp Cloud API media reference. + * Enforced before any bytes leave Sim so oversized files fail with an actionable + * message instead of WhatsApp's generic error 131052. + */ +const WHATSAPP_MEDIA_LIMITS = [ + { prefix: 'image/', maxBytes: 5 * 1024 * 1024, label: 'Images (5 MB)' }, + { prefix: 'video/', maxBytes: 16 * 1024 * 1024, label: 'Videos (16 MB)' }, + { prefix: 'audio/', maxBytes: 16 * 1024 * 1024, label: 'Audio (16 MB)' }, +] as const + +/** Stickers are `image/webp` but carry a far tighter cap than other images. */ +const WHATSAPP_STICKER_MAX_BYTES = 500 * 1024 + +/** Documents carry the largest documented ceiling, so it doubles as the overall cap. */ +export const WHATSAPP_MEDIA_MAX_BYTES = 100 * 1024 * 1024 + +/** + * Resolve the documented upload ceiling for a MIME type. Unknown types fall back to + * the 100 MB document ceiling, which is also the largest WhatsApp accepts. + */ +export function whatsappMediaLimitFor(mimeType: string): { maxBytes: number; label: string } { + const normalized = mimeType.toLowerCase() + if (normalized === 'image/webp') { + return { maxBytes: WHATSAPP_STICKER_MAX_BYTES, label: 'Stickers (500 KB)' } + } + const match = WHATSAPP_MEDIA_LIMITS.find((limit) => normalized.startsWith(limit.prefix)) + return match ?? { maxBytes: WHATSAPP_MEDIA_MAX_BYTES, label: 'Documents (100 MB)' } +} + /** Build the shared Bearer auth headers for the WhatsApp Cloud API. */ export function buildAuthHeaders(accessToken: string | undefined): Record { if (!accessToken) { @@ -26,23 +69,91 @@ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -async function parseWhatsAppResponse(response: Response): Promise> { +export async function parseWhatsAppResponse(response: Response): Promise> { const responseText = await response.text() const parsed = responseText ? (JSON.parse(responseText) as unknown) : {} return isRecord(parsed) ? parsed : {} } -/** Extract a human-readable error message from a WhatsApp API error payload. */ -function extractErrorMessage(data: Record, status: number): string { +/** + * Extract a human-readable error message from a WhatsApp API error payload. + * + * The Cloud API error envelope is `{ error: { message, type, code, error_subcode, + * error_data: { details }, fbtrace_id } }`. `error_data.details` usually carries the + * actionable explanation while `message` carries the summary, so both are surfaced, + * along with the numeric `code` that the error-code reference is keyed on. + */ +export function extractWhatsAppErrorMessage(data: Record, status: number): string { const error = isRecord(data.error) ? data.error : undefined - return ( - (typeof error?.message === 'string' ? error.message : undefined) || - (typeof error?.error_user_msg === 'string' ? error.error_user_msg : undefined) || - (isRecord(error?.error_data) && typeof error.error_data.details === 'string' + const summary = typeof error?.message === 'string' ? error.message : undefined + const details = + isRecord(error?.error_data) && typeof error.error_data.details === 'string' ? error.error_data.details - : undefined) || - `WhatsApp API error (${status})` - ) + : undefined + const code = typeof error?.code === 'number' ? error.code : undefined + + const base = [summary, details].filter(Boolean).join(' — ') || `WhatsApp API error (${status})` + return code === undefined || base.includes(`${code}`) ? base : `${base} (code ${code})` +} + +export const WHATSAPP_MEDIA_TYPES = [ + 'image', + 'document', + 'video', + 'audio', + 'sticker', +] as const satisfies readonly WhatsAppMediaType[] + +/** Audio and sticker messages have no `caption` field; only documents accept `filename`. */ +const CAPTION_TYPES: ReadonlySet = new Set(['image', 'video', 'document']) + +interface MediaMessageInput { + phoneNumber?: string + mediaType?: string + mediaId?: string + mediaLink?: string + caption?: string + filename?: string +} + +/** + * Build the `/messages` body for a media send. Exactly one of `mediaId` or `mediaLink` + * must be set — the Cloud API treats `id` and `link` as mutually exclusive. + */ +export function buildMediaMessageBody(params: MediaMessageInput): Record { + if (!params.phoneNumber) { + throw new Error('Phone number is required but was not provided') + } + + const mediaType = params.mediaType?.trim() as WhatsAppMediaType + if (!WHATSAPP_MEDIA_TYPES.includes(mediaType)) { + throw new Error(`Media type must be one of: ${WHATSAPP_MEDIA_TYPES.join(', ')}`) + } + + const link = params.mediaLink?.trim() + const id = params.mediaId?.trim() + if (!link && !id) { + throw new Error('Either a file, a media ID, or a media link is required') + } + if (link && id) { + throw new Error('Provide either a media ID or a media link, not both') + } + + const media: Record = id ? { id } : { link: link as string } + if (params.caption && CAPTION_TYPES.has(mediaType)) { + media.caption = params.caption + } + if (params.filename && mediaType === 'document') { + media.filename = params.filename + } + + return { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: params.phoneNumber.trim(), + type: mediaType, + [mediaType]: media, + } } /** @@ -55,7 +166,7 @@ export async function transformWhatsAppSendResponse( const data = await parseWhatsAppResponse(response) if (!response.ok) { - throw new Error(extractErrorMessage(data, response.status)) + throw new Error(extractWhatsAppErrorMessage(data, response.status)) } const contacts = Array.isArray(data.contacts) @@ -98,7 +209,8 @@ export const whatsappSendOutputs = { messageId: { type: 'string', description: 'Unique WhatsApp message identifier' }, messageStatus: { type: 'string', - description: 'Initial delivery state returned by the API', + description: + 'Message pacing status when WhatsApp returns one: accepted, held_for_quality_assessment, or paused. Acceptance is not delivery — subscribe to the webhook trigger for delivery status.', optional: true, }, messagingProduct: { diff --git a/apps/sim/tools/whatsapp/whatsapp.test.ts b/apps/sim/tools/whatsapp/whatsapp.test.ts new file mode 100644 index 00000000000..da03761887b --- /dev/null +++ b/apps/sim/tools/whatsapp/whatsapp.test.ts @@ -0,0 +1,535 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { WhatsAppBlock } from '@/blocks/blocks/whatsapp' +import { getMediaTool } from '@/tools/whatsapp/get_media' +import { markReadTool } from '@/tools/whatsapp/mark_read' +import { sendInteractiveTool } from '@/tools/whatsapp/send_interactive' +import { sendMediaTool } from '@/tools/whatsapp/send_media' +import { sendMessageTool } from '@/tools/whatsapp/send_message' +import { sendReactionTool } from '@/tools/whatsapp/send_reaction' +import { sendTemplateTool } from '@/tools/whatsapp/send_template' +import { uploadMediaTool } from '@/tools/whatsapp/upload_media' +import { + buildMediaMessageBody, + buildMediaUploadUrl, + buildMediaUrl, + transformWhatsAppSendResponse, + whatsappMediaLimitFor, +} from '@/tools/whatsapp/utils' + +const auth = { phoneNumberId: ' 15550001111 ', accessToken: ' token ' } + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), init) +} + +describe('WhatsApp request URL and headers', () => { + it('trims the phone number ID into the versioned messages endpoint', () => { + expect(sendMessageTool.request.url(auth)).toBe( + 'https://graph.facebook.com/v25.0/15550001111/messages' + ) + }) + + it('trims the access token into a Bearer header', () => { + expect(sendMessageTool.request.headers!(auth)).toEqual({ + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + }) + }) + + it('throws when the phone number ID is missing', () => { + expect(() => sendMessageTool.request.url({ ...auth, phoneNumberId: '' })).toThrow( + /Phone Number ID is required/ + ) + }) +}) + +describe('sendMessageTool body', () => { + const base = { ...auth, phoneNumber: ' +14155552671 ', message: 'hi' } + + it('sends a trimmed recipient and omits preview_url when unset', () => { + expect(sendMessageTool.request.body!(base)).toEqual({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: '+14155552671', + type: 'text', + text: { body: 'hi' }, + }) + }) + + it('includes preview_url only when an explicit boolean is provided', () => { + expect(sendMessageTool.request.body!({ ...base, previewUrl: true })).toMatchObject({ + text: { body: 'hi', preview_url: true }, + }) + expect(sendMessageTool.request.body!({ ...base, previewUrl: false })).toMatchObject({ + text: { body: 'hi', preview_url: false }, + }) + }) +}) + +describe('sendTemplateTool body', () => { + const base = { ...auth, phoneNumber: '+14155552671', templateName: ' hello ', languageCode: 'en' } + + it('omits components when none are provided', () => { + expect(sendTemplateTool.request.body!(base)).toEqual({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: '+14155552671', + type: 'template', + template: { name: 'hello', language: { code: 'en' } }, + }) + }) + + it('parses a JSON string of components', () => { + const components = [{ type: 'body', parameters: [{ type: 'text', text: 'x' }] }] + expect( + sendTemplateTool.request.body!({ ...base, components: JSON.stringify(components) }) + ).toMatchObject({ template: { components } }) + }) + + it('rejects components that are not an array', () => { + expect(() => + sendTemplateTool.request.body!({ ...base, components: '{"type":"body"}' }) + ).toThrow(/must be a JSON array/) + }) +}) + +describe('buildMediaMessageBody', () => { + const base = { phoneNumber: '+14155552671' } + + it('sends a link under a key named after the media type', () => { + expect( + buildMediaMessageBody({ ...base, mediaType: 'image', mediaLink: ' https://x/a.png ' }) + ).toEqual({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: '+14155552671', + type: 'image', + image: { link: 'https://x/a.png' }, + }) + }) + + it('rejects an unsupported media type', () => { + expect(() => + buildMediaMessageBody({ ...base, mediaType: 'gif', mediaLink: 'https://x/a.gif' }) + ).toThrow(/Media type must be one of/) + }) + + it('supports stickers, which take no caption', () => { + const body = buildMediaMessageBody({ + ...base, + mediaType: 'sticker', + mediaId: '123', + caption: 'ignored', + }) + + expect(body).toMatchObject({ type: 'sticker' }) + expect(body.sticker).toEqual({ id: '123' }) + }) + + it('requires exactly one media source', () => { + expect(() => buildMediaMessageBody({ ...base, mediaType: 'image' })).toThrow( + /a file, a media ID, or a media link is required/ + ) + expect(() => + buildMediaMessageBody({ + ...base, + mediaType: 'image', + mediaLink: 'https://x/a.png', + mediaId: '123', + }) + ).toThrow(/not both/) + }) + + it('drops caption for audio and filename for non-documents', () => { + const audioBody = buildMediaMessageBody({ + ...base, + mediaType: 'audio', + mediaId: '1', + caption: 'nope', + filename: 'nope.mp3', + }) + expect(audioBody.audio).toEqual({ id: '1' }) + + const imageBody = buildMediaMessageBody({ + ...base, + mediaType: 'image', + mediaId: '1', + caption: 'shown', + filename: 'nope.png', + }) + expect(imageBody.image).toEqual({ id: '1', caption: 'shown' }) + + expect( + buildMediaMessageBody({ + ...base, + mediaType: 'document', + mediaId: '1', + caption: 'report', + filename: 'q3.pdf', + }) + ).toMatchObject({ document: { id: '1', caption: 'report', filename: 'q3.pdf' } }) + }) +}) + +describe('sendMediaTool', () => { + it('routes through the internal route so a file can be uploaded before sending', () => { + expect(sendMediaTool.request.url).toBe('/api/tools/whatsapp/send-media') + }) + + it('forwards every media source to the route', () => { + const file = { name: 'a.png', key: 'k', url: 'u', size: 1, type: 'image/png' } + expect( + sendMediaTool.request.body!({ ...auth, phoneNumber: '+1', mediaType: 'image', file }) + ).toMatchObject({ file, mediaType: 'image', phoneNumber: '+1' }) + }) +}) + +describe('sendInteractiveTool body', () => { + const base = { ...auth, phoneNumber: '+14155552671', bodyText: 'Pick one' } + const buttons = [{ type: 'reply', reply: { id: 'yes', title: 'Yes' } }] + const sections = [{ title: 'Menu', rows: [{ id: 'r1', title: 'Row 1' }] }] + + it('builds a button message with optional header and footer', () => { + expect( + sendInteractiveTool.request.body!({ + ...base, + buttons, + headerText: 'Header', + footerText: 'Footer', + }) + ).toMatchObject({ + type: 'interactive', + interactive: { + type: 'button', + body: { text: 'Pick one' }, + header: { type: 'text', text: 'Header' }, + footer: { text: 'Footer' }, + action: { buttons }, + }, + }) + }) + + it('builds a list message from sections and a menu button label', () => { + expect( + sendInteractiveTool.request.body!({ ...base, sections, listButtonText: ' Menu ' }) + ).toMatchObject({ + interactive: { type: 'list', action: { button: 'Menu', sections } }, + }) + }) + + it('requires a menu button label for lists', () => { + expect(() => sendInteractiveTool.request.body!({ ...base, sections })).toThrow( + /listButtonText is required/ + ) + }) + + it('requires exactly one of buttons and sections', () => { + expect(() => sendInteractiveTool.request.body!(base)).toThrow(/either buttons .* or sections/) + expect(() => + sendInteractiveTool.request.body!({ ...base, buttons, sections, listButtonText: 'Menu' }) + ).toThrow(/not both/) + }) + + it('treats an empty array as absent', () => { + expect(() => sendInteractiveTool.request.body!({ ...base, buttons: [] })).toThrow( + /either buttons .* or sections/ + ) + }) +}) + +describe('sendReactionTool body', () => { + const base = { ...auth, phoneNumber: '+14155552671', messageId: ' wamid.abc ' } + + it('trims the target message ID and sends the emoji', () => { + expect(sendReactionTool.request.body!({ ...base, emoji: '👍' })).toEqual({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: '+14155552671', + type: 'reaction', + reaction: { message_id: 'wamid.abc', emoji: '👍' }, + }) + }) + + it('sends an empty emoji to remove a reaction', () => { + expect(sendReactionTool.request.body!(base)).toMatchObject({ + reaction: { message_id: 'wamid.abc', emoji: '' }, + }) + }) +}) + +describe('markReadTool body', () => { + const base = { ...auth, messageId: ' wamid.abc ' } + + it('omits the typing indicator by default', () => { + expect(markReadTool.request.body!(base)).toEqual({ + messaging_product: 'whatsapp', + status: 'read', + message_id: 'wamid.abc', + }) + }) + + it('adds a text typing indicator when requested', () => { + expect(markReadTool.request.body!({ ...base, showTypingIndicator: true })).toMatchObject({ + typing_indicator: { type: 'text' }, + }) + }) + + it('reports success from the {"success": true} response', async () => { + const result = await markReadTool.transformResponse!(jsonResponse({ success: true })) + expect(result.output.success).toBe(true) + }) + + it('surfaces the API error message and code', async () => { + await expect( + markReadTool.transformResponse!( + jsonResponse( + { error: { message: 'Invalid parameter', code: 100, error_data: { details: 'bad id' } } }, + { status: 400 } + ) + ) + ).rejects.toThrow('Invalid parameter — bad id (code 100)') + }) +}) + +describe('WhatsApp media endpoints', () => { + it('builds the upload endpoint from a trimmed phone number ID', () => { + expect(buildMediaUploadUrl(' 15550001111 ')).toBe( + 'https://graph.facebook.com/v25.0/15550001111/media' + ) + }) + + it('builds the media metadata endpoint with and without the scoping param', () => { + expect(buildMediaUrl(' 987654321 ')).toBe('https://graph.facebook.com/v25.0/987654321') + expect(buildMediaUrl('987654321', '15550001111')).toBe( + 'https://graph.facebook.com/v25.0/987654321?phone_number_id=15550001111' + ) + }) + + it('encodes IDs so a crafted value cannot alter the path', () => { + expect(buildMediaUrl('../messages')).toBe('https://graph.facebook.com/v25.0/..%2Fmessages') + }) +}) + +describe('whatsappMediaLimitFor', () => { + it('applies the documented per-type ceilings', () => { + expect(whatsappMediaLimitFor('image/jpeg').maxBytes).toBe(5 * 1024 * 1024) + expect(whatsappMediaLimitFor('video/mp4').maxBytes).toBe(16 * 1024 * 1024) + expect(whatsappMediaLimitFor('audio/ogg').maxBytes).toBe(16 * 1024 * 1024) + expect(whatsappMediaLimitFor('application/pdf').maxBytes).toBe(100 * 1024 * 1024) + }) + + it('gives stickers the tighter cap even though they are images', () => { + expect(whatsappMediaLimitFor('image/webp').maxBytes).toBe(500 * 1024) + expect(whatsappMediaLimitFor('IMAGE/WEBP').maxBytes).toBe(500 * 1024) + }) + + it('falls back to the document ceiling for unknown types', () => { + expect(whatsappMediaLimitFor('application/x-thing').maxBytes).toBe(100 * 1024 * 1024) + }) +}) + +describe('WhatsAppBlock file param mapping', () => { + const userFile = { + id: 'f1', + name: 'invoice.pdf', + url: 'https://storage/invoice.pdf', + size: 1024, + type: 'application/pdf', + key: 'execution/ws/wf/ex/invoice.pdf', + } + + function mapParams(params: Record) { + return WhatsAppBlock.tools.config!.params!(params) as Record + } + + /** + * The serializer deletes the `uploadFile`/`uploadFileRef` subblock IDs and republishes + * the value under the `file` canonicalParamId, so the params mapper must read the + * canonical key. Reading a subblock ID here would silently upload nothing. + */ + it('maps the canonical file param onto the tool file param', () => { + expect(mapParams({ operation: 'upload_media', file: userFile }).file).toEqual(userFile) + }) + + it('parses a JSON-stringified file reference from advanced mode', () => { + expect(mapParams({ operation: 'upload_media', file: JSON.stringify(userFile) }).file).toEqual( + userFile + ) + }) + + it('unwraps a single-element array so a file reference resolves to one file', () => { + expect(mapParams({ operation: 'upload_media', file: [userFile] }).file).toEqual(userFile) + }) + + it('omits file entirely when nothing was uploaded', () => { + expect(mapParams({ operation: 'upload_media' })).not.toHaveProperty('file') + }) + + it('does not leak the UI-only params to the tool', () => { + const mapped = mapParams({ + operation: 'upload_media', + file: userFile, + interactiveType: 'button', + downloadMediaId: 'should-not-leak', + }) + + expect(mapped).not.toHaveProperty('interactiveType') + expect(mapped).not.toHaveProperty('downloadMediaId') + }) + + it('maps the download media ID onto the tool mediaId param', () => { + expect(mapParams({ operation: 'get_media', downloadMediaId: '123' }).mediaId).toBe('123') + }) + + describe('send_media media source', () => { + /** The canonical pair carries exactly one thing — a file. Upload basic, reference advanced. */ + it('pairs a file upload with a file reference and nothing else', () => { + const members = WhatsAppBlock.subBlocks.filter( + (sub) => sub.canonicalParamId === 'mediaSource' + ) + + expect(members.map((sub) => [sub.id, sub.type, sub.mode])).toEqual([ + ['sendMediaFile', 'file-upload', 'basic'], + ['sendMediaFileRef', 'short-input', 'advanced'], + ]) + }) + + it('resolves either pair member to the tool file param', () => { + expect(mapParams({ operation: 'send_media', mediaSource: userFile }).file).toEqual(userFile) + expect( + mapParams({ operation: 'send_media', mediaSource: JSON.stringify(userFile) }).file + ).toEqual(userFile) + }) + + /** + * Media ID and media link are separate concepts, so they stay separate subblocks and pass + * through untouched. Keeping them out of the pair is also what preserves workflows built + * before the file upload existed. + */ + it('passes a media ID or link through without touching the file param', () => { + const byId = mapParams({ operation: 'send_media', mediaId: '1003383421387256' }) + expect(byId.mediaId).toBe('1003383421387256') + expect(byId.file).toBeUndefined() + + const byLink = mapParams({ operation: 'send_media', mediaLink: 'https://legacy/a.png' }) + expect(byLink.mediaLink).toBe('https://legacy/a.png') + expect(byLink.file).toBeUndefined() + }) + + /** None of the three sources is statically required, so no path fails pre-execution validation. */ + it('leaves every media source optional so any one of them can satisfy the send', () => { + const sourceIds = ['sendMediaFile', 'sendMediaFileRef', 'mediaId', 'mediaLink'] + for (const id of sourceIds) { + const sub = WhatsAppBlock.subBlocks.find((candidate) => candidate.id === id) + expect(sub?.required, `${id} must not be required`).toBe(false) + } + expect(mapParams({ operation: 'send_media' })).not.toHaveProperty('file') + }) + }) +}) + +describe('media tool request bodies', () => { + it('forwards the file and credentials to the upload route', () => { + const file = { name: 'a.pdf', key: 'k', url: 'u', size: 10, type: 'application/pdf' } + expect(uploadMediaTool.request.url).toBe('/api/tools/whatsapp/upload-media') + expect(uploadMediaTool.request.body!({ ...auth, file })).toEqual({ + accessToken: ' token ', + phoneNumberId: ' 15550001111 ', + file, + }) + }) + + it('forwards execution context so the download is stored as an execution file', () => { + const body = getMediaTool.request.body!({ + ...auth, + mediaId: '123', + _context: { workspaceId: 'ws', workflowId: 'wf', executionId: 'ex', userId: 'u' }, + }) + + expect(body).toEqual({ + accessToken: ' token ', + phoneNumberId: ' 15550001111 ', + mediaId: '123', + workspaceId: 'ws', + workflowId: 'wf', + executionId: 'ex', + }) + }) + + it('omits execution context when the tool runs outside an execution', () => { + expect(getMediaTool.request.body!({ ...auth, mediaId: '123' })).toEqual({ + accessToken: ' token ', + phoneNumberId: ' 15550001111 ', + mediaId: '123', + workspaceId: undefined, + workflowId: undefined, + executionId: undefined, + }) + }) + + it('surfaces the route error message', async () => { + await expect( + getMediaTool.transformResponse!(jsonResponse({ success: false, error: 'Media not found' })) + ).rejects.toThrow('Media not found') + }) +}) + +describe('transformWhatsAppSendResponse', () => { + it('extracts the message ID, pacing status, and first contact', async () => { + const result = await transformWhatsAppSendResponse( + jsonResponse({ + messaging_product: 'whatsapp', + contacts: [{ input: '+14155552671', wa_id: '14155552671' }], + messages: [{ id: 'wamid.abc', message_status: 'accepted' }], + }) + ) + + expect(result.output).toEqual({ + success: true, + messageId: 'wamid.abc', + messageStatus: 'accepted', + messagingProduct: 'whatsapp', + inputPhoneNumber: '+14155552671', + whatsappUserId: '14155552671', + contacts: [{ input: '+14155552671', wa_id: '14155552671' }], + }) + }) + + it('tolerates a reaction response that omits message_status', async () => { + const result = await transformWhatsAppSendResponse( + jsonResponse({ + messaging_product: 'whatsapp', + contacts: [{ input: '+14155552671', wa_id: '14155552671' }], + messages: [{ id: 'wamid.abc' }], + }) + ) + + expect(result.output.messageId).toBe('wamid.abc') + expect(result.output.messageStatus).toBeUndefined() + }) + + it('nulls out contact fields when WhatsApp returns no contacts', async () => { + const result = await transformWhatsAppSendResponse( + jsonResponse({ messaging_product: 'whatsapp', messages: [{ id: 'wamid.abc' }] }) + ) + + expect(result.output.inputPhoneNumber).toBeNull() + expect(result.output.whatsappUserId).toBeNull() + expect(result.output.contacts).toEqual([]) + }) + + it('throws when the response carries no message ID', async () => { + await expect( + transformWhatsAppSendResponse(jsonResponse({ messaging_product: 'whatsapp', messages: [] })) + ).rejects.toThrow(/did not include a message ID/) + }) + + it('falls back to the status code when the error body has no message', async () => { + await expect(transformWhatsAppSendResponse(jsonResponse({}, { status: 503 }))).rejects.toThrow( + 'WhatsApp API error (503)' + ) + }) +}) diff --git a/apps/sim/triggers/whatsapp/webhook.ts b/apps/sim/triggers/whatsapp/webhook.ts index 8de96d84962..e5d0728d882 100644 --- a/apps/sim/triggers/whatsapp/webhook.ts +++ b/apps/sim/triggers/whatsapp/webhook.ts @@ -1,5 +1,5 @@ import { WhatsAppIcon } from '@/components/icons' -import type { TriggerConfig } from '../types' +import type { TriggerConfig } from '@/triggers/types' export const whatsappWebhookTrigger: TriggerConfig = { id: 'whatsapp_webhook', @@ -105,6 +105,19 @@ export const whatsappWebhookTrigger: TriggerConfig = { type: 'string', description: 'Type of the first incoming message in the batch (text, image, system, etc.)', }, + mediaId: { + type: 'string', + description: + 'Media asset ID from the first incoming media message. Pass to the Download Media operation to fetch the file. Expires after 7 days.', + }, + mediaMimeType: { + type: 'string', + description: 'MIME type of the first incoming media message', + }, + caption: { + type: 'string', + description: 'Caption on the first incoming image, video, or document message', + }, status: { type: 'string', description: diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 7bbfe14e8c6..63a5a59b65f 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: 973, - zodRoutes: 973, + totalRoutes: 975, + zodRoutes: 975, nonZodRoutes: 0, } as const From 9666533d0086f6076de6c616f6b9ac383bbce8bf Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 24 Jul 2026 16:20:03 -0700 Subject: [PATCH 31/32] fix(chat): sweep the text shimmer left-to-right again (#5926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5671 rewrote the shimmer-sweep keyframes to run 100% -> -100%, which is already left-to-right, but left the `reverse` from #5650 on the animation shorthand. The two flips cancel into a right-to-left sweep on every ShimmerText consumer: subagent labels, tool-call rows, and the new agent-stream thinking chrome. Drop `reverse` so direction lives in exactly one place — the keyframes. --- apps/sim/components/ui/shimmer-text.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/components/ui/shimmer-text.module.css b/apps/sim/components/ui/shimmer-text.module.css index d99f8740a04..f5a727623e4 100644 --- a/apps/sim/components/ui/shimmer-text.module.css +++ b/apps/sim/components/ui/shimmer-text.module.css @@ -13,7 +13,7 @@ -webkit-background-clip: text; background-clip: text; color: transparent; - animation: shimmer-sweep 2.2s linear infinite reverse; + animation: shimmer-sweep 2.2s linear infinite; } :global(.dark) .shimmer { From f43b52c56963217c82c239cd338bcd79c6f5df9f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 24 Jul 2026 16:45:40 -0700 Subject: [PATCH 32/32] fix(realtime): evict revoked collaborators from live workflow rooms (#5917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(realtime): evict revoked collaborators from live workflow rooms via periodic read-access re-validation * fix * fix(realtime): close join/eviction race and make sweep cleanups independent Re-authorize immediately before socket.join so an in-flight join cannot reverse a sweep eviction, and run the sweep's best-effort cleanups independently so a room-state failure cannot skip the presence broadcast. Co-Authored-By: Claude Fable 5 * fix(realtime): single-flight role resolution and retry failed eviction cleanup Coalesce concurrent role resolutions per (user, workflow) so a slow stale read can never overwrite a recorded revocation, and defer failed eviction room-state cleanups into a per-sweep retry queue so collaborators are not left with a stale presence entry. Co-Authored-By: Claude Fable 5 * fix(realtime): scope room removal to the target workflow and detect swallowed cleanup failures Honor the workflowIdHint as the target room in both room managers so removing a stale room cannot clobber the mapping of a room the socket has since moved to, and confirm eviction cleanup via the returned workflowId plus an unswallowed mapping read so Redis failures actually defer into the retry queue. Co-Authored-By: Claude Fable 5 * fix(realtime): treat unconfirmed removals as failures and refresh role cache on fresh verify Treat any null removal result as a failed cleanup (the sweep always passes the target room, so null only means failure — including with expired mapping keys), move the same-room rejoin guard to a synchronous check immediately before the removal, and record verifyWorkflowAccess's fresh decision into the role cache so a re-granted user is not blocked by a stale cached revocation. Co-Authored-By: Claude Fable 5 * fix(realtime): prefer a mid-flight recorded role decision over the in-flight query result If a fresh authoritative read (join-time verify) records a decision while a single-flighted resolution's query is in flight, keep the recorded decision instead of overwriting it with the potentially stale result. Co-Authored-By: Claude Fable 5 * fix(realtime): isolate the security scan from the Redis cleanup lane Run the revocation scan (local sockets + DB only) and the best-effort room-state cleanup as independently-guarded lanes so a hanging Redis command can stall only presence cleanup, never revocation enforcement. Evictions now enqueue cleanup instead of awaiting it, and the scan no longer reads presence for a fallback role. Co-Authored-By: Claude Fable 5 * fix(realtime): bound authorization waits in the revocation scan Race each socket's authorization check against a per-socket timeout and cap the whole pass with a budget below the sweep interval, so a hanging DB query skips that socket for the pass (never evicting on uncertainty) instead of wedging the scan lane and starving subsequent ticks. Co-Authored-By: Claude Fable 5 * fix(realtime): round-robin the revocation scan so hung checks cannot starve later sockets Resume each scan pass after the last target the previous pass processed, so a fixed prefix of hanging authorization checks can never repeatedly consume the pass budget and leave sockets behind it unexamined. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/realtime/src/access-revalidation.test.ts | 385 ++++++++++++++++++ apps/realtime/src/access-revalidation.ts | 309 ++++++++++++++ apps/realtime/src/handlers/workflow.test.ts | 58 ++- apps/realtime/src/handlers/workflow.ts | 25 +- apps/realtime/src/index.ts | 7 + .../src/middleware/permissions.test.ts | 136 ++++++- apps/realtime/src/middleware/permissions.ts | 107 +++-- apps/realtime/src/rooms/memory-manager.ts | 19 +- apps/realtime/src/rooms/redis-manager.ts | 20 +- apps/realtime/src/rooms/types.ts | 11 +- .../providers/socket-join-controller.ts | 13 + .../workspace/providers/socket-provider.tsx | 24 ++ apps/sim/hooks/use-collaborative-workflow.ts | 19 + packages/realtime-protocol/src/events.ts | 13 + 14 files changed, 1102 insertions(+), 44 deletions(-) create mode 100644 apps/realtime/src/access-revalidation.test.ts create mode 100644 apps/realtime/src/access-revalidation.ts diff --git a/apps/realtime/src/access-revalidation.test.ts b/apps/realtime/src/access-revalidation.test.ts new file mode 100644 index 00000000000..0688d5be334 --- /dev/null +++ b/apps/realtime/src/access-revalidation.test.ts @@ -0,0 +1,385 @@ +/** + * @vitest-environment node + * + * Tests for the periodic read-access re-validation sweep. The security contract: + * a socket is evicted only when its role resolves to `null` (a confirmed + * revocation), and a transient failure never evicts a still-authorized socket. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveRole } = vi.hoisted(() => ({ + mockResolveRole: vi.fn(), +})) + +vi.mock('@/middleware/permissions', () => ({ + resolveCurrentWorkflowRole: mockResolveRole, + ROLE_REVALIDATION_TTL_MS: 30_000, +})) + +import { + ACCESS_REVALIDATION_SWEEP_INTERVAL_MS, + startAccessRevalidationSweep, +} from '@/access-revalidation' +import type { IRoomManager, UserPresence } from '@/rooms' + +interface FakeSocket { + id: string + userId?: string + rooms: Set + emit: ReturnType + leave: ReturnType +} + +function makeSocket(id: string, userId: string | undefined, workflowId?: string): FakeSocket { + const rooms = new Set([id]) + if (workflowId) rooms.add(workflowId) + return { + id, + userId, + rooms, + emit: vi.fn(), + // Socket.IO's leave removes the room from `rooms` synchronously. + leave: vi.fn((room: string) => { + rooms.delete(room) + }), + } +} + +function makeManager(sockets: FakeSocket[], presence: Partial[] = []) { + const socketMap = new Map(sockets.map((s) => [s.id, s])) + const manager = { + io: { sockets: { sockets: socketMap } }, + isReady: () => true, + getWorkflowUsers: vi.fn().mockResolvedValue(presence), + getWorkflowIdForSocket: vi.fn().mockResolvedValue(null), + removeUserFromRoom: vi + .fn() + .mockImplementation(async (_socketId: string, workflowId?: string) => workflowId ?? null), + broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined), + } + return manager as unknown as IRoomManager & { + getWorkflowUsers: ReturnType + getWorkflowIdForSocket: ReturnType + removeUserFromRoom: ReturnType + broadcastPresenceUpdate: ReturnType + } +} + +describe('access-revalidation sweep', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('evicts a socket whose role has been revoked', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) + mockResolveRole.mockResolvedValue(null) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(socket.emit).toHaveBeenCalledWith( + 'access-revoked', + expect.objectContaining({ workflowId: 'wf-1' }) + ) + expect(socket.leave).toHaveBeenCalledWith('wf-1') + expect(manager.removeUserFromRoom).toHaveBeenCalledWith('sock-1', 'wf-1') + expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1') + }) + + it('keeps a socket whose access is still valid', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'write' }]) + mockResolveRole.mockResolvedValue('write') + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(socket.emit).not.toHaveBeenCalled() + expect(socket.leave).not.toHaveBeenCalled() + expect(manager.removeUserFromRoom).not.toHaveBeenCalled() + }) + + it('does not evict a downgraded-but-still-authorized socket', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'admin' }]) + // Downgraded admin -> read still resolves to a non-null role: keep the reader. + mockResolveRole.mockResolvedValue('read') + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(socket.emit).not.toHaveBeenCalled() + expect(socket.leave).not.toHaveBeenCalled() + }) + + it('never evicts when re-validation throws (transient failure)', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) + mockResolveRole.mockRejectedValue(new Error('db unreachable')) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(socket.emit).not.toHaveBeenCalled() + expect(socket.leave).not.toHaveBeenCalled() + expect(manager.removeUserFromRoom).not.toHaveBeenCalled() + }) + + it('resolves with the static safe fallback and no presence reads in the scan', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'admin' }]) + mockResolveRole.mockResolvedValue('admin') + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(mockResolveRole).toHaveBeenCalledWith('user-1', 'wf-1', 'read') + // The security scan must stay Redis-free — presence is never consulted. + expect(manager.getWorkflowUsers).not.toHaveBeenCalled() + }) + + it('evicts only the revoked socket, not co-members of the room', async () => { + const revoked = makeSocket('sock-1', 'user-1', 'wf-1') + const kept = makeSocket('sock-2', 'user-2', 'wf-1') + const manager = makeManager( + [revoked, kept], + [ + { socketId: 'sock-1', role: 'read' }, + { socketId: 'sock-2', role: 'write' }, + ] + ) + mockResolveRole.mockImplementation(async (userId: string) => + userId === 'user-1' ? null : 'write' + ) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(revoked.leave).toHaveBeenCalledWith('wf-1') + expect(kept.leave).not.toHaveBeenCalled() + expect(kept.emit).not.toHaveBeenCalled() + }) + + it('skips unauthenticated sockets and sockets not in a workflow room', async () => { + const noUser = makeSocket('sock-1', undefined, 'wf-1') + const noRoom = makeSocket('sock-2', 'user-2') + const manager = makeManager([noUser, noRoom]) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(mockResolveRole).not.toHaveBeenCalled() + expect(noUser.leave).not.toHaveBeenCalled() + expect(noRoom.leave).not.toHaveBeenCalled() + }) + + it('defers failed room-state cleanup and retries it on the next pass', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) + manager.removeUserFromRoom.mockRejectedValueOnce(new Error('redis down')) + mockResolveRole.mockResolvedValue(null) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + + expect(socket.leave).toHaveBeenCalledWith('wf-1') + expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled() + + // The evicted socket left the room, so membership scans no longer see it — + // the retry queue must drive the cleanup to completion. + await sweep.runOnce() + sweep.stop() + + expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2) + expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1') + }) + + it('defers cleanup when removal fails with expired socket mappings', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) + // Mapping keys already expired (lookup resolves null) AND the removal fails + // (the Redis manager swallows the transport error into null) — the failed + // removal must still defer instead of reading as success. + manager.removeUserFromRoom.mockResolvedValueOnce(null) + mockResolveRole.mockResolvedValue(null) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + + expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled() + + await sweep.runOnce() + sweep.stop() + + expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2) + expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1') + }) + + it('defers cleanup when the manager swallows a removal failure into null', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) + // Live mapping but the removal reports nothing removed — the Redis manager + // swallows transport errors into null, so this is the only failure signal. + manager.getWorkflowIdForSocket.mockResolvedValue('wf-1') + manager.removeUserFromRoom.mockResolvedValueOnce(null) + mockResolveRole.mockResolvedValue(null) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + + expect(socket.leave).toHaveBeenCalledWith('wf-1') + expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled() + + // Next pass: the removal now succeeds and the cleanup completes. + await sweep.runOnce() + sweep.stop() + + expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(2) + expect(manager.broadcastPresenceUpdate).toHaveBeenCalledWith('wf-1') + }) + + it('skips removal when the socket has since moved to a different workflow', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) + // Between the membership snapshot and cleanup, the socket switched to a + // workflow it can still access — removal must not touch its new presence. + manager.getWorkflowIdForSocket.mockResolvedValue('wf-2') + mockResolveRole.mockResolvedValue(null) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + sweep.stop() + + expect(socket.leave).toHaveBeenCalledWith('wf-1') + expect(manager.removeUserFromRoom).not.toHaveBeenCalled() + expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled() + }) + + it('drops a deferred cleanup when the socket legitimately re-joined the room', async () => { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) + manager.removeUserFromRoom.mockRejectedValueOnce(new Error('redis down')) + mockResolveRole.mockResolvedValueOnce(null) + + const sweep = startAccessRevalidationSweep(manager) + await sweep.runOnce() + expect(socket.leave).toHaveBeenCalledWith('wf-1') + + // Access restored and the socket re-joined the same room: the retry must + // NOT remove the fresh presence entry that re-join created. + socket.rooms.add('wf-1') + mockResolveRole.mockResolvedValue('read') + await sweep.runOnce() + sweep.stop() + + expect(manager.removeUserFromRoom).toHaveBeenCalledTimes(1) + expect(manager.broadcastPresenceUpdate).not.toHaveBeenCalled() + }) + + it('skips a socket whose authorization query hangs and still evicts the rest', async () => { + vi.useFakeTimers() + try { + const hung = makeSocket('sock-1', 'user-1', 'wf-1') + const revoked = makeSocket('sock-2', 'user-2', 'wf-1') + const manager = makeManager([hung, revoked]) + // user-1's authorization query hangs (wedged DB connection); user-2's + // resolves to a confirmed revocation. + mockResolveRole.mockImplementation(async (userId: string) => { + if (userId === 'user-1') return new Promise(() => {}) + return null + }) + + const sweep = startAccessRevalidationSweep(manager) + + // First tick starts the scan; the per-socket timeout fires at +5s and the + // scan moves on to evict the revoked socket in the same pass. + await vi.advanceTimersByTimeAsync(ACCESS_REVALIDATION_SWEEP_INTERVAL_MS) + await vi.advanceTimersByTimeAsync(10_000) + + expect(hung.leave).not.toHaveBeenCalled() + expect(revoked.leave).toHaveBeenCalledWith('wf-1') + + // The next tick's scan still runs — the hung query did not wedge the lane. + const callsAfterFirstPass = mockResolveRole.mock.calls.length + await vi.advanceTimersByTimeAsync(ACCESS_REVALIDATION_SWEEP_INTERVAL_MS) + await vi.advanceTimersByTimeAsync(10_000) + sweep.stop() + + expect(mockResolveRole.mock.calls.length).toBeGreaterThan(callsAfterFirstPass) + } finally { + vi.useRealTimers() + } + }) + + it('rotates the scan start so hung checks cannot starve later sockets', async () => { + vi.useFakeTimers() + try { + // Four hung authorization checks consume exactly the 20s pass budget + // (4 × 5s per-socket timeout); the revoked socket sits behind them. + const hungSockets = [1, 2, 3, 4].map((i) => makeSocket(`sock-${i}`, `user-${i}`, 'wf-1')) + const revoked = makeSocket('sock-5', 'user-5', 'wf-1') + const manager = makeManager([...hungSockets, revoked]) + mockResolveRole.mockImplementation(async (userId: string) => { + if (userId === 'user-5') return null + return new Promise(() => {}) + }) + + const sweep = startAccessRevalidationSweep(manager) + + // First pass burns its whole budget on the hung prefix. + await vi.advanceTimersByTimeAsync(ACCESS_REVALIDATION_SWEEP_INTERVAL_MS) + await vi.advanceTimersByTimeAsync(25_000) + expect(revoked.leave).not.toHaveBeenCalled() + + // Second pass resumes after the last processed socket, so the revoked + // socket is examined first and evicted. + await vi.advanceTimersByTimeAsync(10_000) + sweep.stop() + + expect(revoked.leave).toHaveBeenCalledWith('wf-1') + } finally { + vi.useRealTimers() + } + }) + + it('keeps scanning on later ticks while a deferred cleanup hangs', async () => { + vi.useFakeTimers() + try { + const socket = makeSocket('sock-1', 'user-1', 'wf-1') + const manager = makeManager([socket], [{ socketId: 'sock-1', role: 'read' }]) + // A Redis outage where commands hang in the offline queue instead of + // failing: the cleanup lane stalls, but scans must keep running. + manager.getWorkflowIdForSocket.mockReturnValue(new Promise(() => {})) + mockResolveRole.mockResolvedValue(null) + + const sweep = startAccessRevalidationSweep(manager) + + await vi.advanceTimersByTimeAsync(ACCESS_REVALIDATION_SWEEP_INTERVAL_MS) + expect(socket.leave).toHaveBeenCalledWith('wf-1') + const scansAfterFirstTick = mockResolveRole.mock.calls.length + + // Second socket appears while the first eviction's cleanup hangs. + const second = makeSocket('sock-2', 'user-2', 'wf-1') + const socketMap = manager.io.sockets.sockets as unknown as Map + socketMap.set('sock-2', second) + + await vi.advanceTimersByTimeAsync(ACCESS_REVALIDATION_SWEEP_INTERVAL_MS) + sweep.stop() + + // The hung cleanup did not block the next scan: the new socket was + // evaluated and evicted. + expect(mockResolveRole.mock.calls.length).toBeGreaterThan(scansAfterFirstTick) + expect(second.leave).toHaveBeenCalledWith('wf-1') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/realtime/src/access-revalidation.ts b/apps/realtime/src/access-revalidation.ts new file mode 100644 index 00000000000..18d63ea490d --- /dev/null +++ b/apps/realtime/src/access-revalidation.ts @@ -0,0 +1,309 @@ +import { createLogger } from '@sim/logger' +import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events' +import { sleep } from '@sim/utils/helpers' +import type { AuthenticatedSocket } from '@/middleware/auth' +import { ROLE_REVALIDATION_TTL_MS, resolveCurrentWorkflowRole } from '@/middleware/permissions' +import type { IRoomManager } from '@/rooms' + +const logger = createLogger('AccessRevalidation') + +/** + * How often each pod re-validates live read access for its connected sockets. + * + * Coupled to {@link ROLE_REVALIDATION_TTL_MS} — the same per-pod role cache and + * TTL that already bound *write* revocation — so a collaborator whose workspace + * permission is removed loses live *reads* within a comparable window instead of + * retaining them until they disconnect. Detection latency is one cache TTL plus + * up to one sweep interval (the sweep keeps seeing the cached non-null role + * until it expires), so ~30s typical and bounded well under a minute worst case. + */ +export const ACCESS_REVALIDATION_SWEEP_INTERVAL_MS = ROLE_REVALIDATION_TTL_MS + +/** + * Non-null fallback consumed by {@link resolveCurrentWorkflowRole} only on a + * transient DB failure with a cold cache, where returning a non-null role + * (never eviction) is the safe outcome during an outage. The scan deliberately + * does not read presence for a per-socket join-time role: that would put a + * Redis dependency inside the security-critical scan lane, and the fallback's + * only job is to be non-null. + */ +const FALLBACK_ROLE = 'read' + +/** + * Upper bound on a single socket's authorization check inside the scan. A DB + * query that hangs (wedged connection, exhausted pool, network partition) must + * not wedge the scan lane — on timeout the socket is skipped for this pass + * (never evicted) and re-checked next pass, where the single-flighted + * resolution is re-raced and acted on once it finally settles. + */ +const SCAN_SOCKET_TIMEOUT_MS = 5_000 + +/** + * Hard budget for one whole scan pass, deliberately below + * {@link ACCESS_REVALIDATION_SWEEP_INTERVAL_MS} so `scanRunning` can never + * starve subsequent ticks: a pass that runs out of budget ends early and the + * remaining sockets are evaluated on the next pass. + */ +const SCAN_PASS_BUDGET_MS = 20_000 + +const SCAN_TIMED_OUT = Symbol('scan-timed-out') + +export interface AccessRevalidationSweep { + /** Stop the periodic sweep (clears the interval). */ + stop: () => void + /** Run one full scan + cleanup pass sequentially. Exposed for deterministic testing. */ + runOnce: () => Promise +} + +interface ScanTarget { + workflowId: string + socket: AuthenticatedSocket + userId: string +} + +/** + * Collects this pod's authenticated sockets with the workflow room each has + * joined, in stable socket order. + * + * The workflow room is derived from the socket's own `rooms` set (pod-local, no + * Redis round-trips): a socket joins exactly one workflow room, so its rooms are + * `{ ownSocketId, workflowId }`. Only local sockets are evaluated — sockets are + * sticky to a pod, so every socket is swept by exactly one pod using that pod's + * warm role cache (mirroring the per-pod reasoning of the write-path cache). + */ +function collectScanTargets(io: IRoomManager['io']): ScanTarget[] { + const targets: ScanTarget[] = [] + for (const socket of io.sockets.sockets.values()) { + const authed = socket as AuthenticatedSocket + if (!authed.userId) continue + for (const room of socket.rooms) { + if (room === socket.id) continue + targets.push({ workflowId: room, socket: authed, userId: authed.userId }) + } + } + return targets +} + +/** + * Starts a per-pod loop that re-validates every connected socket's workspace + * role and evicts sockets whose access has been revoked, closing the read-side + * gap left by the join-only access check. + * + * Blip-safety: eviction fires *only* when {@link resolveCurrentWorkflowRole} + * returns `null`, which happens solely for a successful DB "no access" result or + * a previously-recorded revocation reused across a failure. A transient DB error + * against a still-authorized (or freshly-joined) user resolves to the last-known + * or fallback role, so a database blip never evicts anyone. + * + * Liveness: the loop runs as two independently-guarded lanes. The security scan + * (local sockets + DB role checks + emit/leave) touches no Redis at all; the + * best-effort room-state cleanup (Redis presence) runs in its own lane. A Redis + * outage — including commands that hang in the client's offline queue rather + * than failing — can therefore stall only presence cleanup, never revocation + * enforcement on subsequent ticks. Within the scan, every authorization wait is + * bounded ({@link SCAN_SOCKET_TIMEOUT_MS}) and the whole pass has a hard budget + * below the interval ({@link SCAN_PASS_BUDGET_MS}), so a hanging DB query can + * delay a socket's re-check but can never wedge the scan lane itself. + */ +export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessRevalidationSweep { + const io = roomManager.io + let scanRunning = false + let cleanupRunning = false + /** + * Round-robin cursor: the `${socketId}:${workflowId}` key of the last target + * the previous pass processed. Each pass resumes after it, so a fixed prefix + * of hanging authorization checks can never starve the sockets behind it — + * every target is examined within a bounded number of passes. + */ + let scanCursorKey: string | null = null + + /** + * Room-state cleanups owed for evicted sockets, keyed + * `${socketId}:${workflowId}`. Every eviction enqueues here (the evicted + * socket has already left the Socket.IO room, so membership scans will never + * see it again); the cleanup lane drains the queue until each removal is + * confirmed, so remaining collaborators do not keep a stale presence entry. + */ + const pendingCleanups = new Map() + + async function cleanupEvictedSocket(socketId: string, workflowId: string): Promise { + const key = `${socketId}:${workflowId}` + try { + // Unlike removeUserFromRoom, this read does not swallow transport errors, + // so a Redis outage lands in the catch below and defers the cleanup. + const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socketId) + if (currentWorkflowId !== null && currentWorkflowId !== workflowId) { + // The socket has since moved to a different workflow it can still + // access; that join's room switch already removed this room's presence + // entry, so there is nothing stale left to clean here. + pendingCleanups.delete(key) + return + } + + // Synchronous re-join guard with no awaits before the removal: if the + // socket legitimately re-joined this room after the eviction (access + // restored), that join re-added its presence — removal would erase it. + if (io.sockets.sockets.get(socketId)?.rooms.has(workflowId)) { + pendingCleanups.delete(key) + return + } + + const removed = await roomManager.removeUserFromRoom(socketId, workflowId) + if (removed === null) { + // The sweep always passes the target room, and both managers report a + // performed removal by returning it — the Redis manager swallows + // transport errors into null, so null means the removal did not happen + // (even when the socket's mapping keys have already expired). + throw new Error('room-state removal not confirmed') + } + + await roomManager.broadcastPresenceUpdate(workflowId) + pendingCleanups.delete(key) + } catch (error) { + pendingCleanups.set(key, { socketId, workflowId }) + logger.warn( + `Room-state cleanup failed for evicted socket ${socketId} on ${workflowId}; will retry next sweep`, + error + ) + } + } + + async function drainPendingCleanups(): Promise { + for (const [, { socketId, workflowId }] of pendingCleanups) { + await cleanupEvictedSocket(socketId, workflowId) + } + } + + /** + * Launches the cleanup lane unless it is already running or has nothing to + * do. Never awaited by the scan lane: a Redis command hanging in the client's + * offline queue stalls only this lane, never revocation enforcement. + */ + function launchCleanups(): void { + if (cleanupRunning || pendingCleanups.size === 0) { + return + } + cleanupRunning = true + drainPendingCleanups() + .catch((error) => logger.error('Deferred eviction cleanup failed', error)) + .finally(() => { + cleanupRunning = false + }) + } + + function revokeSocket(socket: AuthenticatedSocket, workflowId: string): void { + // Security-critical, pod-local, and synchronous: stop this socket receiving + // room broadcasts immediately. Room-state cleanup is only enqueued here — + // the cleanup lane performs the Redis work, so eviction never blocks on it. + const payload: AccessRevokedBroadcast = { + workflowId, + message: 'Your access to this workflow has been revoked', + timestamp: Date.now(), + } + socket.emit('access-revoked', payload) + socket.leave(workflowId) + + logger.info( + `Revoked live access for user ${socket.userId} on workflow ${workflowId} (socket ${socket.id})` + ) + + pendingCleanups.set(`${socket.id}:${workflowId}`, { socketId: socket.id, workflowId }) + } + + async function scanMemberships(): Promise { + const targets = collectScanTargets(io) + if (targets.length === 0) return + + let startIndex = 0 + if (scanCursorKey !== null) { + const cursorIndex = targets.findIndex( + ({ socket, workflowId }) => `${socket.id}:${workflowId}` === scanCursorKey + ) + if (cursorIndex !== -1) { + startIndex = (cursorIndex + 1) % targets.length + } + } + + const deadline = Date.now() + SCAN_PASS_BUDGET_MS + + for (let offset = 0; offset < targets.length; offset++) { + const { workflowId, socket, userId } = targets[(startIndex + offset) % targets.length] + + const remainingBudget = deadline - Date.now() + if (remainingBudget <= 0) { + logger.warn( + 'Access re-validation scan budget exhausted; remaining sockets defer to the next pass' + ) + return + } + + try { + // Bounded wait: a hanging authorization query skips this socket for + // the pass instead of wedging the scan lane. The single-flighted + // resolution keeps running in the background and is re-raced when the + // rotation returns to this socket, so it is acted on once it settles. + const role = await Promise.race([ + resolveCurrentWorkflowRole(userId, workflowId, FALLBACK_ROLE), + sleep(Math.min(SCAN_SOCKET_TIMEOUT_MS, remainingBudget)).then(() => SCAN_TIMED_OUT), + ]) + if (role === SCAN_TIMED_OUT) { + logger.warn( + `Authorization check timed out for user ${userId} on workflow ${workflowId}; skipping this pass` + ) + } else if (role === null) { + revokeSocket(socket, workflowId) + } + } catch (error) { + // Never evict on an unexpected error — only a definitive `null` role + // evicts, so a failure here leaves the socket's access intact. + logger.warn( + `Access re-validation failed for user ${userId} on workflow ${workflowId}; leaving membership intact`, + error + ) + } finally { + scanCursorKey = `${socket.id}:${workflowId}` + } + } + } + + async function runOnce(): Promise { + await scanMemberships() + if (!cleanupRunning && pendingCleanups.size > 0) { + cleanupRunning = true + try { + await drainPendingCleanups() + } finally { + cleanupRunning = false + } + } + } + + const timer = setInterval(() => { + if (scanRunning) { + logger.warn('Skipping access re-validation scan; previous scan still running') + } else { + scanRunning = true + scanMemberships() + .catch((error) => logger.error('Access re-validation scan failed', error)) + .finally(() => { + scanRunning = false + // Freshly-enqueued evictions get their cleanup promptly rather than + // waiting a full interval. + launchCleanups() + }) + } + launchCleanups() + }, ACCESS_REVALIDATION_SWEEP_INTERVAL_MS) + + // Do not keep the process alive solely for this timer. + timer.unref?.() + + logger.info( + `Access re-validation sweep started (every ${ACCESS_REVALIDATION_SWEEP_INTERVAL_MS}ms)` + ) + + return { + stop: () => clearInterval(timer), + runOnce, + } +} diff --git a/apps/realtime/src/handlers/workflow.test.ts b/apps/realtime/src/handlers/workflow.test.ts index 9dd82db87c4..ad649f88f53 100644 --- a/apps/realtime/src/handlers/workflow.test.ts +++ b/apps/realtime/src/handlers/workflow.test.ts @@ -4,10 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { IRoomManager } from '@/rooms' -const { mockGetWorkflowState, mockVerifyWorkflowAccess } = vi.hoisted(() => ({ - mockGetWorkflowState: vi.fn(), - mockVerifyWorkflowAccess: vi.fn(), -})) +const { mockGetWorkflowState, mockVerifyWorkflowAccess, mockResolveCurrentWorkflowRole } = + vi.hoisted(() => ({ + mockGetWorkflowState: vi.fn(), + mockVerifyWorkflowAccess: vi.fn(), + mockResolveCurrentWorkflowRole: vi.fn(), + })) vi.mock('@sim/db', () => ({ db: { select: vi.fn() }, @@ -20,6 +22,7 @@ vi.mock('@/database/operations', () => ({ vi.mock('@/middleware/permissions', () => ({ verifyWorkflowAccess: mockVerifyWorkflowAccess, + resolveCurrentWorkflowRole: mockResolveCurrentWorkflowRole, })) import { setupWorkflowHandlers } from '@/handlers/workflow' @@ -86,6 +89,7 @@ describe('setupWorkflowHandlers', () => { vi.clearAllMocks() mockGetWorkflowState.mockResolvedValue({ id: 'workflow-1', state: {} }) mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true, role: 'admin' }) + mockResolveCurrentWorkflowRole.mockResolvedValue('admin') }) it('includes workflowId when authentication is missing', async () => { @@ -149,6 +153,52 @@ describe('setupWorkflowHandlers', () => { }) }) + it('denies the join when access is revoked while the join is in flight', async () => { + mockResolveCurrentWorkflowRole.mockResolvedValue(null) + + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' }) + + expect(socket.emit).toHaveBeenCalledWith('join-workflow-error', { + workflowId: 'workflow-1', + error: 'Access denied to workflow', + code: 'ACCESS_DENIED', + retryable: false, + }) + expect(socket.join).not.toHaveBeenCalled() + expect(roomManager.addUserToRoom).not.toHaveBeenCalled() + }) + + it('joins with the re-validated role, passing the join-time role as fallback', async () => { + mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true, role: 'write' }) + mockResolveCurrentWorkflowRole.mockResolvedValue('read') + + const { socket, handlers } = createSocket() + const roomManager = createRoomManager() + + setupWorkflowHandlers( + socket as unknown as Parameters[0], + roomManager + ) + + await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' }) + + expect(mockResolveCurrentWorkflowRole).toHaveBeenCalledWith('user-1', 'workflow-1', 'write') + expect(socket.join).toHaveBeenCalledWith('workflow-1') + expect(roomManager.addUserToRoom).toHaveBeenCalledWith( + 'workflow-1', + 'socket-1', + expect.objectContaining({ role: 'read' }) + ) + }) + it('marks workflow access verification failures as retryable', async () => { mockVerifyWorkflowAccess.mockRejectedValue(new Error('database unavailable')) diff --git a/apps/realtime/src/handlers/workflow.ts b/apps/realtime/src/handlers/workflow.ts index da977fcdb5e..2c00895b201 100644 --- a/apps/realtime/src/handlers/workflow.ts +++ b/apps/realtime/src/handlers/workflow.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { getWorkflowState } from '@/database/operations' import type { AuthenticatedSocket } from '@/middleware/auth' -import { verifyWorkflowAccess } from '@/middleware/permissions' +import { resolveCurrentWorkflowRole, verifyWorkflowAccess } from '@/middleware/permissions' import type { IRoomManager, UserPresence } from '@/rooms' const logger = createLogger('WorkflowHandlers') @@ -131,6 +131,29 @@ export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: } } + // Re-authorize immediately before joining: the access-revalidation sweep + // may have evicted this socket while the awaits above were in flight, and + // its eviction is recorded in the shared role cache before it runs — so a + // revoked user resolves to null here. The resolver is single-flighted per + // (user, workflow), so this read cannot race the sweep's and overwrite a + // recorded revocation with a stale role; and no awaits sit between this + // check and socket.join, so a sweep eviction cannot interleave after it + // and be reversed by this join. + const currentRole = await resolveCurrentWorkflowRole(userId, workflowId, userRole) + if (currentRole === null) { + logger.warn( + `User ${userId} (${userName}) lost access to workflow ${workflowId} before join completed` + ) + socket.emit('join-workflow-error', { + workflowId, + error: 'Access denied to workflow', + code: 'ACCESS_DENIED', + retryable: false, + }) + return + } + userRole = currentRole + // Join the new room socket.join(workflowId) diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index e43184c1f02..3f7f7eb22d0 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -1,6 +1,7 @@ import { createServer } from 'http' import { createLogger } from '@sim/logger' import type { Server as SocketIOServer } from 'socket.io' +import { startAccessRevalidationSweep } from '@/access-revalidation' import { createSocketIOServer, shutdownSocketIOAdapter } from '@/config/socket' import { assertSchemaCompatibility } from '@/database/preflight' import { env } from '@/env' @@ -94,6 +95,10 @@ async function main() { setupAllHandlers(socket, roomManager) }) + // Bound read-access staleness: periodically re-validate connected sockets and + // evict any whose workspace permission has been revoked, matching the write path. + const accessRevalidation = startAccessRevalidationSweep(roomManager) + await assertSchemaCompatibility() httpServer.listen(PORT, '0.0.0.0', () => { @@ -104,6 +109,8 @@ async function main() { const shutdown = async () => { logger.info('Shutting down Socket.IO server...') + accessRevalidation.stop() + try { await roomManager.shutdown() logger.info('RoomManager shutdown complete') diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts index c1078b8c0c0..d9f0b4d15f0 100644 --- a/apps/realtime/src/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -23,7 +23,24 @@ vi.mock('@sim/platform-authz/workflow', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorize, })) -import { checkRolePermission, checkWorkflowOperationPermission } from '@/middleware/permissions' +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(async () => [{ workspaceId: 'ws-1', name: 'Test Workflow' }]), + })), + })), + })), + }, +})) + +import { + checkRolePermission, + checkWorkflowOperationPermission, + resolveCurrentWorkflowRole, + verifyWorkflowAccess, +} from '@/middleware/permissions' describe('checkRolePermission', () => { describe('admin role', () => { @@ -414,3 +431,120 @@ describe('checkWorkflowOperationPermission', () => { } }) }) + +describe('resolveCurrentWorkflowRole single-flight', () => { + const userId = 'sf-user-1' + let workflowCounter = 0 + let workflowId: string + + beforeEach(() => { + vi.clearAllMocks() + // Unique workflowId per test so the module-level role cache never leaks across tests + workflowCounter += 1 + workflowId = `sf-wf-${workflowCounter}` + }) + + it('coalesces concurrent resolutions into a single authorization query', async () => { + let resolveAuthorize!: (value: { allowed: boolean; workspacePermission: string | null }) => void + mockAuthorize.mockReturnValue( + new Promise((resolve) => { + resolveAuthorize = resolve + }) + ) + + // Both callers race the same expired/cold cache entry; they must share one + // in-flight query so a slower duplicate can never overwrite a newer + // decision (e.g. a revocation recorded by the eviction sweep). + const first = resolveCurrentWorkflowRole(userId, workflowId, 'read') + const second = resolveCurrentWorkflowRole(userId, workflowId, 'read') + + resolveAuthorize({ allowed: true, workspacePermission: 'write' }) + + expect(await first).toBe('write') + expect(await second).toBe('write') + expect(mockAuthorize).toHaveBeenCalledTimes(1) + }) + + it('does not coalesce resolutions for different workflows', async () => { + mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' }) + + const [first, second] = await Promise.all([ + resolveCurrentWorkflowRole(userId, workflowId, 'read'), + resolveCurrentWorkflowRole(userId, `${workflowId}-other`, 'read'), + ]) + + expect(first).toBe('read') + expect(second).toBe('read') + expect(mockAuthorize).toHaveBeenCalledTimes(2) + }) + + it('starts a fresh query after an in-flight resolution settles and its cache entry expires', async () => { + vi.useFakeTimers() + try { + mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' }) + expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBe('write') + + vi.advanceTimersByTime(31_000) + mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null }) + + expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBeNull() + expect(mockAuthorize).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('verifyWorkflowAccess role-cache refresh', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('records the fresh decision so a stale cached revocation does not block a re-granted join', async () => { + const userId = 'vw-user-1' + const workflowId = 'vw-wf-1' + + // A sweep-style resolution records the revocation. + mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null }) + expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBeNull() + + // Access is restored and a fresh join-time verify succeeds. + mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' }) + const access = await verifyWorkflowAccess(userId, workflowId) + expect(access.hasAccess).toBe(true) + + // The pre-join gate's warm read now sees the fresh role, not the stale + // cached null recorded before the re-grant. + mockAuthorize.mockRejectedValue(new Error('must not re-query')) + expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBe('write') + }) + + it('does not let a stale in-flight resolution overwrite a fresher verify decision', async () => { + const userId = 'vw-user-2' + const workflowId = 'vw-wf-2' + + // A sweep-style resolution starts against pre-re-grant state and stalls. + let resolveStaleQuery!: (value: { + allowed: boolean + workspacePermission: string | null + }) => void + mockAuthorize.mockReturnValueOnce( + new Promise((resolve) => { + resolveStaleQuery = resolve + }) + ) + const staleResolution = resolveCurrentWorkflowRole(userId, workflowId, 'read') + + // Access is re-granted and a fresh join-time verify records it mid-flight. + mockAuthorize.mockResolvedValueOnce({ allowed: true, workspacePermission: 'write' }) + await verifyWorkflowAccess(userId, workflowId) + + // The stale query now completes with the pre-re-grant revocation — it must + // yield to the fresher recorded decision, not overwrite it. + resolveStaleQuery({ allowed: false, workspacePermission: null }) + expect(await staleResolution).toBe('write') + + mockAuthorize.mockRejectedValue(new Error('must not re-query')) + expect(await resolveCurrentWorkflowRole(userId, workflowId, 'read')).toBe('write') + }) +}) diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 4f4a4296c16..779814257bf 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -85,11 +85,13 @@ export function checkRolePermission( } /** - * TTL for the per-pod role cache backing live re-validation of mutating operations. - * Bounds how long a revoked or downgraded collaborator can retain write access on an - * already-connected socket. + * TTL for the per-pod role cache backing live re-validation. It gates both the + * mutating-operation checks ({@link checkWorkflowOperationPermission}) and the + * periodic read-access sweep (`access-revalidation.ts`), so a revoked or + * downgraded collaborator loses write access — and live reads — on an + * already-connected socket within a bounded window rather than until disconnect. */ -const ROLE_REVALIDATION_TTL_MS = 30_000 +export const ROLE_REVALIDATION_TTL_MS = 30_000 /** Soft cap on cached entries before an opportunistic purge of expired ones runs. */ const MAX_ROLE_CACHE_ENTRIES = 5_000 @@ -110,6 +112,15 @@ interface CachedRole { */ const roleCache = new Map() +/** + * In-flight resolutions keyed like {@link roleCache}. Concurrent callers for the same + * (user, workflow) share one authorization query instead of racing independent ones, so + * cache writes per key are serialized — a slow, stale pre-revocation read can never + * overwrite a newer recorded decision (e.g. the revocation the eviction sweep just + * cached before kicking the socket). + */ +const inFlightRoleResolutions = new Map>() + function purgeExpiredRoles(now: number): void { for (const [key, entry] of roleCache) { if (entry.expiresAt <= now) { @@ -119,27 +130,27 @@ function purgeExpiredRoles(now: number): void { } /** - * Resolves a user's current workspace role for a workflow, re-reading the `permissions` - * table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod. - * - * Returns `null` when the user genuinely has no access (removed/revoked). On a transient - * DB failure it reuses the last recorded decision for this (user, workflow) — including a - * previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no - * decision has been recorded yet, so a blip neither blocks legitimate editors nor - * resurrects already-revoked access. + * Records a freshly-read authoritative decision into the role cache. Every + * successful DB read of a user's workspace role goes through this — including + * the join-time {@link verifyWorkflowAccess} — so a stale cached revocation + * never outlives a newer authoritative read (e.g. a re-granted user re-joining + * within the TTL of the sweep's recorded `null`). */ -export async function resolveCurrentWorkflowRole( +function recordRoleDecision(key: string, role: string | null): void { + const now = Date.now() + if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) { + purgeExpiredRoles(now) + } + roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS }) +} + +async function resolveRoleUncached( + key: string, userId: string, workflowId: string, fallbackRole: string ): Promise { - const now = Date.now() - const key = `${userId}:${workflowId}` - const cached = roleCache.get(key) - if (cached && cached.expiresAt > now) { - return cached.role - } - + const entryBeforeQuery = roleCache.get(key) try { const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, @@ -147,10 +158,15 @@ export async function resolveCurrentWorkflowRole( action: 'read', }) const role = authorization.allowed ? (authorization.workspacePermission ?? null) : null - if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) { - purgeExpiredRoles(now) + // A fresh authoritative read (e.g. a join-time verifyWorkflowAccess) may + // have recorded a decision while this query was in flight. That write is + // newer than this query's read snapshot, so prefer it instead of + // overwriting it with a potentially stale result. + const entryAfterQuery = roleCache.get(key) + if (entryAfterQuery !== undefined && entryAfterQuery !== entryBeforeQuery) { + return entryAfterQuery.role } - roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS }) + recordRoleDecision(key, role) return role } catch (error) { logger.warn( @@ -166,6 +182,41 @@ export async function resolveCurrentWorkflowRole( } } +/** + * Resolves a user's current workspace role for a workflow, re-reading the `permissions` + * table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod. Concurrent calls for + * the same (user, workflow) coalesce onto a single in-flight query (single-flight), so + * out-of-order cache writes cannot resurrect revoked access. + * + * Returns `null` when the user genuinely has no access (removed/revoked). On a transient + * DB failure it reuses the last recorded decision for this (user, workflow) — including a + * previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no + * decision has been recorded yet, so a blip neither blocks legitimate editors nor + * resurrects already-revoked access. + */ +export async function resolveCurrentWorkflowRole( + userId: string, + workflowId: string, + fallbackRole: string +): Promise { + const key = `${userId}:${workflowId}` + const cached = roleCache.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.role + } + + const inFlight = inFlightRoleResolutions.get(key) + if (inFlight) { + return inFlight + } + + const resolution = resolveRoleUncached(key, userId, workflowId, fallbackRole).finally(() => { + inFlightRoleResolutions.delete(key) + }) + inFlightRoleResolutions.set(key, resolution) + return resolution +} + /** * Live permission gate for mutating socket operations. Re-validates the user's workspace * role against the database (cached per pod for {@link ROLE_REVALIDATION_TTL_MS}) so that @@ -195,6 +246,11 @@ export async function checkWorkflowOperationPermission( * Returns `hasAccess: false` only for genuine denials (workflow missing/archived * or no workspace permission). Transient failures (DB errors) are rethrown so the * caller can report them as retryable instead of a permanent access denial. + * + * The fresh authorization decision is recorded into the role cache, so the + * pre-join re-check and the eviction sweep see it immediately — in particular, + * a user whose access was revoked and then restored is not blocked by the + * sweep's stale cached revocation for the remainder of its TTL. */ export async function verifyWorkflowAccess( userId: string, @@ -222,6 +278,11 @@ export async function verifyWorkflowAccess( action: 'read', }) + recordRoleDecision( + `${userId}:${workflowId}`, + authorization.allowed ? (authorization.workspacePermission ?? null) : null + ) + if (!authorization.allowed || !authorization.workspacePermission) { logger.warn( `User ${userId} is not permitted to access workflow ${workflowId}: ${authorization.message}` diff --git a/apps/realtime/src/rooms/memory-manager.ts b/apps/realtime/src/rooms/memory-manager.ts index a032e785bb5..a0aeb9d1fea 100644 --- a/apps/realtime/src/rooms/memory-manager.ts +++ b/apps/realtime/src/rooms/memory-manager.ts @@ -66,8 +66,9 @@ export class MemoryRoomManager implements IRoomManager { logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`) } - async removeUserFromRoom(socketId: string, _workflowIdHint?: string): Promise { - const workflowId = this.socketToWorkflow.get(socketId) + async removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise { + const currentWorkflowId = this.socketToWorkflow.get(socketId) ?? null + const workflowId = workflowIdHint ?? currentWorkflowId if (!workflowId) { return null @@ -75,8 +76,9 @@ export class MemoryRoomManager implements IRoomManager { const room = this.workflowRooms.get(workflowId) if (room) { - room.users.delete(socketId) - room.activeConnections = Math.max(0, room.activeConnections - 1) + if (room.users.delete(socketId)) { + room.activeConnections = Math.max(0, room.activeConnections - 1) + } // Clean up empty rooms if (room.activeConnections === 0) { @@ -85,8 +87,13 @@ export class MemoryRoomManager implements IRoomManager { } } - this.socketToWorkflow.delete(socketId) - this.userSessions.delete(socketId) + // Only clear the socket's own mappings when it is not mapped to a different + // room — removing a stale room's entry must not destroy the mapping of a + // room the socket has since moved to. + if (currentWorkflowId === null || currentWorkflowId === workflowId) { + this.socketToWorkflow.delete(socketId) + this.userSessions.delete(socketId) + } logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`) return workflowId diff --git a/apps/realtime/src/rooms/redis-manager.ts b/apps/realtime/src/rooms/redis-manager.ts index 0e6b3eadf2b..4ed34d2dfa5 100644 --- a/apps/realtime/src/rooms/redis-manager.ts +++ b/apps/realtime/src/rooms/redis-manager.ts @@ -18,7 +18,11 @@ const SOCKET_PRESENCE_WORKFLOW_KEY_TTL = 24 * 60 * 60 /** * Lua script for atomic user removal from room. - * Returns workflowId if user was removed, null otherwise. + * The hint, when provided, is the target room to remove membership from; the + * socket's current mapping is only the fallback. Socket-level keys are cleared + * only when the socket is not mapped to a different room, so removing a stale + * room cannot destroy the mapping of a room the socket has since moved to. + * Returns the target workflowId, or null when no target could be resolved. * Handles room cleanup atomically to prevent race conditions. */ const REMOVE_USER_SCRIPT = ` @@ -30,12 +34,13 @@ local workflowMetaPrefix = ARGV[2] local socketId = ARGV[3] local workflowIdHint = ARGV[4] -local workflowId = redis.call('GET', socketWorkflowKey) -if not workflowId then - workflowId = redis.call('GET', socketPresenceWorkflowKey) +local currentWorkflowId = redis.call('GET', socketWorkflowKey) +if not currentWorkflowId then + currentWorkflowId = redis.call('GET', socketPresenceWorkflowKey) end -if not workflowId and workflowIdHint ~= '' then +local workflowId = currentWorkflowId +if workflowIdHint ~= '' then workflowId = workflowIdHint end @@ -47,7 +52,10 @@ local workflowUsersKey = workflowUsersPrefix .. workflowId .. ':users' local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta' redis.call('HDEL', workflowUsersKey, socketId) -redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey) + +if (not currentWorkflowId) or currentWorkflowId == workflowId then + redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey) +end local remaining = redis.call('HLEN', workflowUsersKey) if remaining == 0 then diff --git a/apps/realtime/src/rooms/types.ts b/apps/realtime/src/rooms/types.ts index 9553a427e1e..0447516805e 100644 --- a/apps/realtime/src/rooms/types.ts +++ b/apps/realtime/src/rooms/types.ts @@ -64,9 +64,14 @@ export interface IRoomManager { addUserToRoom(workflowId: string, socketId: string, presence: UserPresence): Promise /** - * Remove a user from their current room - * Optional workflowIdHint is used when socket mapping keys are missing/expired. - * Returns the workflowId they were in, or null if not in any room. + * Remove a user's membership of a workflow room. + * When workflowIdHint is provided it is the target room; the socket's current + * mapping is only the fallback (and covers missing/expired mapping keys). + * Socket-level mappings are cleared only when the socket is not mapped to a + * different room, so removing a stale room cannot destroy the mapping of a + * room the socket has since moved to. + * Returns the target workflowId, or null when no target could be resolved + * (or, for the Redis manager, when the removal failed). */ removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise diff --git a/apps/sim/app/workspace/providers/socket-join-controller.ts b/apps/sim/app/workspace/providers/socket-join-controller.ts index b3fb18b5e88..4f46a30d1fd 100644 --- a/apps/sim/app/workspace/providers/socket-join-controller.ts +++ b/apps/sim/app/workspace/providers/socket-join-controller.ts @@ -92,6 +92,19 @@ export class SocketJoinController { } handleWorkflowDeleted(workflowId: string): SocketJoinDeleteResult { + return this.evictFromWorkflow(workflowId) + } + + /** + * Handles the realtime server evicting this socket because the user's access + * to the workflow was revoked. Identical control flow to a deletion: block + * re-join of that workflow and drop any current/pending membership. + */ + handleAccessRevoked(workflowId: string): SocketJoinDeleteResult { + return this.evictFromWorkflow(workflowId) + } + + private evictFromWorkflow(workflowId: string): SocketJoinDeleteResult { const commands = this.takeRetryResetCommands( this.retryWorkflowId === workflowId ? null : this.retryWorkflowId ) diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index af6df147547..65a8678ccfa 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -12,6 +12,7 @@ import { } from 'react' import { createLogger } from '@sim/logger' import type { + AccessRevokedBroadcast, CursorUpdateBroadcast, OperationConfirmedBroadcast, OperationFailedBroadcast, @@ -104,6 +105,7 @@ interface SocketContextType { onCursorUpdate: (handler: (data: CursorUpdateBroadcast) => void) => void onSelectionUpdate: (handler: (data: SelectionUpdateBroadcast) => void) => void onWorkflowDeleted: (handler: (data: WorkflowDeletedBroadcast) => void) => void + onAccessRevoked: (handler: (data: AccessRevokedBroadcast) => void) => void onWorkflowReverted: (handler: (data: WorkflowRevertedBroadcast) => void) => void onWorkflowUpdated: (handler: (data: WorkflowUpdatedBroadcast) => void) => void onWorkflowDeployed: (handler: (data: WorkflowDeployedBroadcast) => void) => void @@ -135,6 +137,7 @@ const SocketContext = createContext({ onCursorUpdate: () => {}, onSelectionUpdate: () => {}, onWorkflowDeleted: () => {}, + onAccessRevoked: () => {}, onWorkflowReverted: () => {}, onWorkflowUpdated: () => {}, onWorkflowDeployed: () => {}, @@ -182,6 +185,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { cursorUpdate?: (data: CursorUpdateBroadcast) => void selectionUpdate?: (data: SelectionUpdateBroadcast) => void workflowDeleted?: (data: WorkflowDeletedBroadcast) => void + accessRevoked?: (data: AccessRevokedBroadcast) => void workflowReverted?: (data: WorkflowRevertedBroadcast) => void workflowUpdated?: (data: WorkflowUpdatedBroadcast) => void workflowDeployed?: (data: WorkflowDeployedBroadcast) => void @@ -595,6 +599,20 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowDeleted?.(data) }) + socketInstance.on('access-revoked', (data: AccessRevokedBroadcast) => { + logger.warn(`Access to workflow ${data.workflowId} has been revoked`) + const result = joinControllerRef.current.handleAccessRevoked(data.workflowId) + if (result.shouldClearCurrent) { + clearJoinedWorkflowState(true) + // Surface the same blocked-join UX as a denied join: persistent + // toast plus read-only enforcement while the user is still on the + // revoked workflow. + setBlockedJoinWorkflowId(data.workflowId) + } + executeJoinCommands(result.commands) + eventHandlers.current.accessRevoked?.(data) + }) + socketInstance.on('workflow-reverted', (data: WorkflowRevertedBroadcast) => { logger.info(`Workflow ${data.workflowId} has been reverted to deployed state`) eventHandlers.current.workflowReverted?.(data) @@ -1154,6 +1172,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowDeleted = handler }, []) + const onAccessRevoked = useCallback((handler: (data: AccessRevokedBroadcast) => void) => { + eventHandlers.current.accessRevoked = handler + }, []) + const onWorkflowReverted = useCallback((handler: (data: WorkflowRevertedBroadcast) => void) => { eventHandlers.current.workflowReverted = handler }, []) @@ -1202,6 +1224,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { onCursorUpdate, onSelectionUpdate, onWorkflowDeleted, + onAccessRevoked, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, @@ -1232,6 +1255,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { onCursorUpdate, onSelectionUpdate, onWorkflowDeleted, + onAccessRevoked, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index e0c752bb446..ca73695d24f 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -153,6 +153,7 @@ export function useCollaborativeWorkflow() { onSubblockUpdate, onVariableUpdate, onWorkflowDeleted, + onAccessRevoked, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, @@ -639,6 +640,22 @@ export function useCollaborativeWorkflow() { } } + const handleAccessRevoked = (data: any) => { + const { workflowId } = data + logger.warn(`Access to workflow ${workflowId} has been revoked`) + + if (activeWorkflowId === workflowId) { + logger.info( + `Access to currently active workflow ${workflowId} was revoked, stopping collaborative operations` + ) + + const currentUserId = session?.user?.id || 'unknown' + useUndoRedoStore.getState().clear(workflowId, currentUserId) + + isApplyingRemoteChange.current = false + } + } + const reloadWorkflowFromApi = async (workflowId: string, reason: string): Promise => { const reloadSequence = (reloadSequencesRef.current[workflowId] ?? 0) + 1 reloadSequencesRef.current[workflowId] = reloadSequence @@ -913,6 +930,7 @@ export function useCollaborativeWorkflow() { onSubblockUpdate(handleSubblockUpdate) onVariableUpdate(handleVariableUpdate) onWorkflowDeleted(handleWorkflowDeleted) + onAccessRevoked(handleAccessRevoked) onWorkflowReverted(handleWorkflowReverted) onWorkflowUpdated(handleWorkflowUpdated) onWorkflowDeployed(handleWorkflowDeployed) @@ -935,6 +953,7 @@ export function useCollaborativeWorkflow() { onSubblockUpdate, onVariableUpdate, onWorkflowDeleted, + onAccessRevoked, onWorkflowReverted, onWorkflowUpdated, onWorkflowDeployed, diff --git a/packages/realtime-protocol/src/events.ts b/packages/realtime-protocol/src/events.ts index 95f630de80e..89b6fadb586 100644 --- a/packages/realtime-protocol/src/events.ts +++ b/packages/realtime-protocol/src/events.ts @@ -108,6 +108,19 @@ export interface WorkflowDeployedBroadcast { timestamp: number } +/** + * `access-revoked` broadcast. Emitted to a single socket when its owner's live + * read access to the workflow has been revoked (workspace permission removed or + * downgraded to no access), forcing that client to leave the room and clear its + * editor state. Unlike the lifecycle broadcasts above, this targets one socket + * rather than the whole room — only the revoked user is evicted. + */ +export interface AccessRevokedBroadcast { + workflowId: string + message: string + timestamp: number +} + /** `operation-confirmed` ack for a previously-emitted operation. */ export interface OperationConfirmedBroadcast { operationId: string