diff --git a/apps/docs/components/workflow-preview/format-references.tsx b/apps/docs/components/workflow-preview/format-references.tsx index 6a3c01ae21e..15bfeb5c049 100644 --- a/apps/docs/components/workflow-preview/format-references.tsx +++ b/apps/docs/components/workflow-preview/format-references.tsx @@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] { const isReference = (part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}')) return isReference ? ( - // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered {part} ) : ( - // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered {part} ) }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts new file mode 100644 index 00000000000..894e543c3b2 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -0,0 +1,323 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockOAuth2LinkAccount, + mockCheckWorkspaceAccess, + mockGetCredentialActorContext, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockOAuth2LinkAccount: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockGetCredentialActorContext: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/auth/auth', () => ({ + auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), +})) + +import { GET } from '@/app/api/auth/oauth2/authorize/route' + +const BASE_URL = 'https://sim.test' +const WORKSPACE_ID = 'ws-1' +const USER_ID = 'user-1' +const CREDENTIAL_ID = 'cred-1' +const LINK_URL = 'https://provider.example/authorize?state=abc' + +function authorizeRequest(query: Record) { + const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value) + } + return createMockRequest('GET', undefined, {}, url.toString()) +} + +function oauthCredentialActor(overrides: Record = {}) { + return { + credential: { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'oauth', + providerId: 'google-email', + displayName: 'Work Gmail', + ...((overrides.credential as Record) ?? {}), + }, + member: null, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), + } +} + +describe('OAuth2 authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + process.env.NEXT_PUBLIC_APP_URL = BASE_URL + mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: true, + canAdmin: false, + workspace: { id: WORKSPACE_ID }, + }) + mockOAuth2LinkAccount.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ url: LINK_URL }), + headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] }, + }) + }) + + describe('plain connect (no credentialId)', () => { + it('creates a draft with credentialId null and redirects to the provider', async () => { + const response = await GET( + authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(response.headers.get('location')).toBe(LINK_URL) + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: USER_ID, + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + credentialId: null, + }) + ) + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ credentialId: null }), + }) + ) + }) + + it('numbers the draft display name when the default collides with an existing credential', async () => { + dbChainMockFns.where + .mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }])) + .mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }])) + + await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ displayName: "Justin's Gmail 2" }) + ) + }) + + it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => { + await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) + + const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] + expect(set).toHaveProperty('credentialId', null) + }) + + it('redirects to login when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + + const response = await GET( + authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(response.headers.get('location')).toContain('/login') + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('rejects without workspace write access', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + hasAccess: true, + canWrite: false, + canAdmin: false, + workspace: { id: WORKSPACE_ID }, + }) + + const response = await GET( + authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=workspace_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + }) + + describe('reconnect (credentialId present)', () => { + it('creates a reconnect draft carrying credentialId in values and upsert set', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe(LINK_URL) + expect(mockGetCredentialActorContext).toHaveBeenCalledWith( + CREDENTIAL_ID, + USER_ID, + expect.objectContaining({ workspaceAccess: expect.anything() }) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: CREDENTIAL_ID }) + ) + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ credentialId: CREDENTIAL_ID }), + }) + ) + }) + + it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { displayName: 'Renamed By User' } }) + ) + + await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ displayName: 'Renamed By User' }) + ) + }) + + it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => { + for (const providerId of ['trello', 'shopify']) { + const response = await GET( + authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_reconnect_unsupported` + ) + } + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + + it('rejects when the caller is not a credential admin and writes no draft', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + + it('rejects when the credential belongs to a different workspace', async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) + ) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('rejects when the credential does not exist', async () => { + mockGetCredentialActorContext.mockResolvedValue({ + credential: null, + member: null, + hasWorkspaceAccess: false, + canWriteWorkspace: false, + isAdmin: false, + }) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'cred-missing', + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('rejects a non-oauth credential', async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { type: 'env_workspace' } }) + ) + + const response = await GET( + authorizeRequest({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_access_denied` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + + it('rejects when the query providerId does not match the credential provider', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + const response = await GET( + authorizeRequest({ + providerId: 'slack', + workspaceId: WORKSPACE_ID, + credentialId: CREDENTIAL_ID, + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_provider_mismatch` + ) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 65bb6a773b9..884762d3afd 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { pendingCredentialDraft, user } from '@sim/db/schema' +import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' @@ -9,6 +9,8 @@ import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { getAllOAuthServices } from '@/lib/oauth/utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -30,20 +32,53 @@ async function createConnectDraft(params: { userId: string workspaceId: string providerId: string + credentialId?: string + /** Reconnect only: the credential's actual name, so audit records stay accurate. */ + displayName?: string }): Promise { - const { userId, workspaceId, providerId } = params - - const service = getAllOAuthServices().find((s) => s.providerId === providerId) - const serviceName = service?.name ?? providerId + const { userId, workspaceId, providerId, credentialId } = params + + let displayName = params.displayName + if (!displayName) { + const service = getAllOAuthServices().find((s) => s.providerId === providerId) + const serviceName = service?.name ?? providerId + + let userName: string | null = null + try { + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + userName = row?.name ?? null + } catch (error) { + // Cosmetic only — fall back to the "My {Service}" default + logger.warn('User name lookup failed for connect draft display name', { + userId, + workspaceId, + providerId, + error, + }) + } - let displayName = serviceName - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - if (row?.name) { - displayName = `${row.name}'s ${serviceName}` + // Auto-number against existing workspace credentials so repeat connects for + // the same provider stay distinguishable — same behavior as the connect + // modal, which computes this client-side. Best effort: on failure the name + // simply skips deduplication. + let takenNames: ReadonlySet = new Set() + try { + const rows = await db + .select({ displayName: credential.displayName }) + .from(credential) + .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) + } catch (error) { + // Cosmetic only — proceed without collision numbering + logger.warn('Credential name lookup failed for connect draft deduplication', { + userId, + workspaceId, + providerId, + error, + }) } - } catch { - // Fall back to service name only + + displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) } const now = new Date() @@ -61,6 +96,7 @@ async function createConnectDraft(params: { workspaceId, providerId, displayName, + credentialId: credentialId ?? null, expiresAt, createdAt: now, }) @@ -70,10 +106,18 @@ async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - set: { displayName, expiresAt, createdAt: now }, + // credentialId must be written on BOTH paths: a plain connect that reuses a + // stale reconnect draft row would otherwise silently rebind the old + // credential instead of creating a new one. + set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, }) - logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) + logger.info('Created OAuth connect credential draft', { + userId, + workspaceId, + providerId, + credentialId: credentialId ?? null, + }) } /** @@ -92,7 +136,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query + const { + providerId, + workspaceId, + callbackURL: requestedCallback, + credentialId, + } = parsed.data.query const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) ? requestedCallback @@ -109,10 +158,65 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) } + let reconnectDisplayName: string | undefined + if (credentialId) { + // Trello and Shopify authorize through their own custom flows that bypass + // this endpoint, so a reconnect draft written here would linger unconsumed + // and could later be picked up by their token-store callbacks, silently + // rebinding the credential. Mirror the copilot tool and reject reconnect. + if (providerId === 'trello' || providerId === 'shopify') { + logger.warn('Reconnect not supported for custom-flow provider', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`) + } + + // Reconnect: the OAuth callback will rebind this credential to the fresh + // account, so require the same credential-admin access as the draft POST + // route — workspace write alone must not be enough to swap someone's tokens. + const actor = await getCredentialActorContext(credentialId, userId, { + workspaceAccess: access, + }) + if ( + !actor.credential || + actor.credential.workspaceId !== workspaceId || + actor.credential.type !== 'oauth' || + !actor.isAdmin + ) { + logger.warn('Credential admin access denied for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + } + if (actor.credential.providerId !== providerId) { + logger.warn('Provider mismatch for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + credentialProviderId: actor.credential.providerId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + reconnectDisplayName = actor.credential.displayName + } + // Create the draft before initiating the link so it is guaranteed to exist // (and freshly clocked) when the OAuth callback's `account.create.after` // hook runs. If this throws, we never start the OAuth flow. - await createConnectDraft({ userId, workspaceId, providerId }) + await createConnectDraft({ + userId, + workspaceId, + providerId, + credentialId, + displayName: reconnectDisplayName, + }) const linkResponse = await auth.api.oAuth2LinkAccount({ body: { providerId, callbackURL }, diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index f14f6a1bfa8..b73c6c64fb8 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -15,7 +15,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteInE2B, mockExecuteInIsolatedVM, + mockFetchWorkspaceFileBuffer, mockGetWorkspaceFile, + mockResolveWorkspaceFileReference, mockUpdateWorkspaceFileContent, mockUploadFile, mockValidateWorkspaceFileWriteTarget, @@ -23,7 +25,9 @@ const { } = vi.hoisted(() => ({ mockExecuteInE2B: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), + mockFetchWorkspaceFileBuffer: vi.fn(), mockGetWorkspaceFile: vi.fn(), + mockResolveWorkspaceFileReference: vi.fn(), mockUpdateWorkspaceFileContent: vi.fn(), mockUploadFile: vi.fn(), mockValidateWorkspaceFileWriteTarget: vi.fn(), @@ -37,6 +41,7 @@ vi.mock('@/lib/execution/isolated-vm', () => ({ vi.mock('@/lib/execution/e2b', () => ({ executeInE2B: mockExecuteInE2B, executeShellInE2B: vi.fn(), + SIM_RESULT_PREFIX: '__SIM_RESULT__=', })) vi.mock('@/lib/copilot/request/tools/files', () => ({ @@ -81,7 +86,9 @@ vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, getWorkspaceFile: mockGetWorkspaceFile, + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, updateWorkspaceFileContent: mockUpdateWorkspaceFileContent, uploadWorkspaceFile: vi.fn(), })) @@ -138,6 +145,8 @@ describe('Function Execute API Route', () => { url: '/api/files/view/existing', key: 'workspace/existing.png', }) + mockResolveWorkspaceFileReference.mockResolvedValue(null) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.alloc(0)) mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({ mode: target.mode, vfsPath: target.path, @@ -530,6 +539,152 @@ describe('Function Execute API Route', () => { expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() }) + it('rejects sandboxPath outputs when the call would run in isolated-vm (E2B enabled, JS without imports)', async () => { + envFlagsMock.isE2bEnabled = true + + const req = createMockRequest('POST', { + code: 'return "content"', + language: 'javascript', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(422) + expect(data.success).toBe(false) + expect(data.error).toContain('no sandbox filesystem') + expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() + expect(mockExecuteInE2B).not.toHaveBeenCalled() + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('rejects sandbox file mounts when the call would run in isolated-vm', async () => { + const req = createMockRequest('POST', { + code: 'return 1', + language: 'javascript', + workspaceId: 'workspace-1', + _sandboxFiles: [{ path: '/home/user/files/data.csv', content: 'a,b\n1,2' }], + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(422) + expect(data.success).toBe(false) + // E2B is disabled in this test, so the remediation must name that cause + // instead of suggesting python (which would also fail without E2B). + expect(data.error).toContain('E2B is not enabled') + expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() + }) + + it('flags an overwrite export whose bytes are identical to the current file content as unchanged', async () => { + envFlagsMock.isE2bEnabled = true + const staleContent = '# doc\nunchanged mounted content\n' + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/doc.md': staleContent }, + }) + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'wf_doc', + name: 'doc.md', + size: Buffer.byteLength(staleContent, 'utf-8'), + key: 'workspace/doc.md', + }) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from(staleContent, 'utf-8')) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + mimeType: 'text/markdown', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + // Idempotent overwrites (retries, unchanged regenerations) must not fail; + // the write proceeds and the receipt carries the loud unchanged signal so + // the model can tell its "new content" never reached the sandbox file. + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) + expect(data.output.result.unchanged).toBe(true) + expect(data.output.result.message).toContain('byte-identical to the previous version') + expect(data.output.result.message).toContain('/home/user/doc.md') + }) + + it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => { + envFlagsMock.isE2bEnabled = true + const newContent = '# doc\nnew content\n' + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/doc.md': newContent }, + }) + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'wf_doc', + name: 'doc.md', + size: 36728, + key: 'workspace/doc.md', + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + mimeType: 'text/markdown', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + // Sizes differ, so the current content is never downloaded for comparison. + expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() + expect(data.output.result.size).toBe(Buffer.byteLength(newContent, 'utf-8')) + expect(data.output.result.previousSize).toBe(36728) + expect(data.output.result.sha256).toMatch(/^[0-9a-f]{64}$/) + expect(data.output.result.unchanged).toBe(false) + expect(data.output.result.message).toContain('replaced 36728 bytes') + expect(data.output.result.message).toContain('sha256:') + // The python wrapper prints the marker with a leading \n so it always + // starts a fresh line even after non-newline-terminated user output. + const e2bCode = mockExecuteInE2B.mock.calls[0][0].code as string + expect(e2bCode).toContain("print('\\n__SIM_RESULT__=' + json.dumps(__sim_result__))") + }) + it('should return computed result for multi-line code', async () => { mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' }) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 33ab418dac5..20568fd2010 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { functionExecuteContract } from '@/lib/api/contracts' @@ -18,7 +19,7 @@ import { import { isE2bEnabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' +import { executeInE2B, executeShellInE2B, SIM_RESULT_PREFIX } from '@/lib/execution/e2b' import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys' @@ -36,6 +37,10 @@ import { import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { materializeLargeValueRef } from '@/lib/execution/payloads/store' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' +import { + fetchWorkspaceFileBuffer, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' @@ -899,6 +904,49 @@ async function functionJsonResponse( ) } +/** + * Compares an about-to-be-exported buffer against the overwrite target's + * current content. `identical: true` means the export is a byte-for-byte no-op: + * either a legitimately idempotent regeneration, or the incident signature of + * code that never wrote to the declared sandboxPath (the file still holds the + * mounted input). Only the model can tell those apart, so callers surface the + * fact loudly in the receipt instead of failing the write. Comparison failures + * never block the write; the current content is only downloaded when the sizes + * already match. + */ +async function checkOverwriteTarget( + workspaceId: string, + targetPath: string, + buffer: Buffer +): Promise<{ previousSize?: number; identical: boolean }> { + try { + const existing = await resolveWorkspaceFileReference(workspaceId, targetPath) + if (!existing) return { identical: false } + if (existing.size !== buffer.length) { + return { previousSize: existing.size, identical: false } + } + const current = await fetchWorkspaceFileBuffer(existing) + return { previousSize: existing.size, identical: current.equals(buffer) } + } catch { + return { identical: false } + } +} + +function formatExportReceipt(bytes: number, previousSize: number | undefined, sha256: string) { + return `(${bytes} bytes${ + previousSize !== undefined ? `, replaced ${previousSize} bytes` : '' + }, sha256:${sha256.slice(0, 16)})` +} + +function exportUnchangedNote(sandboxPath?: string): string { + return ( + 'WARNING: content is byte-identical to the previous version — nothing changed.' + + (sandboxPath + ? ` If you expected new content, your code did not modify the sandbox file at "${sandboxPath}" (it still holds the mounted input); write the new content to exactly that path and export again.` + : ' If you expected new content, the code returned the same bytes as before.') + ) +} + async function maybeExportSandboxFileToWorkspace(args: { authUserId: string workflowId?: string @@ -982,7 +1030,16 @@ async function maybeExportSandboxFileToWorkspace(args: { const targetPath = overwriteFileId || outputPath const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') + let previousSize: number | undefined + let unchanged = false + if (mode === 'overwrite') { + const check = await checkOverwriteTarget(resolvedWorkspaceId, targetPath, fileBuffer) + previousSize = check.previousSize + unchanged = check.identical + } + try { + const sha256 = sha256Hex(fileBuffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, userId: authUserId, @@ -1001,17 +1058,28 @@ async function maybeExportSandboxFileToWorkspace(args: { mode, mimeType: resolvedMimeType, size: fileBuffer.length, + previousSize, + sha256, + unchanged, }) return NextResponse.json({ success: true, output: { result: { - message: `Sandbox file exported to ${written.vfsPath}`, + message: `Sandbox file exported to ${written.vfsPath} ${formatExportReceipt( + fileBuffer.length, + previousSize, + sha256 + )}${unchanged ? ` — ${exportUnchangedNote(outputSandboxPath)}` : ''}`, fileId: written.id, fileName: written.name, vfsPath: written.vfsPath, downloadUrl: written.downloadUrl, sandboxPath: outputSandboxPath, + size: fileBuffer.length, + previousSize, + sha256, + unchanged, }, stdout: cleanStdout(stdout), executionTime, @@ -1200,6 +1268,14 @@ async function maybeExportSandboxFilesToWorkspace(args: { const buffer = prepared.isBinary ? Buffer.from(prepared.content, 'base64') : Buffer.from(prepared.content, 'utf-8') + let previousSize: number | undefined + let unchanged = false + if (prepared.target.mode === 'overwrite') { + const check = await checkOverwriteTarget(resolvedWorkspaceId, prepared.target.path, buffer) + previousSize = check.previousSize + unchanged = check.identical + } + const sha256 = sha256Hex(buffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, userId: args.authUserId, @@ -1214,8 +1290,18 @@ async function maybeExportSandboxFilesToWorkspace(args: { mode: prepared.file.mode ?? 'create', mimeType: prepared.resolvedMimeType, size: prepared.size, + previousSize, + sha256, + unchanged, + }) + writtenFiles.push({ + ...written, + sandboxPath: prepared.sandboxPath, + exportedBytes: buffer.length, + previousSize, + sha256, + unchanged, }) - writtenFiles.push({ ...written, sandboxPath: prepared.sandboxPath }) } } catch (error) { return NextResponse.json( @@ -1232,11 +1318,27 @@ async function maybeExportSandboxFilesToWorkspace(args: { ) } + const unchangedFiles = writtenFiles.filter((file) => file.unchanged) return NextResponse.json({ success: true, output: { result: { - message: `Exported ${writtenFiles.length} sandbox files`, + message: `Exported ${writtenFiles.length} sandbox files: ${writtenFiles + .map( + (file) => + `${file.vfsPath} ${formatExportReceipt( + file.exportedBytes, + file.previousSize, + file.sha256 + )}${file.unchanged ? ' [UNCHANGED]' : ''}` + ) + .join('; ')}${ + unchangedFiles.length > 0 + ? ` — WARNING: ${unchangedFiles.map((file) => file.vfsPath).join(', ')} ${ + unchangedFiles.length === 1 ? 'is' : 'are' + } byte-identical to the previous version (nothing changed). If you expected new content there, your code did not modify the corresponding sandbox file.` + : '' + }`, files: writtenFiles.map((file) => ({ fileId: file.id, fileName: file.name, @@ -1244,6 +1346,10 @@ async function maybeExportSandboxFilesToWorkspace(args: { backingVfsPath: file.backingVfsPath, downloadUrl: file.downloadUrl, sandboxPath: file.sandboxPath, + size: file.exportedBytes, + previousSize: file.previousSize, + sha256: file.sha256, + unchanged: file.unchanged, })), }, stdout: cleanStdout(args.stdout), @@ -1506,6 +1612,29 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } + // Sandbox file mounts and sandboxPath exports only exist in the E2B + // runtime; isolated-vm has no filesystem. Silently dropping a declared + // sandbox input/output here produced "export succeeded" responses with + // zero bytes written, so refuse the call instead. The remediation depends + // on WHY this call runs in isolated-vm — "switch to python" is a dead end + // when E2B is disabled or the call is a custom tool. + if (!useE2B && (outputSandboxPaths.length > 0 || outputSandboxPath || _sandboxFiles?.length)) { + const remediation = !isE2bEnabled + ? "E2B is not enabled on this deployment, so there is no sandbox filesystem for any language. Pass input data via params and return output as the code's return value with outputs.files[].path (no sandboxPath)." + : isCustomTool + ? "custom tools always run in the isolated JavaScript VM, which has no sandbox filesystem. Pass input data via params and return output as the code's return value." + : 'plain JavaScript runs in the isolated VM, which has no sandbox filesystem. Use language "python" so the code runs in the E2B sandbox, or drop sandboxPath and return the file content as the code\'s return value with outputs.files[].path.' + return functionJsonResponse( + { + success: false, + error: `Sandbox file inputs/outputs are unavailable for this call: ${remediation}`, + output: { result: null, stdout: '', executionTime: Date.now() - startTime }, + }, + routeContext, + { status: 422 } + ) + } + if (useE2B) { logger.info(`[${requestId}] E2B status`, { enabled: isE2bEnabled, @@ -1543,7 +1672,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ' const __sim_result = await (async () => {', ` ${codeBody.split('\n').join('\n ')}`, ' })();', - " console.log('__SIM_RESULT__=' + JSON.stringify(__sim_result));", + // Leading \n guarantees the marker starts a fresh line even when user + // code's last stdout write was not newline-terminated (chunks are + // concatenated verbatim on the parse side, so a glued marker would + // otherwise be missed silently). + ` console.log('\\n${SIM_RESULT_PREFIX}' + JSON.stringify(__sim_result));`, ' } catch (error) {', ' console.log(String((error && (error.stack || error.message)) || error));', ' throw error;', @@ -1635,7 +1768,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { 'def __sim_main__():', ...resolvedCode.split('\n').map((l) => ` ${l}`), '__sim_result__ = __sim_main__()', - "print('__SIM_RESULT__=' + json.dumps(__sim_result__))", + // Leading \n: same fresh-line guarantee as the JS wrapper's marker. + `print('\\n${SIM_RESULT_PREFIX}' + json.dumps(__sim_result__))`, ].join('\n') const codeForE2B = prologue + wrapped diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts new file mode 100644 index 00000000000..c863c50b0d6 --- /dev/null +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -0,0 +1,533 @@ +/** + * @vitest-environment node + */ +import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockTransaction, + mockSelectRows, + mockCheckStorageQuota, + mockFilterForkableChatFiles, + mockListForkableChatFiles, + mockPlanChatFileCopies, + mockExecuteChatFileBlobCopies, + mockLoadCopilotChatMessages, + mockAppendCopilotChatMessages, + mockAssertActiveWorkspaceAccess, + mockFetchGo, + mockPublishStatusChanged, + mockCaptureServerEvent, + mockDeleteWhere, + mockRemoveChatResources, +} = vi.hoisted(() => ({ + mockTransaction: vi.fn(), + mockSelectRows: vi.fn(), + mockCheckStorageQuota: vi.fn(), + // Real (pure) cut semantics so tests drive selection through row.messageId: + // rows with a NULL/undefined messageId are kept in every fork. + mockFilterForkableChatFiles: vi.fn( + (rows: Array<{ messageId?: string | null }>, kept: ReadonlySet) => + rows.filter((row) => !row.messageId || kept.has(row.messageId)) + ), + mockListForkableChatFiles: vi.fn(), + mockPlanChatFileCopies: vi.fn(), + mockExecuteChatFileBlobCopies: vi.fn(), + mockLoadCopilotChatMessages: vi.fn(), + mockAppendCopilotChatMessages: vi.fn(), + mockAssertActiveWorkspaceAccess: vi.fn(), + mockFetchGo: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockDeleteWhere: vi.fn(), + mockRemoveChatResources: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => mockSelectRows(), + }), + }), + }), + delete: () => ({ + where: mockDeleteWhere, + }), + transaction: mockTransaction, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + copilotChats: { + id: 'copilotChats.id', + userId: 'copilotChats.userId', + type: 'copilotChats.type', + workspaceId: 'copilotChats.workspaceId', + title: 'copilotChats.title', + model: 'copilotChats.model', + resources: 'copilotChats.resources', + previewYaml: 'copilotChats.previewYaml', + planArtifact: 'copilotChats.planArtifact', + config: 'copilotChats.config', + }, + workspaceFiles: { + id: 'workspaceFiles.id', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), + inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })), +})) + +vi.mock('@/lib/copilot/resources/persistence', () => ({ + removeChatResources: mockRemoveChatResources, +})) + +vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) + +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuota: mockCheckStorageQuota, +})) + +vi.mock('@/lib/copilot/chat/fork-chat-files', () => ({ + filterForkableChatFiles: mockFilterForkableChatFiles, + listForkableChatFiles: mockListForkableChatFiles, + planChatFileCopies: mockPlanChatFileCopies, + executeChatFileBlobCopies: mockExecuteChatFileBlobCopies, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + loadCopilotChatMessages: mockLoadCopilotChatMessages, +})) + +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: mockAppendCopilotChatMessages, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/copilot/request/go/fetch', () => ({ + fetchGo: mockFetchGo, +})) + +vi.mock('@/lib/copilot/server/agent-url', () => ({ + getMothershipBaseURL: vi.fn().mockResolvedValue('http://mothership.test'), + getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), +})) + +vi.mock('@/lib/core/config/env', () => ({ env: {} })) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, + isWorkspaceAccessDeniedError: () => false, +})) + +import { POST } from '@/app/api/mothership/chats/[chatId]/fork/route' + +const OLD_FILE_ID = 'wf_oldfile' +const NEW_FILE_ID = 'wf_newfile' + +const parentRow = { + id: 'chat-1', + userId: 'user-1', + type: 'mothership', + workspaceId: 'ws-1', + title: 'Generate Logs', + model: 'claude-opus-4-8', + resources: [{ type: 'file', id: OLD_FILE_ID, title: 'cat.png' }], + previewYaml: null, + planArtifact: null, + config: null, +} + +const threeMessages = [ + { + id: 'msg-1', + role: 'user', + content: `See ![cat](/api/files/view/${OLD_FILE_ID})`, + timestamp: '2026-07-01T00:00:00.000Z', + }, + { + id: 'msg-2', + role: 'assistant', + content: 'Nice cat.', + timestamp: '2026-07-01T00:00:01.000Z', + }, + { + id: 'msg-3', + role: 'user', + content: 'A later message the fork must not keep.', + timestamp: '2026-07-01T00:00:02.000Z', + }, +] + +/** Chat rows inserted through the mock transaction, captured for title assertions. */ +let insertedChatRows: Array> = [] +/** tx.update(...).set(...) payloads, captured for resource-rewrite assertions. */ +let updatedChatRows: Array> = [] + +function makeTx() { + return { + insert: () => ({ + values: (v: Record) => { + insertedChatRows.push(v) + return { + returning: async () => [{ id: 'row-id', workspaceId: 'ws-1' }], + } + }, + }), + update: () => ({ + set: (v: Record) => { + updatedChatRows.push(v) + return { where: async () => undefined } + }, + }), + } +} + +function createRequest(chatId: string, body?: unknown) { + return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/fork`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? { upToMessageId: 'msg-2' }), + }) +} + +function makeContext(chatId: string) { + return { params: Promise.resolve({ chatId }) } +} + +describe('POST /api/mothership/chats/[chatId]/fork', () => { + beforeEach(() => { + vi.clearAllMocks() + insertedChatRows = [] + updatedChatRows = [] + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: 'user-1', + isAuthenticated: true, + }) + mockSelectRows.mockResolvedValue([parentRow]) + mockListForkableChatFiles.mockResolvedValue([]) + mockCheckStorageQuota.mockResolvedValue({ allowed: true }) + mockLoadCopilotChatMessages.mockResolvedValue(threeMessages) + mockPlanChatFileCopies.mockResolvedValue({ + idMap: new Map(), + keyMap: new Map(), + blobTasks: [], + }) + mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 0, failed: 0, failedCopyIds: [] }) + mockAppendCopilotChatMessages.mockResolvedValue(undefined) + mockDeleteWhere.mockResolvedValue(undefined) + mockRemoveChatResources.mockResolvedValue(undefined) + mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) + mockFetchGo.mockResolvedValue({ ok: true }) + mockTransaction.mockImplementation(async (cb: (tx: unknown) => Promise) => + cb(makeTx()) + ) + }) + + it('rejects unauthenticated callers', async () => { + copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ + userId: null, + isAuthenticated: false, + }) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(401) + }) + + it('404s when the chat belongs to another user', async () => { + mockSelectRows.mockResolvedValue([{ ...parentRow, userId: 'someone-else' }]) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(404) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('404s for non-mothership chats', async () => { + mockSelectRows.mockResolvedValue([{ ...parentRow, type: 'copilot' }]) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(404) + }) + + it('400s when upToMessageId is missing', async () => { + const res = await POST(createRequest('chat-1', {}), makeContext('chat-1')) + expect(res.status).toBe(400) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('400s when upToMessageId is an empty string', async () => { + const res = await POST(createRequest('chat-1', { upToMessageId: '' }), makeContext('chat-1')) + expect(res.status).toBe(400) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('400s when the message is not in the chat', async () => { + const res = await POST( + createRequest('chat-1', { upToMessageId: 'msg-unknown' }), + makeContext('chat-1') + ) + expect(res.status).toBe(400) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('applies the timeline cut: kept message ids drive the file selection', async () => { + // Two uploads born pre-cut, one born post-cut, and one legacy row with no + // birth message. The single chat-owned read is cut in memory: everything + // but the post-cut row. + const preCutUpload = { id: 'wf_up', size: 1, context: 'mothership', messageId: 'msg-1' } + const secondPreCut = { id: 'wf_up2', size: 1, context: 'mothership', messageId: 'msg-2' } + const postCutUpload = { id: 'wf_late', size: 1, context: 'mothership', messageId: 'msg-3' } + const legacyRow = { id: 'wf_legacy', size: 1, context: 'mothership', messageId: null } + mockListForkableChatFiles.mockResolvedValue([ + preCutUpload, + secondPreCut, + postCutUpload, + legacyRow, + ]) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(200) + + // The cut runs over the single read with the kept slice (inclusive of + // msg-2, excluding msg-3). + expect(mockListForkableChatFiles).toHaveBeenCalledTimes(1) + const filterCall = mockFilterForkableChatFiles.mock.calls[0] + expect(filterCall[1]).toEqual(new Set(['msg-1', 'msg-2'])) + + // The copy plan receives the cut set — the pre-cut uploads plus the + // legacy no-birthdate row; the post-cut upload stays behind. + expect(mockPlanChatFileCopies.mock.calls[0][0].rows).toEqual([ + preCutUpload, + secondPreCut, + legacyRow, + ]) + + // The appended transcript is the same inclusive slice. + const appended = mockAppendCopilotChatMessages.mock.calls[0] + expect(appended[1].map((m: { id: string }) => m.id)).toEqual(['msg-1', 'msg-2']) + }) + + it('fails up front with the quota error when copied bytes would exceed the limit', async () => { + mockListForkableChatFiles.mockResolvedValue([ + { size: 600, workspaceId: 'ws-1' }, + { size: 400, workspaceId: 'ws-1' }, + ]) + mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' }) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + + expect(res.status).toBe(400) + expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 1000) + expect(mockTransaction).not.toHaveBeenCalled() + expect(mockExecuteChatFileBlobCopies).not.toHaveBeenCalled() + }) + + it('excludes uncopyable rows (no workspaceId) from the quota sum', async () => { + // planChatFileCopies skips workspaceId-less legacy rows, so their bytes + // must not count against the gate. + mockListForkableChatFiles.mockResolvedValue([{ size: 600, workspaceId: 'ws-1' }, { size: 400 }]) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + + expect(res.status).toBe(200) + expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 600) + }) + + it('skips the quota check entirely when no chat-owned rows are in the cut', async () => { + // The chat owns one file, but it was born after the fork point. + mockListForkableChatFiles.mockResolvedValue([ + { id: 'wf_late', size: 500, context: 'mothership', messageId: 'msg-3' }, + ]) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(200) + expect(mockCheckStorageQuota).not.toHaveBeenCalled() + }) + + it('forks the chat: copies kept uploads, rewrites references, clones agent state', async () => { + const blobTasks = [ + { + sourceKey: 'workspace/ws-1/old-cat.png', + targetKey: 'workspace/ws-1/new-cat.png', + context: 'mothership', + fileName: 'cat.png', + contentType: 'image/png', + }, + ] + mockListForkableChatFiles.mockResolvedValue([ + { size: 100, messageId: 'msg-1', workspaceId: 'ws-1' }, + ]) + mockPlanChatFileCopies.mockResolvedValue({ + idMap: new Map([[OLD_FILE_ID, NEW_FILE_ID]]), + keyMap: new Map([['workspace/ws-1/old-cat.png', 'workspace/ws-1/new-cat.png']]), + blobTasks, + }) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.success).toBe(true) + expect(typeof body.id).toBe('string') + + expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 100) + + // The real rewriter runs: the kept message's view-URL points at the copy. + const appended = mockAppendCopilotChatMessages.mock.calls[0] + expect(appended[0]).toBe(body.id) + expect(appended[1][0].content).toBe(`See ![cat](/api/files/view/${NEW_FILE_ID})`) + + expect(mockExecuteChatFileBlobCopies).toHaveBeenCalledWith(blobTasks, { + userId: 'user-1', + workspaceId: 'ws-1', + }) + + const goCall = mockFetchGo.mock.calls[0] + expect(goCall[0]).toBe('http://mothership.test/api/chats/fork') + const goBody = JSON.parse(goCall[1].body) + // The copilot service only knows USER message ids, so the clone cut is the + // kept slice's last user message (msg-1), not the clicked assistant (msg-2). + expect(goBody).toEqual({ + sourceChatId: 'chat-1', + newChatId: body.id, + upToMessageId: 'msg-1', + userId: 'user-1', + }) + + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + chatId: body.id, + type: 'created', + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'task_forked', + { workspace_id: 'ws-1', source_chat_id: 'chat-1' }, + { groups: { workspace: 'ws-1' } } + ) + + // Forks are titled "Fork | ". + expect(insertedChatRows[0].title).toBe('Fork | Generate Logs') + }) + + it('still succeeds when the copilot-service clone fails (best-effort)', async () => { + mockFetchGo.mockRejectedValue(new Error('mothership unreachable')) + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + expect(res.status).toBe(200) + }) + + it('surfaces failed blob copies and cleans up their dead rows + resource chips', async () => { + mockExecuteChatFileBlobCopies.mockResolvedValue({ + copied: 1, + failed: 2, + failedCopyIds: ['wf_dead1', 'wf_dead2'], + }) + + const failedRes = await POST(createRequest('chat-1'), makeContext('chat-1')) + const body = await failedRes.json() + + expect(body.failedFileCopies).toBe(2) + + // The dead rows (committed, but no bytes behind them) are hard-deleted so + // they vanish from VFS listings and name resolution… + expect(mockDeleteWhere).toHaveBeenCalledWith({ + type: 'inArray', + field: 'workspaceFiles.id', + values: ['wf_dead1', 'wf_dead2'], + }) + // …and their resource chips are dropped from the new chat. + expect(mockRemoveChatResources).toHaveBeenCalledWith(body.id, [ + { type: 'file', id: 'wf_dead1', title: '' }, + { type: 'file', id: 'wf_dead2', title: '' }, + ]) + }) + + it('omits failedFileCopies and skips cleanup when every blob copies', async () => { + mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 3, failed: 0, failedCopyIds: [] }) + + const cleanRes = await POST(createRequest('chat-1'), makeContext('chat-1')) + + expect('failedFileCopies' in (await cleanRes.json())).toBe(false) + expect(mockDeleteWhere).not.toHaveBeenCalled() + expect(mockRemoveChatResources).not.toHaveBeenCalled() + }) + + it('copies pre-cut uploads and drops only post-cut ghosts', async () => { + // The source chat owns two more uploads (apple pre-cut, banana post-cut) + // beside the kept one, plus one shared workspace-file resource. The fork + // copies the kept upload AND the pre-cut apple; only the post-cut banana + // stays behind, so only its resource is dropped — not left pointing at + // the source chat. + mockSelectRows.mockResolvedValue([ + { + ...parentRow, + resources: [ + { type: 'file', id: OLD_FILE_ID, title: 'cat.png' }, + { type: 'file', id: 'wf_apple', title: 'apple.png' }, + { type: 'file', id: 'wf_banana', title: 'banana.png' }, + { type: 'file', id: 'wf_shared', title: 'shared.pdf' }, + { type: 'workflow', id: 'wflow-1', title: 'My flow' }, + ], + }, + ]) + // Every chat-owned file of the source chat in the single read; messageId + // drives the in-memory cut. + mockListForkableChatFiles.mockResolvedValue([ + { id: OLD_FILE_ID, size: 100, context: 'mothership', messageId: 'msg-1' }, + { id: 'wf_apple', size: 50, context: 'mothership', messageId: 'msg-1' }, + { id: 'wf_banana', size: 50, context: 'mothership', messageId: 'msg-3' }, + ]) + mockPlanChatFileCopies.mockResolvedValue({ + idMap: new Map([ + [OLD_FILE_ID, NEW_FILE_ID], + ['wf_apple', 'wf_apple_copy'], + ]), + keyMap: new Map(), + blobTasks: [], + }) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + + expect(res.status).toBe(200) + // The plan received the cut set: the kept upload + pre-cut apple only. + expect(mockPlanChatFileCopies.mock.calls[0][0].rows.map((r: { id: string }) => r.id)).toEqual([ + OLD_FILE_ID, + 'wf_apple', + ]) + expect(updatedChatRows).toHaveLength(1) + expect(updatedChatRows[0].resources).toEqual([ + { type: 'file', id: NEW_FILE_ID, title: 'cat.png' }, + { type: 'file', id: 'wf_apple_copy', title: 'apple.png' }, + { type: 'file', id: 'wf_shared', title: 'shared.pdf' }, + { type: 'workflow', id: 'wflow-1', title: 'My flow' }, + ]) + }) + + it('drops ghosts even when the fork copies no files at all', async () => { + // Fork cut before the chat's only upload arrived: a guard that skips the + // resources update when idMap is empty would leave the ghost in place. + mockSelectRows.mockResolvedValue([ + { + ...parentRow, + resources: [{ type: 'file', id: 'wf_banana', title: 'banana.png' }], + }, + ]) + mockListForkableChatFiles.mockResolvedValue([ + { id: 'wf_banana', size: 50, context: 'mothership', messageId: 'msg-3' }, + ]) + + const res = await POST(createRequest('chat-1'), makeContext('chat-1')) + + expect(res.status).toBe(200) + expect(updatedChatRows).toHaveLength(1) + expect(updatedChatRows[0].resources).toEqual([]) + }) +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 44b63c7f163..e2e361f2222 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -1,13 +1,24 @@ import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' +import { copilotChats, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' +import { checkStorageQuota } from '@/lib/billing/storage' +import { + executeChatFileBlobCopies, + filterForkableChatFiles, + listForkableChatFiles, + planChatFileCopies, +} from '@/lib/copilot/chat/fork-chat-files' import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' +import { + rewriteMessageFileRefs, + rewriteResourceFileRefs, +} from '@/lib/copilot/chat/rewrite-file-references' import { chatPubSub } from '@/lib/copilot/chat-status' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { @@ -18,6 +29,7 @@ import { createNotFoundResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { removeChatResources } from '@/lib/copilot/resources/persistence' import type { MothershipResource } from '@/lib/copilot/resources/types' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' @@ -33,7 +45,16 @@ const logger = createLogger('ForkChatAPI') /** * POST /api/mothership/chats/[chatId]/fork * Creates a new chat branched from the given chat, keeping messages up to and - * including the specified message. Resources and copilot-side state are copied. + * including the specified message, along with the chat's uploads born + * at-or-before the fork point (a file travels iff the user message that + * carried it is kept). Resources and copilot-side state are copied. + * + * Every copied file gets a fresh row id and storage key, bytes are physically + * copied and counted against the storage quota, and every in-transcript file + * reference is re-pointed at the copies so the new chat survives deletion of + * the source chat. File resources whose chat-owned file was NOT copied + * (uploads born after the cut) are dropped from the new chat's resources + * rather than left as ghosts pointing at the source chat's files. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { @@ -82,17 +103,49 @@ export const POST = withRouteHandler( } const forkedMessages = messages.slice(0, forkIdx + 1) - // Resources are stored as a jsonb array on the chat row — copy them directly. + // Single workspace_files read per fork: every chat-owned upload. The + // copied set is timeline-cut to the kept message slice in memory (files + // born after the fork point stay behind). + const chatOwnedFiles = await listForkableChatFiles(db, chatId) + const sourceFiles = filterForkableChatFiles( + chatOwnedFiles, + new Set(forkedMessages.map((m) => m.id)) + ) + // Sum only rows the plan will actually copy — planChatFileCopies skips + // rows with no workspaceId, so counting their bytes could reject a fork + // whose real copies fit within quota. + const totalFileBytes = sourceFiles.reduce( + (sum, row) => (row.workspaceId ? sum + row.size : sum), + 0 + ) + if (totalFileBytes > 0) { + const quotaCheck = await checkStorageQuota(userId, totalFileBytes) + if (!quotaCheck.allowed) { + return createBadRequestResponse(quotaCheck.error || 'Storage limit exceeded') + } + } + + // Resources are stored as a jsonb array on the chat row. They carry no + // timestamps, so they can't be timeline-cut like messages — instead, + // file resources whose chat-owned file is NOT copied (uploads born + // after the cut) are dropped in the rewrite below; everything else is + // copied. const parentResources = Array.isArray(parent.resources) ? (parent.resources as MothershipResource[]) : [] + // The source chat's chat-owned file ids (no cut) — the "is this + // resource a ghost?" test set for the rewrite. + const chatOwnedFileIds = new Set(chatOwnedFiles.map((row) => row.id)) + const newId = generateId() + // Strip a leading "Fork | " so titles don't stack prefixes when forking + // a forked chat. const baseTitle = (parent.title ?? 'New chat').replace(/^Fork \| /, '') const title = `Fork | ${baseTitle}` const now = new Date() - const newChat = await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const [row] = await tx .insert(copilotChats) .values({ @@ -114,16 +167,85 @@ export const POST = withRouteHandler( if (!row) return null - await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx) - return row + // File rows FK the new chat row, so the plan runs after the insert. + const { idMap, keyMap, blobTasks } = await planChatFileCopies({ + tx, + rows: sourceFiles, + newChatId: newId, + userId, + now, + }) + + const maps = { fileIds: idMap, fileKeys: keyMap } + const newChatResources = rewriteResourceFileRefs(parentResources, maps, chatOwnedFileIds) + // Skip the redundant update only when the rewrite changed nothing: + // no ids re-pointed AND no ghost resources dropped. (idMap and keyMap + // are populated in lockstep, so idMap alone decides the first half.) + if (idMap.size > 0 || newChatResources.length !== parentResources.length) { + await tx + .update(copilotChats) + .set({ resources: newChatResources }) + .where(eq(copilotChats.id, newId)) + } + + await appendCopilotChatMessages( + newId, + rewriteMessageFileRefs(forkedMessages, maps), + { chatModel: parent.model }, + tx + ) + return { row, blobTasks } }) - if (!newChat) { + if (!result) { return createInternalServerErrorResponse('Failed to create forked chat') } + const newChat = result.row + + const { copied, failed, failedCopyIds } = await executeChatFileBlobCopies(result.blobTasks, { + userId, + workspaceId: parent.workspaceId ?? undefined, + }) + if (failed > 0) { + // A failed blob copy leaves a committed row with no bytes behind it. + // Cleanly absent beats present-but-broken: hard-delete the dead rows + // (they vanish from the VFS listings and name resolution) and drop + // their resource chips from the new chat. Inline transcript embeds + // can't be healed — those 404 — which is what `failedFileCopies` in + // the response warns the user about. + try { + await db.delete(workspaceFiles).where(inArray(workspaceFiles.id, failedCopyIds)) + await removeChatResources( + newId, + failedCopyIds.map((id) => ({ type: 'file' as const, id, title: '' })) + ) + } catch (cleanupError) { + logger.error('Failed to clean up dead file rows after blob-copy failure', { + newChatId: newId, + failedCopyIds, + error: cleanupError, + }) + } + logger.warn('Some chat file blobs failed to copy during fork', { + chatId, + newChatId: newId, + copied, + failed, + }) + } // Clone copilot-service conversation state (messages, active_messages, memory files). // Best-effort: if the copilot service doesn't have a row for the source chat yet, skip. + // The service stamps MessageID only on USER messages (assistant rows carry + // Sim-local ids it has never seen), so hand it the kept slice's last user + // message — it clones through the end of that turn, matching this route's cut. + let goCutMessageId = upToMessageId + for (let i = forkedMessages.length - 1; i >= 0; i--) { + if (forkedMessages[i].role === 'user') { + goCutMessageId = forkedMessages[i].id + break + } + } try { const copilotHeaders: Record = { 'Content-Type': 'application/json' } if (env.COPILOT_API_KEY) { @@ -137,7 +259,7 @@ export const POST = withRouteHandler( body: JSON.stringify({ sourceChatId: chatId, newChatId: newId, - upToMessageId, + upToMessageId: goCutMessageId, userId, }), spanName: 'sim → go /api/chats/fork', @@ -168,7 +290,11 @@ export const POST = withRouteHandler( { groups: { workspace: parent.workspaceId ?? '' } } ) - return NextResponse.json({ success: true, id: newId }) + return NextResponse.json({ + success: true, + id: newId, + ...(failed > 0 ? { failedFileCopies: failed } : {}), + }) } catch (error) { if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 37236aa160f..b37b3407147 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -17,7 +17,6 @@ import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explic import type { StreamEvent } from '@/lib/copilot/request/types' import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildUserSkillTool } from '@/lib/mothership/skills' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -141,25 +140,23 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content // double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime const agentMentions = contexts as unknown as ChatContext[] | undefined - const [workspaceContext, integrationTools, userSkillTool, userPermission, agentContexts] = - await Promise.all([ - generateWorkspaceContext(workspaceId, userId), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - buildUserSkillTool(workspaceId), - getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), - processContextsServer( - agentMentions, - userId, - lastUserMessage, - workspaceId, - effectiveChatId - ).catch((error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - }), - ]) + const [workspaceContext, integrationTools, userPermission, agentContexts] = await Promise.all([ + generateWorkspaceContext(workspaceId, userId), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), + processContextsServer( + agentMentions, + 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, @@ -180,7 +177,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), ...(agentContexts.length > 0 ? { contexts: agentContexts } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), } diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index fa55168db32..bb9f2618984 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -18,6 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useSession } from '@/lib/auth/auth-client' import type { OAuthReturnContext } from '@/lib/credentials/client-state' import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' +import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { getProviderIdFromServiceId, OAUTH_PROVIDERS, @@ -30,18 +31,6 @@ import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections' const logger = createLogger('ConnectOAuthModal') -/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */ -const DISPLAY_NAME_MAX_LENGTH = 255 - -/** - * Reserved tail budget when truncating the username so the auto-numbering - * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}. - */ -const COLLISION_SUFFIX_RESERVATION = 5 - -/** Upper bound for the auto-numbering search — pathological if ever reached. */ -const MAX_COLLISION_INDEX = 10000 - const EMPTY_SCOPES: readonly string[] = [] type ServiceIcon = ComponentType<{ className?: string }> @@ -51,44 +40,6 @@ function isHiddenScope(scope: string): boolean { return scope.includes('userinfo.email') || scope.includes('userinfo.profile') } -/** - * Default credential display name. Produces `"{Name}'s {Service}"` when the - * user's name is known, falling back to `"My {Service}"` otherwise. The - * username is truncated so the full string (including any auto-numbering - * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}. - * - * When the base name collides with an existing credential in `takenNames`, - * `" 2"`, `" 3"`, ... are appended until an unused name is found. Comparison - * is case-insensitive to match the duplicate-detection used elsewhere in the - * modal. - */ -function defaultDisplayName( - userName: string | null | undefined, - serviceName: string, - takenNames: ReadonlySet -): string { - const trimmed = userName?.trim() - let base: string - if (trimmed) { - const suffix = `'s ${serviceName}` - const nameBudget = Math.max( - 0, - DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION - ) - const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed - base = `${safeName}${suffix}` - } else { - base = `My ${serviceName}` - } - - if (!takenNames.has(base.toLowerCase())) return base - for (let n = 2; n < MAX_COLLISION_INDEX; n++) { - const candidate = `${base} ${n}` - if (!takenNames.has(candidate.toLowerCase())) return candidate - } - return base -} - /** * Resolves the display name + icon for an OAuth `provider`/`serviceId` pair, * preferring the most specific service entry and falling back to the base @@ -255,7 +206,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { } if (!isConnect || prefilled.current || credentialsLoading) return prefilled.current = true - setDisplayName(defaultDisplayName(userName, providerName, takenNames)) + setDisplayName(defaultCredentialDisplayName(userName, providerName, takenNames)) setDescription('') setValidationError(null) setSubmitError(null) diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 91ebc37ea42..b2b4e22d1c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -10,19 +10,20 @@ import { ChipModalHeader, cn, Duplicate, + Split, ThumbsDown, ThumbsUp, Tooltip, toast, } from '@sim/emcn' -import { GitBranch } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback' import { useForkMothershipChat } from '@/hooks/queries/mothership-chats' import { useFolderStore } from '@/stores/folders/store' -const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file' +const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question' function toPlainText(raw: string): string { return ( @@ -153,6 +154,11 @@ export const MessageActions = memo(function MessageActions({ if (!chatId || !messageId || forkChat.isPending) return try { const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId }) + if (result.failedFileCopies) { + toast.warning( + `${result.failedFileCopies} file${result.failedFileCopies === 1 ? '' : 's'} could not be copied to the fork` + ) + } useFolderStore.getState().clearChatSelection() router.push(`/workspace/${params.workspaceId}/chat/${result.id}`) } catch { @@ -162,7 +168,10 @@ export const MessageActions = memo(function MessageActions({ const hasContent = Boolean(content) const canSubmitFeedback = Boolean(chatId && userQuery) - const canFork = false + // A live (just-streamed) assistant message carries a synthetic id that the + // persisted transcript doesn't know — forking it would 400. The button + // appears once the transcript refetch swaps in the persisted message id. + const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId)) if (!hasContent && !canSubmitFeedback && !canFork) return null return ( @@ -220,15 +229,15 @@ export const MessageActions = memo(function MessageActions({ - Fork from here + Branch in new chat )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 1283dc8617e..91698fdc02b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -99,7 +99,8 @@ function endsInlineWord(value: string): boolean { function nextInlineSegmentLabel(segment?: ContentSegment): string { if (!segment) return '' - if (segment.type === 'text' || segment.type === 'thinking') return segment.content + // Thinking segments are never rendered, so they contribute no following text. + if (segment.type === 'text') return segment.content if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || '' return '' } @@ -304,6 +305,8 @@ const MARKDOWN_COMPONENTS = { interface ChatContentProps { content: string isStreaming?: boolean + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void @@ -312,6 +315,7 @@ interface ChatContentProps { function ChatContentInner({ content, isStreaming = false, + questionAnswers, onOptionSelect, onWorkspaceResourceSelect, onRevealStateChange, @@ -468,7 +472,11 @@ function ChatContentInner({ `[${label}](<#wsres-${s.data.type}-${ref}>)`, nextSegment ) - } else if (s.type === 'text' || s.type === 'thinking') { + } else if (s.type === 'thinking') { + // Model-emitted tag bodies are reasoning, not answer text — + // never rendered (matches the block-level thinking omission in + // message-content and the tag stripping in the inbox executor). + } else if (s.type === 'text') { pendingMarkdown += s.content } else { flushMarkdown() @@ -513,6 +521,7 @@ function ChatContentInner({ ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts index f211a5f42e5..8151e32f11c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts @@ -2,4 +2,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group' export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group' export { ChatContent } from './chat-content' export { Options } from './options' +export { QuestionDisplay } from './question' export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts new file mode 100644 index 00000000000..5272577cfda --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts @@ -0,0 +1,5 @@ +export { + formatQuestionAnswerMessage, + parseQuestionAnswerMessage, + QuestionDisplay, +} from './question' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts new file mode 100644 index 00000000000..f3d7b9528ea --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + formatQuestionAnswerMessage, + parseQuestionAnswerMessage, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question' +import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +const QUESTIONS: QuestionItem[] = [ + { + type: 'single_select', + prompt: 'How should I handle the duplicates?', + options: [{ id: 'keep_newest', label: 'Keep the newest entry' }], + }, + { + type: 'single_select', + prompt: 'Delete 4 archived workflows?', + options: [ + { id: 'yes', label: 'Delete them' }, + { id: 'no', label: 'Cancel' }, + ], + }, + { + type: 'multi_select', + prompt: 'What time zone should the daily report run in?', + options: [ + { id: 'est', label: 'EST' }, + { id: 'pst', label: 'PST' }, + ], + }, +] + +describe('formatQuestionAnswerMessage', () => { + it('sends a prompt-answer line for a single question', () => { + expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe( + 'How should I handle the duplicates? — Keep the newest entry' + ) + }) + + it('sends one prompt-answer line per question for multi-step batches', () => { + expect(formatQuestionAnswerMessage(QUESTIONS, ['Keep the newest entry', 'Cancel', 'EST'])).toBe( + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'Delete 4 archived workflows? — Cancel\n' + + 'What time zone should the daily report run in? — EST' + ) + }) +}) + +describe('parseQuestionAnswerMessage', () => { + it('round-trips what formatQuestionAnswerMessage produces', () => { + const answers = ['Keep the newest entry', 'Cancel', 'EST, PST'] + const message = formatQuestionAnswerMessage(QUESTIONS, answers) + expect(parseQuestionAnswerMessage(QUESTIONS, message)).toEqual(answers) + }) + + it('round-trips a single question', () => { + const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['Merge them']) + expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual(['Merge them']) + }) + + it('rejects an unrelated user message (dismissed card, typed something else)', () => { + expect(parseQuestionAnswerMessage([QUESTIONS[0]], 'actually, show me the logs')).toBeNull() + }) + + it('rejects when the line count does not match the question count', () => { + const partial = formatQuestionAnswerMessage(QUESTIONS.slice(0, 2), ['A', 'B']) + expect(parseQuestionAnswerMessage(QUESTIONS, partial)).toBeNull() + }) + + it('rejects when a line pairs with the wrong prompt', () => { + const swapped = + 'Delete 4 archived workflows? — Cancel\n' + + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'What time zone should the daily report run in? — EST' + expect(parseQuestionAnswerMessage(QUESTIONS, swapped)).toBeNull() + }) + + it('preserves em-dashes inside the answer text', () => { + const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['newest — but keep backups']) + expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual([ + 'newest — but keep backups', + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx new file mode 100644 index 00000000000..2381993aa05 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -0,0 +1,477 @@ +'use client' + +import { useState } from 'react' +import { + ArrowRight, + Button, + Check, + ChevronLeft, + ChevronRight, + checkboxIconVariants, + checkboxVariants, + cn, + X, +} from '@sim/emcn' +import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' + +/** + * Builds the single user message sent after the final question is answered: + * one `Prompt — Answer` line per question, for lone questions too. The uniform + * shape is what lets the chat pair this message back to its question card + * (see parseQuestionAnswerMessage) and render the card as the user turn + * instead of echoing a duplicate bubble. + */ +export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string { + return questions.map((q, i) => `${q.prompt} — ${answers[i] ?? ''}`).join('\n') +} + +/** + * Strictly matches a user message against a question batch's answer format: + * exactly one `Prompt — Answer` line per question, in order. Returns the + * answers, or null when the message is not this batch's answer — a dismissed + * card followed by an unrelated typed message must not match. + */ +export function parseQuestionAnswerMessage( + questions: QuestionItem[], + content: string +): string[] | null { + const lines = content.split('\n') + if (lines.length !== questions.length) return null + const answers: string[] = [] + for (const [i, question] of questions.entries()) { + const prefix = `${question.prompt} — ` + if (!lines[i].startsWith(prefix)) return null + answers.push(lines[i].slice(prefix.length)) + } + return answers +} + +const OPTION_ROW_CLASSES = + 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors' + +/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */ +const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0' + +/** Leading number slot matching the suggested follow-ups rows. */ +function RowNumber({ value }: { value: number }) { + return ( +
+ {value} +
+ ) +} + +/** + * Leading checkbox slot for multi_select rows. Purely presentational — it + * reuses the emcn Checkbox chrome via its exported variants, but the row + * button (or the free-text input) owns the interaction, so nesting a real + * Radix checkbox button inside the row button is avoided. + */ +function RowCheckbox({ checked, disabled }: { checked: boolean; disabled?: boolean }) { + return ( +
+ + {checked && ( + + )} + +
+ ) +} + +type QuestionPhase = 'active' | 'answered' | 'dismissed' + +interface QuestionDisplayProps { + data: QuestionItem[] + /** + * Answers resolved from the transcript (the paired user message that + * answered this card). When present the card renders as the answered recap + * — it IS the user turn; the paired message bubble is hidden by the chat. + */ + answers?: string[] + /** Sends the combined answer as a user message; undefined renders the div inert. */ + onSelect?: (message: string) => void +} + +/** + * Inline renderer for the `` special tag: a chat-inline div with the + * user input's chrome, the current question's prompt at the top left, dismiss + * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and + * suggested-action option rows beneath, always followed by a "Something else" + * row that reads as a plain option until clicked and then becomes the focused + * text box. `single_select` answers and advances on click (or on submitting + * typed text); `multi_select` rows toggle checkboxes and an option-styled + * Submit row confirms the step. Answering the last question sends one + * combined user message and collapses the div to a question/answer recap. + */ +export function QuestionDisplay({ + data, + answers: transcriptAnswers, + onSelect, +}: QuestionDisplayProps) { + const disabled = !onSelect + const [phase, setPhase] = useState('active') + const [step, setStep] = useState(0) + const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => [])) + const [customByStep, setCustomByStep] = useState(() => data.map(() => '')) + const [freeText, setFreeText] = useState('') + // The "Something else" row reads as a plain option until clicked, then + // becomes the focused text box (and reverts when left empty). + const [freeTextEditing, setFreeTextEditing] = useState(false) + // multi_select only: whether the typed "Something else" text is included in + // the answer. Unchecking keeps the text; it just stops counting. + const [customCheckedByStep, setCustomCheckedByStep] = useState(() => + data.map(() => false) + ) + + // The typed text that actually joins a step's answer: multi_select customs + // only count while checked; single_select customs always count. + const customFor = (i: number, customs: string[]): string => + data[i].type === 'multi_select' && !(customCheckedByStep[i] ?? false) ? '' : (customs[i] ?? '') + + const containerClasses = + 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]' + + // Transcript answers win over local state: they survive reloads (local + // phase does not) and keep live + rehydrated renders identical. + const localAnswers = + phase === 'answered' + ? data.map((question, i) => + answerFor(question, selectedByStep[i] ?? [], customFor(i, customByStep)) + ) + : null + const recapAnswers = transcriptAnswers ?? localAnswers + if (data.length > 0 && recapAnswers) { + return ( +
+ {data.map((question, i) => ( +
+

{question.prompt}

+

{recapAnswers[i]}

+
+ ))} +
+ ) + } + + if (data.length === 0 || phase === 'dismissed') return null + + const question = data[step] + const isLast = step === data.length - 1 + const options = question.options + const selected = selectedByStep[step] ?? [] + const isMulti = question.type === 'multi_select' + + const commitCustom = (): string[] => { + const next = [...customByStep] + next[step] = freeText.trim() + setCustomByStep(next) + return next + } + + const goToStep = (next: number) => { + commitCustom() + setStep(next) + const prefill = customByStep[next] ?? '' + setFreeText(prefill) + setFreeTextEditing(prefill.trim().length > 0) + } + + const finishStep = (selections: string[][], customs: string[]) => { + if (!isLast) { + setStep(step + 1) + const prefill = customs[step + 1] ?? '' + setFreeText(prefill) + setFreeTextEditing(prefill.trim().length > 0) + return + } + setPhase('answered') + onSelect?.( + formatQuestionAnswerMessage( + data, + data.map((q, i) => answerFor(q, selections[i] ?? [], customFor(i, customs))) + ) + ) + } + + const handleSingleSelect = (label: string) => { + const selections = [...selectedByStep] + selections[step] = [label] + setSelectedByStep(selections) + const customs = [...customByStep] + customs[step] = '' + setCustomByStep(customs) + setFreeText('') + finishStep(selections, customs) + } + + const handleMultiToggle = (label: string) => { + const selections = [...selectedByStep] + const current = selections[step] ?? [] + selections[step] = current.includes(label) + ? current.filter((l) => l !== label) + : [...current, label] + setSelectedByStep(selections) + } + + /** multi_select confirm: commits selections and/or typed text, then advances. */ + const submitMultiStep = () => { + finishStep(selectedByStep, commitCustom()) + } + + /** Sets whether the typed "Something else" text counts — never touches the text. */ + const setCustomChecked = (checked: boolean) => { + const next = [...customCheckedByStep] + next[step] = checked + setCustomCheckedByStep(next) + } + + /** single_select free-text arrow: the typed text IS the answer. */ + const submitSingleFreeText = () => { + const customs = commitCustom() + const selections = [...selectedByStep] + selections[step] = [] + setSelectedByStep(selections) + finishStep(selections, customs) + } + + const stepAnswered = (i: number): boolean => { + if ((selectedByStep[i]?.length ?? 0) > 0) return true + const text = i === step ? freeText : (customByStep[i] ?? '') + if (text.trim().length === 0) return false + return data[i].type === 'multi_select' ? (customCheckedByStep[i] ?? false) : true + } + + const canSubmitStep = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0) + + return ( +
+
+

+ {question.prompt} +

+
+ {data.length > 1 && ( +
+ + + {step + 1} of {data.length} + + +
+ )} + {!disabled && ( + + )} +
+
+
+ {options.map((option, i) => { + const isSelected = selected.includes(option.label) + return ( + + ) + })} + {freeTextEditing ? ( +
0 && 'border-t')}> + {isMulti ? ( + // Checked from the moment the row is clicked into; blur with + // nothing typed reverts to the plain option row. A real button + // (the editing row is a div, so no nesting hazard) so the box + // can be toggled even after typing — unchecking keeps the text, + // it just stops counting toward the answer. +
+ +
+ ) : ( + + )} + setFreeText(e.target.value)} + onBlur={() => { + if (freeText.trim().length === 0) { + setFreeTextEditing(false) + if (isMulti) setCustomChecked(false) + } + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.currentTarget.blur() + return + } + if (e.key === 'Enter' && canSubmitStep) { + e.preventDefault() + if (isMulti) { + submitMultiStep() + } else { + submitSingleFreeText() + } + } + }} + aria-label={question.prompt} + className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none disabled:cursor-not-allowed' + /> + {!isMulti && ( + + )} +
+ ) : ( + + )} + {isMulti && ( + + )} +
+
+ ) +} + +/** + * A step's combined answer: selected option labels in option order, with the + * typed "Something else" entry appended last. single_select carries at most + * one selection, so this collapses to the chosen label or the typed text. + */ +function answerFor(question: QuestionItem, selected: string[], custom: string): string { + const ordered = question.options + .map((option) => option.label) + .filter((label) => selected.includes(label)) + const parts = custom.trim() ? [...ordered, custom.trim()] : ordered + return parts.join(', ') +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index c45b9199c31..16dbf2783e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -6,6 +6,10 @@ export type { MothershipErrorTagData, OptionsTagData, ParsedSpecialContent, + QuestionItem, + QuestionOption, + QuestionTagData, + QuestionType, RuntimeSpecialTagName, UsageUpgradeAction, UsageUpgradeTagData, @@ -17,9 +21,12 @@ export { PendingTagIndicator, parseFileTag, parseJsonTagBody, + parseLastQuestionTag, + parseQuestionTagBody, parseSpecialTags, parseTagAttributes, parseTextTagBody, + QUESTION_TYPES, SpecialTags, USAGE_UPGRADE_ACTIONS, WORKSPACE_RESOURCE_TAG_TYPES, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts new file mode 100644 index 00000000000..638e3c3a747 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + parseQuestionTagBody, + parseSpecialTags, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +const SINGLE_SELECT = { + type: 'single_select', + prompt: 'How should I handle the duplicate emails?', + options: [ + { id: 'keep_newest', label: 'Keep the newest entry' }, + { id: 'merge', label: 'Merge fields into one row' }, + ], +} + +const YES_NO = { + type: 'single_select', + prompt: 'Delete 4 archived workflows?', + options: [ + { id: 'yes', label: 'Delete them' }, + { id: 'no', label: 'Cancel' }, + ], +} + +const MULTI_SELECT = { + type: 'multi_select', + prompt: 'Which channels should the report go to?', + options: [ + { id: 'slack', label: 'Slack' }, + { id: 'email', label: 'Email' }, + { id: 'sheet', label: 'Google Sheet' }, + ], +} + +describe('parseQuestionTagBody', () => { + it('normalizes a single object body to a one-element array', () => { + expect(parseQuestionTagBody(JSON.stringify(SINGLE_SELECT))).toEqual([SINGLE_SELECT]) + }) + + it('preserves array order for multi-step bodies', () => { + const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])) + expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT]) + }) + + it('accepts multi_select questions', () => { + expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT]) + }) + + it('rejects single_select without options', () => { + expect(parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Pick' }))).toBe( + null + ) + }) + + it('rejects empty options', () => { + expect( + parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Sure?', options: [] })) + ).toBe(null) + }) + + it('rejects the removed text and confirm types', () => { + expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe( + null + ) + expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null) + }) + + it('strips agent-supplied catch-all options (the card provides its own)', () => { + const withOther = { + ...SINGLE_SELECT, + options: [...SINGLE_SELECT.options, { id: 'other', label: 'Something else' }], + } + expect(parseQuestionTagBody(JSON.stringify(withOther))).toEqual([SINGLE_SELECT]) + }) + + it('rejects a question whose every option is a catch-all', () => { + const onlyOther = { + type: 'single_select', + prompt: 'Pick one', + options: [ + { id: 'a', label: 'Other' }, + { id: 'b', label: 'None of the above' }, + ], + } + expect(parseQuestionTagBody(JSON.stringify(onlyOther))).toBe(null) + }) + + it('rejects an empty prompt', () => { + expect(parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, prompt: ' ' }))).toBe(null) + }) + + it('rejects a malformed option', () => { + expect( + parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, options: [{ id: 'keep_newest' }] })) + ).toBe(null) + }) + + it('rejects an array containing one invalid question', () => { + expect(parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, { type: 'single_select' }]))).toBe( + null + ) + }) + + it('rejects empty arrays and non-JSON bodies', () => { + expect(parseQuestionTagBody('[]')).toBe(null) + expect(parseQuestionTagBody('not json')).toBe(null) + }) +}) + +describe('parseSpecialTags with ', () => { + it('extracts a complete question tag interleaved with text', () => { + const content = `Before the tag. ${JSON.stringify(SINGLE_SELECT)} After the tag.` + const { segments, hasPendingTag } = parseSpecialTags(content, false) + expect(hasPendingTag).toBe(false) + expect(segments).toEqual([ + { type: 'text', content: 'Before the tag. ' }, + { type: 'question', data: [SINGLE_SELECT] }, + { type: 'text', content: ' After the tag.' }, + ]) + }) + + it('extracts a multi-step array body as one segment', () => { + const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])}` + const { segments } = parseSpecialTags(content, false) + expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }]) + }) + + it('flags an unclosed question tag as pending while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags( + 'Thinking about it. [{"type":"single_sel', + true + ) + expect(hasPendingTag).toBe(true) + expect(segments).toEqual([{ type: 'text', content: 'Thinking about it. ' }]) + }) + + it('strips a trailing partial opening tag while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags('Let me ask. { + const { segments, hasPendingTag } = parseSpecialTags( + 'Before. {"type":"single_select"} After.', + false + ) + expect(hasPendingTag).toBe(false) + expect(segments).toEqual([ + { type: 'text', content: 'Before. ' }, + { type: 'text', content: ' After.' }, + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 5fa56531a0b..a23bc0427d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -5,8 +5,9 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUseUserPermissionsContext } = vi.hoisted(() => ({ +const { mockUseUserPermissionsContext, mockUseWorkspaceCredential } = vi.hoisted(() => ({ mockUseUserPermissionsContext: vi.fn(), + mockUseWorkspaceCredential: vi.fn(), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ @@ -17,8 +18,15 @@ vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) +vi.mock('@/hooks/queries/credentials', () => ({ + useWorkspaceCredential: mockUseWorkspaceCredential, +})) + import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' -import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' +import { + parseSpecialTags, + SpecialTags, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' /** * Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the @@ -38,6 +46,7 @@ describe('CredentialDisplay link tag', () => { beforeEach(() => { vi.clearAllMocks() mockUseUserPermissionsContext.mockReturnValue({ canEdit: true }) + mockUseWorkspaceCredential.mockReturnValue({ data: null }) }) it('does not render an anchor for a javascript: scheme value', () => { @@ -88,4 +97,64 @@ describe('CredentialDisplay link tag', () => { expect(container.querySelector('a')).toBeNull() act(() => root.unmount()) }) + + it('does not query a credential for a plain connect URL', () => { + const { root } = renderCredentialLink({ + type: 'link', + provider: 'github', + value: 'https://github.com/login/oauth/authorize?client_id=abc', + }) + + expect(mockUseWorkspaceCredential).toHaveBeenCalledWith(undefined) + act(() => root.unmount()) + }) + + it('labels a reconnect URL with the credential display name', () => { + mockUseWorkspaceCredential.mockReturnValue({ + data: { id: 'cred-1', displayName: "Justin's Gmail" }, + }) + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&workspaceId=ws-1&credentialId=cred-1', + }) + + expect(mockUseWorkspaceCredential).toHaveBeenCalledWith('cred-1') + expect(container.textContent).toContain("Reconnect Justin's Gmail") + act(() => root.unmount()) + }) + + it('falls back to the provider label while the reconnect credential is unresolved', () => { + const { container, root } = renderCredentialLink({ + type: 'link', + provider: 'google-email', + value: + 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&workspaceId=ws-1&credentialId=cred-1', + }) + + expect(container.textContent).toContain('Reconnect google-email') + act(() => root.unmount()) + }) +}) + +describe('parseSpecialTags sim_key placeholder', () => { + it('accepts a value-less {"type":"sim_key"} tag as a credential segment', () => { + const { segments } = parseSpecialTags('{"type":"sim_key"}', false) + const credential = segments.find((s) => s.type === 'credential') + expect(credential).toEqual({ type: 'credential', data: { type: 'sim_key' } }) + }) + + it('still accepts the legacy {"redacted":true} form as a value-less sim_key placeholder', () => { + const { segments } = parseSpecialTags( + '{"type":"sim_key","redacted":true}', + false + ) + const credential = segments.find((s) => s.type === 'credential') + expect(credential?.type).toBe('credential') + if (credential?.type === 'credential') { + expect(credential.data.type).toBe('sim_key') + expect(credential.data.value).toBeUndefined() + } + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index b303f7522bb..1068710b81d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -19,11 +19,13 @@ import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' +import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' import type { ChatMessageContext, MothershipResource, } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useWorkspaceCredential } from '@/hooks/queries/credentials' import { usePersonalEnvironment, useSavePersonalEnvironment, @@ -74,7 +76,6 @@ export interface CredentialTagData { value?: string type: CredentialTagType provider?: string - redacted?: boolean /** * Env-var key name to save the pasted secret under (secret_input only), * e.g. "OPENAI_API_KEY". @@ -96,6 +97,30 @@ export interface FileTagData { content: string } +export const QUESTION_TYPES = ['single_select', 'multi_select'] as const + +export type QuestionType = (typeof QUESTION_TYPES)[number] + +export interface QuestionOption { + id: string + label: string +} + +/** + * One question in a `` tag: a single_select or multi_select with at + * least one real option. The card always appends its own free-text "Something + * else" row, so agent-supplied catch-all options ("Other", "Something else", + * ...) are stripped during parsing. + */ +export interface QuestionItem { + type: QuestionType + prompt: string + options: QuestionOption[] +} + +/** Normalized `` payload: single-object bodies become a one-element array. */ +export type QuestionTagData = QuestionItem[] + export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number] @@ -115,6 +140,7 @@ export type ContentSegment = | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } + | { type: 'question'; data: QuestionTagData } export type RuntimeSpecialTagName = | 'thinking' @@ -123,6 +149,7 @@ export type RuntimeSpecialTagName = | 'mothership-error' | 'file' | 'workspace_resource' + | 'question' export interface ParsedSpecialContent { segments: ContentSegment[] @@ -136,6 +163,7 @@ const RUNTIME_SPECIAL_TAG_NAMES = [ 'mothership-error', 'file', 'workspace_resource', + 'question', ] as const const SPECIAL_TAG_NAMES = [ @@ -145,6 +173,7 @@ const SPECIAL_TAG_NAMES = [ 'credential', 'mothership-error', 'workspace_resource', + 'question', ] as const function isRecord(value: unknown): value is Record { @@ -191,7 +220,11 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { } return typeof value.name === 'string' && value.name.trim().length > 0 } - if (value.redacted === true) return value.value === undefined || typeof value.value === 'string' + // A sim_key chip is platform-filled: the model only marks where the workspace + // API key belongs (it never holds the value) and Sim injects it from the tool + // result, so the tag is valid with or without a `value`. Every other rendered + // type (e.g. link) needs a string value to render. + if (value.type === 'sim_key') return true return typeof value.value === 'string' } @@ -222,6 +255,83 @@ function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceT return id.length > 0 } +function isQuestionOption(value: unknown): value is QuestionOption { + if (!isRecord(value)) return false + return typeof value.id === 'string' && typeof value.label === 'string' +} + +/** + * Catch-all labels the agent must not supply as options — the card renders + * its own free-text "Something else" row. Matching options are stripped; a + * question left with no real options is invalid. + */ +const SELF_PROVIDED_OPTION_LABELS = new Set([ + 'other', + 'others', + 'something else', + 'none of the above', + 'none of these', +]) + +function isQuestionItem(value: unknown): value is QuestionItem { + if (!isRecord(value)) return false + if ( + typeof value.type !== 'string' || + !(QUESTION_TYPES as readonly string[]).includes(value.type) + ) { + return false + } + if (typeof value.prompt !== 'string' || value.prompt.trim().length === 0) return false + return ( + Array.isArray(value.options) && + value.options.length > 0 && + value.options.every(isQuestionOption) + ) +} + +/** Strips agent-supplied catch-all options; null when none remain. */ +function sanitizeQuestionItem(item: QuestionItem): QuestionItem | null { + const options = item.options.filter( + (option) => !SELF_PROVIDED_OPTION_LABELS.has(option.label.trim().toLowerCase()) + ) + if (options.length === 0) return null + return options.length === item.options.length ? item : { ...item, options } +} + +/** + * Parses a `` tag body. Accepts a single question object or a + * non-empty array of them; single objects are normalized to a one-element + * array so the renderer only handles the array shape. + */ +/** + * Extracts the last complete `` tag payload from raw message + * content. Used by the chat list to pair an assistant question card with the + * user message that answered it. + */ +export function parseLastQuestionTag(content: string): QuestionTagData | null { + const matches = content.match(/([\s\S]*?)<\/question>/g) + if (!matches || matches.length === 0) return null + const last = matches[matches.length - 1] + return parseQuestionTagBody(last.slice(''.length, -''.length)) +} + +export function parseQuestionTagBody(body: string): QuestionTagData | null { + try { + const parsed = JSON.parse(body) as unknown + const items = Array.isArray(parsed) ? parsed : [parsed] + if (items.length === 0 || !items.every(isQuestionItem)) return null + const sanitized: QuestionItem[] = [] + for (const item of items) { + const clean = sanitizeQuestionItem(item) + if (!clean) return null + sanitized.push(clean) + } + return sanitized + } catch { + return null + } +} + export function parseJsonTagBody( body: string, isExpectedShape: (value: unknown) => value is T @@ -270,6 +380,7 @@ function parseSpecialTagData( | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } + | { type: 'question'; data: QuestionTagData } | null { if (tagName === 'thinking') { const content = parseTextTagBody(body) @@ -301,6 +412,11 @@ function parseSpecialTagData( return data ? { type: 'workspace_resource', data } : null } + if (tagName === 'question') { + const data = parseQuestionTagBody(body) + return data ? { type: 'question', data } : null + } + return null } @@ -393,6 +509,8 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS interface SpecialTagsProps { segment: Exclude + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void } @@ -403,6 +521,7 @@ interface SpecialTagsProps { */ export function SpecialTags({ segment, + questionAnswers, onOptionSelect, onWorkspaceResourceSelect, }: SpecialTagsProps) { @@ -419,6 +538,10 @@ export function SpecialTags({ return case 'workspace_resource': return + case 'question': + return ( + + ) default: return null } @@ -730,36 +853,58 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } -function CredentialDisplay({ data }: { data: CredentialTagData }) { +function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() + // A connect URL carrying a credentialId re-authorizes that existing + // credential in place (reconnect) rather than creating a new one. + const reconnectCredentialId = useMemo(() => { + if (!data.value) return undefined + try { + return new URL(data.value).searchParams.get('credentialId') ?? undefined + } catch { + return undefined + } + }, [data.value]) + const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) + + // Connecting a credential mutates the workspace — hide it from read-only members. + if (!data.provider || !canEdit) return null + // The connect link value comes from the streamed model output, so only + // render it as a clickable link when it resolves to a real http(s) URL. + if (!data.value || !isSafeHttpUrl(data.value)) return null + const Icon = getCredentialIcon(data.provider) ?? LockIcon + const label = reconnectCredentialId + ? `Reconnect ${reconnectCredential?.displayName ?? data.provider}` + : `Connect ${data.provider}` + return ( + + {createElement(Icon, { className: 'size-[16px] shrink-0' })} + {label} + + + ) +} + +function CredentialDisplay({ data }: { data: CredentialTagData }) { if (data.type === 'secret_input') { return } if (data.type === 'link') { - // Connecting a credential mutates the workspace — hide it from read-only members. - if (!data.provider || !canEdit) return null - // The connect link value comes from the streamed model output, so only - // render it as a clickable link when it resolves to a real http(s) URL. - if (!data.value || !isSafeHttpUrl(data.value)) return null - const Icon = getCredentialIcon(data.provider) ?? LockIcon - return ( - - {createElement(Icon, { className: 'size-[16px] shrink-0' })} - Connect {data.provider} - - - ) + return } if (data.type === 'sim_key') { - return + // SecretReveal masks itself when there's no value, so a value-less tag (the + // model's placeholder / persisted form) renders masked and a Sim-filled tag + // reveals the key + copy button — no separate "redacted" flag needed. + return } return null @@ -777,7 +922,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit' return ( -
+
void onPhaseChange?: (phase: MessagePhase) => void } @@ -659,6 +669,7 @@ function MessageContentInner({ blocks, fallbackContent, isStreaming = false, + questionAnswers, onOptionSelect, onPhaseChange, }: MessageContentProps) { @@ -724,6 +735,7 @@ function MessageContentInner({ segmentIndex: i, segmentCount: segments.length, })} + questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onWorkspaceResourceSelect={onWorkspaceResourceSelect} onRevealStateChange={ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 6a06125c07a..dae69811183 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -30,6 +30,9 @@ const TOOL_ICONS: Record = { glob: FolderCode, grep: Search, read: File, + mv: FolderCode, + cp: Layout, + mkdir: FolderCode, search_online: Search, scrape_page: Search, get_page_contents: Search, @@ -38,6 +41,7 @@ const TOOL_ICONS: Record = { manage_skill: Asterisk, user_memory: Database, function_execute: TerminalWindow, + run_code: TerminalWindow, superagent: Blimp, user_table: TableIcon, workspace_file: File, @@ -51,12 +55,16 @@ const TOOL_ICONS: Record = { auth: Integration, knowledge: Database, knowledge_base: Database, + search_knowledge_base: Database, table: TableIcon, + query_user_table: TableIcon, scheduled_task: Calendar, job: Calendar, agent: AgentIcon, custom_tool: Wrench, research: Search, + scout: Search, + search: Search, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 841538b2e17..c85ef9aa7db 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -20,7 +20,11 @@ import { MessageContent, type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' -import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' +import { + PendingTagIndicator, + parseLastQuestionTag, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -163,6 +167,8 @@ interface AssistantMessageRowProps { message: ChatMessage isStreaming: boolean precedingUserContent?: string + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] rowClassName: string onOptionSelect?: (id: string) => void onAnimatingChange?: (animating: boolean) => void @@ -172,6 +178,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ message, isStreaming, precedingUserContent, + questionAnswers, rowClassName, onOptionSelect, onAnimatingChange, @@ -197,7 +204,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ return null } - const showActions = phase === 'settled' && (message.content || hasAnyBlocks) + // A message that ends with a question card is an input surface, not a + // reactable assistant turn: no copy/thumbs row beneath the card, whether + // the card is awaiting answers or collapsed to its recap. + const endsWithQuestion = trimmedContent.endsWith('') + const showActions = phase === 'settled' && !endsWithQuestion && (message.content || hasAnyBlocks) return (
@@ -205,6 +216,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ blocks={blocks} fallbackContent={message.content} isStreaming={isStreaming} + questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onPhaseChange={setPhase} /> @@ -303,6 +315,29 @@ export function MothershipChat({ return out }, [messages]) + /** + * Pairs each assistant question card with the user message that answered it + * (strict `Prompt — Answer` match). The paired user message is hidden — the + * answered card IS the user turn — and the assistant row renders the card + * as a recap with these answers, both live and after reload. + */ + const questionPairing = useMemo(() => { + const answersByIndex: Array = [] + const hiddenUserByIndex: Array = [] + for (const [index, message] of messages.entries()) { + if (message.role !== 'assistant' || !message.content?.includes('')) continue + const next = messages[index + 1] + if (!next || next.role !== 'user' || !next.content) continue + const questions = parseLastQuestionTag(message.content) + if (!questions) continue + const answers = parseQuestionAnswerMessage(questions, next.content) + if (!answers) continue + answersByIndex[index] = answers + hiddenUserByIndex[index + 1] = true + } + return { answersByIndex, hiddenUserByIndex } + }, [messages]) + /** * Always keep the last row in the rendered window. It is the live/streaming * row; unmounting it (by scrolling far enough up that it leaves the overscan @@ -424,19 +459,22 @@ export function MothershipChat({ style={{ transform: `translateY(${virtualItem.start}px)` }} > {msg.role === 'user' ? ( - + questionPairing.hiddenUserByIndex[index] ? null : ( + + ) ) : ( { qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(wId) }) + qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, task: (qc, wId) => { qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts new file mode 100644 index 00000000000..5526c48aca8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + invalidateResourceQueries: vi.fn(), + removeWorkflowFromActiveCache: vi.fn(), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry', + () => ({ invalidateResourceQueries: mocks.invalidateResourceQueries }) +) +vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ + removeWorkflowFromActiveCache: mocks.removeWorkflowFromActiveCache, +})) + +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers' + +function removeEvent(type: 'workflow' | 'file', id: string): PersistedStreamEventEnvelope { + return { + type: 'resource', + v: 1, + seq: 1, + ts: '', + stream: { streamId: 's', cursor: '1' }, + payload: { op: 'remove', resource: { type, id, title: id } }, + } as PersistedStreamEventEnvelope +} + +describe('handleResourceEvent removal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('closes a deleted workflow tab and removes it from the established workflow cache', () => { + const deps = makeStreamLoopDeps() + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, removeEvent('workflow', 'wf-1')) + + expect(deps.removeResource).toHaveBeenCalledWith('workflow', 'wf-1') + expect(mocks.removeWorkflowFromActiveCache).toHaveBeenCalledWith( + deps.queryClient, + 'ws-1', + 'wf-1' + ) + expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith( + deps.queryClient, + 'ws-1', + 'workflow', + 'wf-1' + ) + }) + + it('closes other resource tabs through the same remove event path', () => { + const deps = makeStreamLoopDeps() + const ctx = { deps } as StreamLoopContext + + handleResourceEvent(ctx, removeEvent('file', 'file-1')) + + expect(deps.removeResource).toHaveBeenCalledWith('file', 'file-1') + expect(mocks.removeWorkflowFromActiveCache).not.toHaveBeenCalled() + expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith( + deps.queryClient, + 'ws-1', + 'file', + 'file-1' + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index b94384a8b34..03af5c60165 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -11,6 +11,7 @@ import { } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' type ResourceEvent = Extract< @@ -46,13 +47,12 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven const resource = payload.resource if (payload.op === MothershipStreamV1ResourceOp.remove) { - removeResource(resource.type as MothershipResourceType, resource.id) - invalidateResourceQueries( - queryClient, - workspaceId, - resource.type as MothershipResourceType, - resource.id - ) + const resourceType = resource.type as MothershipResourceType + removeResource(resourceType, resource.id) + if (resourceType === 'workflow') { + removeWorkflowFromActiveCache(queryClient, workspaceId, resource.id) + } + invalidateResourceQueries(queryClient, workspaceId, resourceType, resource.id) onResourceEvent?.() return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index b49871bfbdf..8d1622f49cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -187,7 +187,8 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext captureRevealedSimKeys( deps.revealedSimKeysRef.current, [deps.assistantId, state.streamRequestId], - modelContent + modelContent, + modelBlocks ) const activeChatId = deps.options.targetChatId ?? deps.chatIdRef.current if (!activeChatId) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 72e1bd2dabc..d63a45c2892 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -21,10 +21,8 @@ import { ManageScheduledTaskOperation, ManageSkill, ManageSkillOperation, - MoveWorkflow, QueryLogs, Redeploy, - RenameWorkflow, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -34,7 +32,7 @@ import { WorkspaceFileOperation, } from '@/lib/copilot/generated/tool-catalog-v1' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' -import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display' import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' @@ -52,12 +50,15 @@ export const DEPLOY_TOOL_NAMES: Set = new Set([ Redeploy.id, ]) -export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id]) +export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id, 'mkdir', 'mv']) export const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([ - MoveWorkflow.id, - RenameWorkflow.id, + 'mv', + 'cp', DeleteWorkflow.id, + // Removed legacy tools, kept while their grace-period executors remain. + 'move_workflow', + 'rename_workflow', ]) export type StreamPayload = Record @@ -289,6 +290,30 @@ export function resolveStreamingToolDisplayTitle( return toolTitle ? `Finding ${toolTitle}` : undefined } + if (name === 'mv') { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + if (!toolTitle) return undefined + // Same rename-vs-move derivation as the settled title: single source with + // only the leaf changing reads as a rename. + const multiSource = /"sources"\s*:\s*\[\s*"[^"]*"\s*,/.test(streamingArgs) + const firstSource = streamingArgs.match(/"sources"\s*:\s*\[\s*"([^"]*)"/m)?.[1] + const destination = matchStreamingStringArg(streamingArgs, 'destination') + const verb = multiSource + ? 'Moving' + : mvDisplayVerb(firstSource ? decodeStreamingString(firstSource) : undefined, destination) + return `${verb} ${toolTitle}` + } + + if (name === 'cp') { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + return toolTitle ? `Duplicating ${toolTitle}` : undefined + } + + if (name === 'mkdir') { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + return toolTitle ? `Creating ${toolTitle}` : undefined + } + if (name === ScrapePage.id) { const url = matchStreamingStringArg(streamingArgs, 'url') return url ? `Scraping ${url}` : undefined @@ -364,12 +389,15 @@ export function resolveStreamingToolDisplayTitle( } if (name === ManageFolder.id) { + // create/rename/move are string literals: the live tool only offers delete + // (mkdir/mv replaced the rest), but grace-period checkpoint resumes and + // transcript replays still stream the legacy operations. return resolveOperationDisplayTitle( matchStreamingStringArg(streamingArgs, 'operation'), { - [ManageFolderOperation.create]: 'Creating folder', - [ManageFolderOperation.rename]: 'Renaming folder', - [ManageFolderOperation.move]: 'Moving folder', + create: 'Creating folder', + rename: 'Renaming folder', + move: 'Moving folder', [ManageFolderOperation.delete]: 'Deleting folder', }, 'Folder action' diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 20bc2513dab..2fb75a58b94 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -157,6 +157,8 @@ export const SUBAGENT_LABELS: Record = { knowledge: 'Knowledge Agent', table: 'Table Agent', custom_tool: 'Custom Tool Agent', + scout: 'Scout Agent', + search: 'Search Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index e88c30a17cd..629524f244e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -61,8 +61,7 @@ export function getBlockIconAndColor( if (mcpBlock) return { icon: mcpBlock.icon, bgColor: mcpBlock.bgColor } } const normalized = normalizeToolId(toolName) - if (normalized === 'load_skill' || normalized === 'load_user_skill') - return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } + if (normalized === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } const toolBlock = getBlockByToolName(normalized) if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor } } diff --git a/apps/sim/blocks/registry-maps.minimal.ts b/apps/sim/blocks/registry-maps.minimal.ts index 585da915b1c..454d23fabb0 100644 --- a/apps/sim/blocks/registry-maps.minimal.ts +++ b/apps/sim/blocks/registry-maps.minimal.ts @@ -9,6 +9,7 @@ import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { FileV5Block } from '@/blocks/blocks/file' import { FunctionBlock } from '@/blocks/blocks/function' import { GenericWebhookBlock } from '@/blocks/blocks/generic_webhook' +import { GmailV2Block } from '@/blocks/blocks/gmail' import { GuardrailsBlock } from '@/blocks/blocks/guardrails' import { HumanInTheLoopBlock } from '@/blocks/blocks/human_in_the_loop' import { ImapBlock } from '@/blocks/blocks/imap' @@ -23,6 +24,7 @@ import { RssBlock } from '@/blocks/blocks/rss' import { ScheduleBlock } from '@/blocks/blocks/schedule' import { SearchBlock } from '@/blocks/blocks/search' import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event' +import { SlackBlock } from '@/blocks/blocks/slack' import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' import { TableBlock } from '@/blocks/blocks/table' import { TranslateBlock } from '@/blocks/blocks/translate' @@ -39,7 +41,9 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' * ~268. The set is drawn from the canonical, toolbar-visible blocks * (`category: 'blocks'` / `'triggers'`, not `hideFromToolbar`, always the latest * version — never a superseded one). The ~247 `category: 'tools'` integrations - * are excluded (that is the heavy graph minimal mode exists to skip), and so are + * are excluded (that is the heavy graph minimal mode exists to skip) — except + * `slack` and `gmail_v2`, kept as the two everyday integrations (their tools are + * likewise included in `tools/registry.minimal.ts`) — and so are * a few heavy or rarely-core-dev blocks pruned by hand: `mothership` and `pi` * (chunkiest configs), the media blocks `tts` / `stt_v2` / `image_generator_v2` * / `video_generator_v3`, and the `circleback` meeting-notetaker integration @@ -58,6 +62,7 @@ export const BLOCK_REGISTRY: Record = { file_v5: FileV5Block, function: FunctionBlock, generic_webhook: GenericWebhookBlock, + gmail_v2: GmailV2Block, guardrails: GuardrailsBlock, human_in_the_loop: HumanInTheLoopBlock, imap: ImapBlock, @@ -72,6 +77,7 @@ export const BLOCK_REGISTRY: Record = { schedule: ScheduleBlock, search: SearchBlock, sim_workspace_event: SimWorkspaceEventBlock, + slack: SlackBlock, start_trigger: StartTriggerBlock, table: TableBlock, translate: TranslateBlock, diff --git a/apps/sim/executor/handlers/agent/skills-resolver.test.ts b/apps/sim/executor/handlers/agent/skills-resolver.test.ts index fdb2cb18c2b..e5e74ba4222 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.test.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.test.ts @@ -20,8 +20,8 @@ vi.mock('drizzle-orm', () => ({ import { resolveSkillContent } from './skills-resolver' -// resolveSkillContent is the shared resolver invoked when the mothership calls -// load_user_skill (and when a workflow agent block calls load_skill). +// resolveSkillContent is the shared resolver invoked when a workflow agent block +// calls load_skill. describe('resolveSkillContent', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 1d802c13d93..606dc0020e3 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -675,12 +675,12 @@ export function useCreateMothershipChat(workspaceId?: string) { async function forkChat(params: { chatId: string upToMessageId: string -}): Promise<{ id: string }> { +}): Promise<{ id: string; failedFileCopies?: number }> { const data = await requestJson(forkMothershipChatContract, { params: { chatId: params.chatId }, body: { upToMessageId: params.upToMessageId }, }) - return { id: data.id } + return { id: data.id, failedFileCopies: data.failedFileCopies } } export function useForkMothershipChat(workspaceId?: string) { diff --git a/apps/sim/hooks/queries/utils/workflow-cache.test.ts b/apps/sim/hooks/queries/utils/workflow-cache.test.ts index a3ccf8da645..3db8179f1a7 100644 --- a/apps/sim/hooks/queries/utils/workflow-cache.test.ts +++ b/apps/sim/hooks/queries/utils/workflow-cache.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' const { getQueryDataMock } = vi.hoisted(() => ({ @@ -13,7 +14,23 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({ })), })) -import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { + getWorkflowById, + getWorkflows, + removeWorkflowFromActiveCache, +} from '@/hooks/queries/utils/workflow-cache' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +function workflow(id: string): WorkflowMetadata { + return { + id, + name: id, + lastModified: new Date(0), + createdAt: new Date(0), + sortOrder: 0, + } +} describe('getWorkflows', () => { beforeEach(() => { @@ -44,3 +61,17 @@ describe('getWorkflows', () => { expect(getWorkflowById('ws-1', 'missing')).toBeUndefined() }) }) + +describe('removeWorkflowFromActiveCache', () => { + it('removes the deleted workflow immediately and returns the rollback snapshot', () => { + const queryClient = new QueryClient() + const key = workflowKeys.list('ws-1', 'active') + const initial = [workflow('wf-1'), workflow('wf-2')] + queryClient.setQueryData(key, initial) + + const snapshot = removeWorkflowFromActiveCache(queryClient, 'ws-1', 'wf-1') + + expect(snapshot).toEqual(initial) + expect(queryClient.getQueryData(key)).toEqual([workflow('wf-2')]) + }) +}) diff --git a/apps/sim/hooks/queries/utils/workflow-cache.ts b/apps/sim/hooks/queries/utils/workflow-cache.ts index c810a236f87..e12c802821d 100644 --- a/apps/sim/hooks/queries/utils/workflow-cache.ts +++ b/apps/sim/hooks/queries/utils/workflow-cache.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/react-query' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -27,3 +28,21 @@ export function getWorkflowById( ): WorkflowMetadata | undefined { return getWorkflows(workspaceId, scope).find((workflow) => workflow.id === workflowId) } + +/** + * Removes a workflow from the active-list cache immediately and returns the + * previous value for callers that need rollback. Shared by user-initiated + * deletion and streamed Mothership resource removals. + */ +export function removeWorkflowFromActiveCache( + queryClient: QueryClient, + workspaceId: string, + workflowId: string +): WorkflowMetadata[] | undefined { + const key = workflowKeys.list(workspaceId, 'active') + const snapshot = queryClient.getQueryData(key) + queryClient.setQueryData(key, (current) => + (current ?? []).filter((workflow) => workflow.id !== workflowId) + ) + return snapshot +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 15aed17e712..a1c08d91986 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -32,7 +32,7 @@ import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-enve import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { getWorkflows, removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { getWorkflowListQueryOptions, @@ -534,13 +534,10 @@ export function useDeleteWorkflowMutation() { queryKey: workflowKeys.list(variables.workspaceId, 'active'), }) - const snapshot = queryClient.getQueryData( - workflowKeys.list(variables.workspaceId, 'active') - ) - - queryClient.setQueryData( - workflowKeys.list(variables.workspaceId, 'active'), - (old) => (old ?? []).filter((w) => w.id !== variables.workflowId) + const snapshot = removeWorkflowFromActiveCache( + queryClient, + variables.workspaceId, + variables.workflowId ) return { snapshot } diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 0165c512bd3..99bff9146c5 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -276,6 +276,13 @@ export const forkMothershipChatContract = defineRouteContract({ schema: z.object({ success: z.literal(true), id: z.string(), + /** + * Present (and > 0) when some file blobs could not be byte-copied: the + * new chat exists and its transcript references those copies, but their + * bytes are missing (blob copies are best-effort, post-transaction). + * Callers should surface a warning. + */ + failedFileCopies: z.number().optional(), }), }, }) diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index f451f555c1f..4fd6e09a0b8 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -195,6 +195,7 @@ export const authorizeOAuth2QuerySchema = z.object({ providerId: z.string().min(1, 'providerId is required'), workspaceId: workspaceIdSchema, callbackURL: z.string().min(1).optional(), + credentialId: z.string().min(1).optional(), }) export const authorizeOAuth2Contract = defineRouteContract({ diff --git a/apps/sim/lib/copilot/chat/effective-transcript.test.ts b/apps/sim/lib/copilot/chat/effective-transcript.test.ts index 285743d37ac..8f69d4fd581 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.test.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest' import { buildEffectiveChatTranscript, getLiveAssistantMessageId, + isLiveAssistantMessageId, } from '@/lib/copilot/chat/effective-transcript' import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { @@ -261,3 +262,11 @@ describe('buildEffectiveChatTranscript', () => { ) }) }) + +describe('isLiveAssistantMessageId', () => { + it('recognizes the synthetic live-assistant id and nothing else', () => { + expect(isLiveAssistantMessageId(getLiveAssistantMessageId('stream-1'))).toBe(true) + expect(isLiveAssistantMessageId('f620fceb-4e9d-4e7f-ab7f-890a2a823564')).toBe(false) + expect(isLiveAssistantMessageId('')).toBe(false) + }) +}) diff --git a/apps/sim/lib/copilot/chat/effective-transcript.ts b/apps/sim/lib/copilot/chat/effective-transcript.ts index ae971047208..8673a6d27dd 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.ts @@ -34,6 +34,16 @@ export function getLiveAssistantMessageId(streamId: string): string { return `live-assistant:${streamId}` } +/** + * True for the synthetic id of a streaming/just-streamed assistant message. + * These ids exist only in the client's effective transcript — never in the + * persisted one — so message-scoped server actions (e.g. fork) must not be + * offered until the transcript refetch swaps in the persisted message id. + */ +export function isLiveAssistantMessageId(messageId: string): boolean { + return messageId.startsWith('live-assistant:') +} + function asPayloadRecord(value: unknown): Record | undefined { return isRecordLike(value) ? value : undefined } diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.test.ts b/apps/sim/lib/copilot/chat/fork-chat-files.test.ts new file mode 100644 index 00000000000..5d27ce67475 --- /dev/null +++ b/apps/sim/lib/copilot/chat/fork-chat-files.test.ts @@ -0,0 +1,210 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateKey, mockDownloadFile, mockUploadFile, mockIncrementStorageUsage } = vi.hoisted( + () => ({ + mockGenerateKey: vi.fn(), + mockDownloadFile: vi.fn(), + mockUploadFile: vi.fn(), + mockIncrementStorageUsage: vi.fn(), + }) +) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + generateWorkspaceFileKey: mockGenerateKey, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFile: mockDownloadFile, + uploadFile: mockUploadFile, +})) + +vi.mock('@/lib/billing/storage', () => ({ + incrementStorageUsage: mockIncrementStorageUsage, +})) + +import { + executeChatFileBlobCopies, + type ForkableChatFileRow, + filterForkableChatFiles, + planChatFileCopies, +} from '@/lib/copilot/chat/fork-chat-files' + +const NOW = new Date('2026-07-08T00:00:00.000Z') + +function makeRow(overrides: Partial = {}): ForkableChatFileRow { + return { + id: 'wf_source', + key: 'workspace/ws-1/1-cat.png', + userId: 'user-1', + workspaceId: 'ws-1', + folderId: null, + context: 'mothership', + chatId: 'chat-1', + messageId: 'msg-1', + originalName: 'cat.png', + displayName: 'cat.png', + contentType: 'image/png', + size: 100, + deletedAt: null, + uploadedAt: new Date('2026-06-01T00:00:00.000Z'), + updatedAt: new Date('2026-06-01T00:00:00.000Z'), + ...overrides, + } as ForkableChatFileRow +} + +describe('planChatFileCopies', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGenerateKey.mockReturnValue('workspace/ws-1/2-cat.png') + }) + + it('copies a row under the fork with a fresh id + key and the SAME message_id', async () => { + const inserted: Array> = [] + const tx = { + insert: () => ({ + values: async (v: Record | Record[]) => { + inserted.push(...(Array.isArray(v) ? v : [v])) + }, + }), + } + + const { idMap, keyMap, blobTasks } = await planChatFileCopies({ + tx: tx as never, + rows: [makeRow()], + newChatId: 'chat-fork', + userId: 'user-1', + now: NOW, + }) + + expect(inserted).toHaveLength(1) + const copy = inserted[0] + expect(copy.id).not.toBe('wf_source') + expect(String(copy.id)).toMatch(/^wf_/) + expect(copy.key).toBe('workspace/ws-1/2-cat.png') + expect(copy.chatId).toBe('chat-fork') + expect(copy.messageId).toBe('msg-1') + expect(copy.displayName).toBe('cat.png') + expect(copy.deletedAt).toBeNull() + + expect(idMap.get('wf_source')).toBe(copy.id) + expect(keyMap.get('workspace/ws-1/1-cat.png')).toBe('workspace/ws-1/2-cat.png') + expect(blobTasks).toEqual([ + { + copyId: copy.id, + sourceKey: 'workspace/ws-1/1-cat.png', + targetKey: 'workspace/ws-1/2-cat.png', + context: 'mothership', + fileName: 'cat.png', + contentType: 'image/png', + }, + ]) + }) + + it('skips legacy rows with no workspaceId instead of failing the fork', async () => { + const inserted: Array> = [] + const tx = { + insert: () => ({ + values: async (v: Record | Record[]) => { + inserted.push(...(Array.isArray(v) ? v : [v])) + }, + }), + } + + const { idMap, blobTasks } = await planChatFileCopies({ + tx: tx as never, + rows: [makeRow({ workspaceId: null })], + newChatId: 'chat-fork', + userId: 'user-1', + now: NOW, + }) + + expect(inserted).toHaveLength(0) + expect(idMap.size).toBe(0) + expect(blobTasks).toHaveLength(0) + }) +}) + +describe('executeChatFileBlobCopies', () => { + const task = { + copyId: 'wf_copy', + sourceKey: 'workspace/ws-1/1-cat.png', + targetKey: 'workspace/ws-1/2-cat.png', + context: 'mothership' as const, + fileName: 'cat.png', + contentType: 'image/png', + } + + beforeEach(() => { + vi.clearAllMocks() + mockDownloadFile.mockResolvedValue(Buffer.from('0123456789')) + mockUploadFile.mockResolvedValue(undefined) + mockIncrementStorageUsage.mockResolvedValue(undefined) + }) + + it('copies bytes to the new key and counts them against the storage quota', async () => { + const result = await executeChatFileBlobCopies([task], { + userId: 'user-1', + workspaceId: 'ws-1', + }) + + expect(result).toEqual({ copied: 1, failed: 0, failedCopyIds: [] }) + expect(mockUploadFile).toHaveBeenCalledWith( + expect.objectContaining({ + customKey: 'workspace/ws-1/2-cat.png', + preserveKey: true, + }) + ) + // No `metadata` in the upload call — passing it would insert a second row. + expect(mockUploadFile.mock.calls[0][0].metadata).toBeUndefined() + expect(mockIncrementStorageUsage).toHaveBeenCalledWith('user-1', 10, 'ws-1') + }) + + it('is best-effort: a failed download skips the file, counts nothing, and reports its copy id', async () => { + mockDownloadFile.mockRejectedValueOnce(new Error('blob missing')) + + const result = await executeChatFileBlobCopies( + [task, { ...task, copyId: 'wf_copy2', targetKey: 'workspace/ws-1/3-cat.png' }], + { + userId: 'user-1', + workspaceId: 'ws-1', + } + ) + + // The first task's download failed — its copy id comes back for row cleanup. + expect(result).toEqual({ copied: 1, failed: 1, failedCopyIds: ['wf_copy'] }) + expect(mockIncrementStorageUsage).toHaveBeenCalledTimes(1) + }) + + it('copies every task even when the batch exceeds the concurrency bound', async () => { + const tasks = Array.from({ length: 9 }, (_, i) => ({ + ...task, + copyId: `wf_copy${i}`, + targetKey: `workspace/ws-1/${i}-cat.png`, + })) + + const result = await executeChatFileBlobCopies(tasks, { userId: 'user-1' }) + + expect(result.copied).toBe(9) + expect(mockUploadFile).toHaveBeenCalledTimes(9) + expect(new Set(mockUploadFile.mock.calls.map((c) => c[0].customKey)).size).toBe(9) + }) +}) + +describe('filterForkableChatFiles', () => { + it('keeps rows born in the kept slice plus NULL-birthdate legacy rows', () => { + const preCut = makeRow({ id: 'wf_pre', messageId: 'msg-1' }) + const postCut = makeRow({ id: 'wf_post', messageId: 'msg-3' }) + const legacy = makeRow({ id: 'wf_legacy', messageId: null }) + const secondPreCut = makeRow({ id: 'wf_pre2', messageId: 'msg-2' }) + + const kept = filterForkableChatFiles( + [preCut, postCut, legacy, secondPreCut], + new Set(['msg-1', 'msg-2']) + ) + + expect(kept.map((r) => r.id)).toEqual(['wf_pre', 'wf_legacy', 'wf_pre2']) + }) +}) diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.ts b/apps/sim/lib/copilot/chat/fork-chat-files.ts new file mode 100644 index 00000000000..ce076052711 --- /dev/null +++ b/apps/sim/lib/copilot/chat/fork-chat-files.ts @@ -0,0 +1,223 @@ +import { workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { and, eq, isNull } from 'drizzle-orm' +import { incrementStorageUsage } from '@/lib/billing/storage' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import type { DbOrTx } from '@/lib/db/types' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' +import type { StorageContext } from '@/lib/uploads/shared/types' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' + +const logger = createLogger('ForkChatFiles') + +/** + * The chat-owned storage context a fork copies: user uploads (`mothership`). + * A fork is a self-contained snapshot — bytes included (every copied row gets + * a fresh storage key; live rows can't share a key because of the + * `workspace_files_key_active_unique` index, and serve/view lookups resolve by + * key) — so the new chat survives deletion of the source chat. The copied set + * is timeline-cut to the fork point ({@link filterForkableChatFiles}). + * Shared workspace `files/` (`context='workspace'`) is workspace-owned, not + * chat-owned — both chats reference it in place and it is never copied. + */ +export const FORKABLE_CHAT_FILE_CONTEXT: StorageContext = 'mothership' + +/** Max concurrent blob byte-copies during a chat fork. */ +const CHAT_BLOB_COPY_CONCURRENCY = 4 + +export type ForkableChatFileRow = typeof workspaceFiles.$inferSelect + +/** One blob byte-copy to run after the fork transaction commits. */ +export interface ChatBlobCopyTask { + /** The copied `workspace_files` row's id — used to delete the row if the blob copy fails. */ + copyId: string + sourceKey: string + targetKey: string + context: StorageContext + fileName: string + contentType: string +} + +export interface PlanChatFileCopiesResult { + /** source `workspace_files.id` -> copy id (rewrites view-URLs, attachment ids, resource ids). */ + idMap: Map + /** source storage key -> copy storage key (rewrites serve-URLs, attachment keys). */ + keyMap: Map + /** Blob duplications to run after the transaction commits. */ + blobTasks: ChatBlobCopyTask[] +} + +/** + * Every live chat-owned file row (no timeline cut): the ghost test set for the + * resource-chip rewrite and the superset a fork cuts down in memory via + * {@link filterForkableChatFiles} — one `workspace_files` read serves both. + * Also used pre-transaction to sum sizes for the storage-quota gate. + */ +export async function listForkableChatFiles( + db: DbOrTx, + chatId: string +): Promise { + return db + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.chatId, chatId), + eq(workspaceFiles.context, FORKABLE_CHAT_FILE_CONTEXT), + isNull(workspaceFiles.deletedAt) + ) + ) +} + +/** + * The rows a fork copies out of the chat's owned files: those whose + * `message_id` is at-or-before the fork point (i.e. in the kept message + * slice). Rows with a NULL `message_id` — uploads that predate messageId + * stamping — are included in every fork of their chat: we can't know when + * they arrived, and copying them beats forking with broken references. Pure + * filter so the route reads `workspace_files` once per fork + * ({@link listForkableChatFiles}). + */ +export function filterForkableChatFiles( + rows: ForkableChatFileRow[], + keptMessageIds: ReadonlySet +): ForkableChatFileRow[] { + return rows.filter((row) => !row.messageId || keptMessageIds.has(row.messageId)) +} + +/** + * Insert copy rows for the kept chat-owned files under the new chat id (fresh + * `wf_` id + fresh storage key; `message_id` carries over verbatim so the copy + * matches the same message in the forked transcript; display names carry over + * verbatim because their uniqueness is per-chat and the new chat is an empty + * namespace). Returns the old->new id/key maps that drive the reference + * rewrite, plus the blob byte-copies to run post-commit. Runs inside the fork + * transaction so a failed insert rolls the whole fork back; blob I/O is + * deferred to {@link executeChatFileBlobCopies}. Modeled on the workspace-fork + * copy (`lib/workspaces/fork/copy/copy-files.ts`), adapted for chat-scoped rows. + */ +export async function planChatFileCopies(params: { + tx: DbOrTx + rows: ForkableChatFileRow[] + newChatId: string + userId: string + now: Date +}): Promise { + const { tx, rows, newChatId, userId, now } = params + const idMap = new Map() + const keyMap = new Map() + const blobTasks: ChatBlobCopyTask[] = [] + const copyRows: (typeof workspaceFiles.$inferInsert)[] = [] + + for (const row of rows) { + if (!row.workspaceId) { + logger.warn('Skipping chat file with no workspaceId during fork', { fileId: row.id }) + continue + } + const copyId = `wf_${generateShortId()}` + const targetKey = generateWorkspaceFileKey(row.workspaceId, row.originalName) + copyRows.push({ + ...row, + id: copyId, + key: targetKey, + chatId: newChatId, + userId, + deletedAt: null, + uploadedAt: now, + updatedAt: now, + }) + idMap.set(row.id, copyId) + keyMap.set(row.key, targetKey) + blobTasks.push({ + copyId, + sourceKey: row.key, + targetKey, + context: row.context as StorageContext, + fileName: row.originalName, + contentType: row.contentType, + }) + } + + // Ids and keys are generated client-side, so one multi-row insert suffices — + // no per-row round trips while the fork transaction is held open. + if (copyRows.length > 0) { + await tx.insert(workspaceFiles).values(copyRows) + } + + return { idMap, keyMap, blobTasks } +} + +/** + * Copy each planned blob to its new key, best-effort: a failed copy logs a + * warning and is skipped (the fork keeps its transcript; that one file is + * missing) rather than failing the whole fork. Runs a bounded worker pool + * ({@link CHAT_BLOB_COPY_CONCURRENCY}) — media-heavy chats must not pay 2N + * serial storage round-trips, but unbounded fan-out would buffer every file + * in memory at once. Each successfully copied file increments the + * storage-usage counter by its actual byte length. Failed tasks' copy-row ids + * are returned so the caller can delete the dead rows (row exists, blob + * doesn't) instead of leaving them listed in the VFS and resources with + * nothing behind them. + */ +export async function executeChatFileBlobCopies( + blobTasks: ChatBlobCopyTask[], + params: { userId: string; workspaceId?: string } +): Promise<{ copied: number; failed: number; failedCopyIds: string[] }> { + let copied = 0 + const failedCopyIds: string[] = [] + + const copyOne = async (task: ChatBlobCopyTask): Promise => { + try { + // No replay guard here, unlike the workspace-fork copy this is modeled + // on: that path persists its task list in a trigger.dev job payload + // (replayable), while these tasks exist only in this request's memory + // and target keys are freshly minted per request — a HEAD check could + // never find an earlier attempt's object. + const buffer = await downloadFile({ + key: task.sourceKey, + context: task.context, + maxBytes: MAX_FILE_SIZE, + }) + // No `metadata` here on purpose: passing it would make uploadFile insert + // its own workspace_files row (without chatId), colliding with the row + // the transaction already created for this key. + await uploadFile({ + file: buffer, + fileName: task.fileName, + contentType: task.contentType, + context: task.context, + customKey: task.targetKey, + preserveKey: true, + }) + copied += 1 + try { + // Forked bytes COUNT against the storage quota, deliberately diverging + // from the workspace-fork copy path + // (lib/workspaces/fork/copy/copy-files.ts), which copies blobs without + // counting them. A chat fork stores a second physical copy of every + // kept upload, so the counter must reflect it. Do not "fix" this back + // to the workspace-fork precedent. + await incrementStorageUsage(params.userId, buffer.length, params.workspaceId) + } catch (error) { + logger.error('Failed to update storage tracking for forked chat file', { + targetKey: task.targetKey, + error: getErrorMessage(error), + }) + } + } catch (error) { + failedCopyIds.push(task.copyId) + logger.warn('Failed to copy chat file blob during fork', { + sourceKey: task.sourceKey, + targetKey: task.targetKey, + error: getErrorMessage(error), + }) + } + } + + await mapWithConcurrency(blobTasks, CHAT_BLOB_COPY_CONCURRENCY, copyOne) + + return { copied, failed: failedCopyIds.length, failedCopyIds } +} diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index e718b33cce3..8a167750bc5 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -9,7 +9,6 @@ import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' -import { buildUserSkillTool } from '@/lib/mothership/skills' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { stripVersionSuffix } from '@/tools/utils' @@ -42,7 +41,6 @@ interface BuildPayloadParams { email?: string timezone?: string } - includeMothershipTools?: boolean } export interface ToolSchema { @@ -286,7 +284,8 @@ export async function buildCopilotRequestPayload( f.key, filename, mediaType, - f.size + f.size, + userMessageId ) // Encode the read path per the percent-encoded VFS convention (matches // files/ and the uploads glob output). The materialize_file `fileName` @@ -324,7 +323,6 @@ export async function buildCopilotRequestPayload( const allContexts = [...(contexts ?? []), ...uploadContexts] let integrationTools: ToolSchema[] = [] - const mothershipTools: ToolSchema[] = [] const payloadLogger = logger.withMetadata({ messageId: userMessageId }) if (effectiveMode === 'build') { @@ -334,20 +332,6 @@ export async function buildCopilotRequestPayload( { schemaSurface: 'copilot' }, params.workspaceId ) - - if (params.includeMothershipTools && params.workspaceId) { - // Expose all workspace user-created skills via the single load_user_skill - // tool. Available to every user; content is fetched sim-side when the - // model calls it. - try { - const userSkillTool = await buildUserSkillTool(params.workspaceId) - if (userSkillTool) mothershipTools.push(userSkillTool) - } catch (error) { - logger.warn('Failed to build load_user_skill tool', { - error: toError(error).message, - }) - } - } } return { @@ -365,7 +349,6 @@ export async function buildCopilotRequestPayload( ...(typeof prefetch === 'boolean' ? { prefetch } : {}), ...(implicitFeedback ? { implicitFeedback } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(mothershipTools.length > 0 ? { mothershipTools } : {}), ...(commands && commands.length > 0 ? { commands } : {}), ...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}), ...(params.vfs ? { vfs: params.vfs } : {}), diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index afe43cf6a08..2b9e5d52e9e 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -90,10 +90,10 @@ describe('persisted-message', () => { const persisted = buildPersistedAssistantMessage(result) expect(persisted.content).not.toContain('sk-sim-secret-123') - expect(persisted.content).toContain('"redacted":true') + expect(persisted.content).toContain('{"type":"sim_key"}') const textBlock = persisted.contentBlocks?.find((b) => b.type === 'text') expect(textBlock?.content).not.toContain('sk-sim-secret-123') - expect(textBlock?.content).toContain('"redacted":true') + expect(textBlock?.content).toContain('{"type":"sim_key"}') }) it('redacts sim_key credential tags split across streamed text chunks', () => { @@ -119,7 +119,7 @@ describe('persisted-message', () => { expect(persisted.contentBlocks).toBeDefined() const joined = (persisted.contentBlocks ?? []).map((b) => b.content ?? '').join('') expect(joined).not.toContain('sk-sim-secret-12345') - expect(joined).toContain('"redacted":true') + expect(joined).toContain('{"type":"sim_key"}') }) it('redacts the api key from a persisted generate_api_key tool result output', () => { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index ddd8ba36252..5d0c6cb4923 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -680,7 +680,6 @@ async function resolveBranch(params: { userPermission: payloadParams.userPermission, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, - includeMothershipTools: true, }, { selectedModel: '' } ), diff --git a/apps/sim/lib/copilot/chat/rewrite-file-references.ts b/apps/sim/lib/copilot/chat/rewrite-file-references.ts new file mode 100644 index 00000000000..021c6c19a2c --- /dev/null +++ b/apps/sim/lib/copilot/chat/rewrite-file-references.ts @@ -0,0 +1,99 @@ +import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import type { MothershipResource } from '@/lib/copilot/resources/types' +import { rewriteForkContentRefs } from '@/ee/workspace-forking/lib/remap/remap-content-refs' + +/** + * Old->new translation tables produced while copying a chat's files + * (`planChatFileCopies`): row ids (view-URLs, attachment ids, resource ids, + * context chips) and storage keys (serve-URLs, attachment keys). + */ +export interface ChatFileRefMaps { + fileIds: ReadonlyMap + fileKeys: ReadonlyMap +} + +function hasMappings(maps: ChatFileRefMaps): boolean { + return maps.fileIds.size > 0 || maps.fileKeys.size > 0 +} + +function rewriteText(text: string, maps: ChatFileRefMaps): string { + return rewriteForkContentRefs(text, { fileIds: maps.fileIds, fileKeys: maps.fileKeys }) +} + +/** + * Re-point every file reference in a copied transcript at the copied files, so + * the fork is self-contained (it survives the original chat's deletion). + * Rewrites: free-text URLs in `content` and text content blocks (serve/view/ + * in-app/`sim:file` forms, via the shared fork grammar), attachment chip + * ids+keys, and `@`-mention context chip file ids. References to anything not + * in the maps (shared workspace files, workflows, other chats) pass through + * unchanged. Pure; returns the input array untouched when there is nothing to + * rewrite. + * + * Pass-through cannot leave the fork pointing at uncopied CHAT-OWNED files: + * a message can only reference files that existed when it was written, every + * chat-owned file is stamped with the user message of the turn it was born in + * (NULL-stamped legacy rows are copied into every fork), and the fork copies + * every chat-owned file born at-or-before the cut — so any chat-owned file a + * kept message references is always in the maps. The only reachable leftovers + * are files soft-deleted before the fork, whose links are equally dead in the + * source chat. + */ +export function rewriteMessageFileRefs( + messages: PersistedMessage[], + maps: ChatFileRefMaps +): PersistedMessage[] { + if (!hasMappings(maps)) return messages + return messages.map((message) => { + const rewritten: PersistedMessage = { + ...message, + content: rewriteText(message.content, maps), + } + if (message.contentBlocks?.length) { + rewritten.contentBlocks = message.contentBlocks.map((block) => + block.content ? { ...block, content: rewriteText(block.content, maps) } : block + ) + } + if (message.fileAttachments?.length) { + rewritten.fileAttachments = message.fileAttachments.map((att) => ({ + ...att, + id: maps.fileIds.get(att.id) ?? att.id, + key: maps.fileKeys.get(att.key) ?? att.key, + })) + } + if (message.contexts?.length) { + rewritten.contexts = message.contexts.map((ctx) => + ctx.fileId ? { ...ctx, fileId: maps.fileIds.get(ctx.fileId) ?? ctx.fileId } : ctx + ) + } + return rewritten + }) +} + +/** + * Re-point `file`-typed resource entries (the chat's attached-resources list + * stores raw `workspace_files.id`s) at the copied files. Non-file resources + * (workflows, tables, knowledge bases…) reference shared workspace entities + * and pass through unchanged. + * + * `dropFileIds` is the source chat's chat-owned file ids (no timeline cut). + * A file resource pointing at one of these that was NOT copied is a ghost in + * the new chat — its file stays behind (an upload born after the cut) — so it + * is dropped rather than left pointing at the source chat's file. Shared + * workspace files are not chat-owned, never appear in the set, and pass + * through unchanged. + */ +export function rewriteResourceFileRefs( + resources: MothershipResource[], + maps: ChatFileRefMaps, + dropFileIds?: ReadonlySet +): MothershipResource[] { + if (!hasMappings(maps) && !dropFileIds?.size) return resources + return resources.flatMap((resource) => { + if (resource.type !== 'file') return [resource] + const copyId = maps.fileIds.get(resource.id) + if (copyId) return [{ ...resource, id: copyId }] + if (dropFileIds?.has(resource.id)) return [] + return [resource] + }) +} diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts b/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts index 70fe637e6d2..0d2e28b8537 100644 --- a/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts +++ b/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts @@ -7,12 +7,21 @@ import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types' import { captureRevealedSimKeys, extractRevealedSimKeys, + extractRevealedSimKeysFromBlocks, restoreRevealedSimKeysForMessage, + toolResultForModel, } from './sim-key-redaction' const credential = (value: string) => `${JSON.stringify({ value, type: 'sim_key' })}` const redacted = `${JSON.stringify({ type: 'sim_key', redacted: true })}` +// The value-less placeholder the model now emits (no `redacted` flag). +const placeholder = `${JSON.stringify({ type: 'sim_key' })}` + +const apiKeyBlock = (key: string) => ({ + type: 'tool_call' as const, + toolCall: { name: 'generate_api_key', result: { success: true, output: { id: 'k1', key } } }, +}) describe('sim-key-redaction', () => { describe('extractRevealedSimKeys', () => { @@ -28,6 +37,55 @@ describe('sim-key-redaction', () => { }) }) + describe('toolResultForModel', () => { + it('reduces a successful generate_api_key result to only its status message', () => { + const data = { + id: 'k1', + name: 'prod', + key: 'sk-sim-secret', + workspaceId: 'ws-1', + message: 'API key "prod" created.', + } + expect(toolResultForModel('generate_api_key', data)).toBe('API key "prod" created.') + }) + + it('leaves other tools untouched', () => { + const data = { key: 'not-a-secret', ok: true } + expect(toolResultForModel('read', data)).toBe(data) + }) + + it('passes generate_api_key errors through (no key to withhold)', () => { + const data = { error: 'name is required' } + expect(toolResultForModel('generate_api_key', data)).toBe(data) + expect(toolResultForModel('generate_api_key', undefined)).toBe(undefined) + }) + }) + + describe('extractRevealedSimKeysFromBlocks', () => { + it('pulls generate_api_key output keys in block order', () => { + expect( + extractRevealedSimKeysFromBlocks([apiKeyBlock('sk-sim-A'), apiKeyBlock('sk-sim-B')]) + ).toEqual(['sk-sim-A', 'sk-sim-B']) + }) + + it('skips redacted markers and unrelated tools', () => { + const blocks = [ + apiKeyBlock('[REDACTED]'), + { + type: 'tool_call' as const, + toolCall: { name: 'read', result: { success: true, output: { key: 'sk-x' } } }, + }, + apiKeyBlock('sk-sim-A'), + ] + expect(extractRevealedSimKeysFromBlocks(blocks)).toEqual(['sk-sim-A']) + }) + + it('returns nothing for empty/undefined block lists', () => { + expect(extractRevealedSimKeysFromBlocks(undefined)).toEqual([]) + expect(extractRevealedSimKeysFromBlocks([])).toEqual([]) + }) + }) + describe('captureRevealedSimKeys', () => { it('records new keys under each provided key', () => { const cache = new Map() @@ -59,6 +117,21 @@ describe('sim-key-redaction', () => { captureRevealedSimKeys(cache, ['msg-1'], 'plain assistant text') expect(cache.has('msg-1')).toBe(false) }) + + it('sources the key from the generate_api_key tool result (model text is a redacted placeholder)', () => { + const cache = new Map() + captureRevealedSimKeys(cache, ['msg-1', 'req-1'], `Here is your key: ${redacted}`, [ + apiKeyBlock('sk-sim-fromtool'), + ]) + expect(cache.get('msg-1')).toEqual(['sk-sim-fromtool']) + expect(cache.get('req-1')).toEqual(['sk-sim-fromtool']) + }) + + it('prefers tool-result keys over any inline content values', () => { + const cache = new Map() + captureRevealedSimKeys(cache, ['msg-1'], credential('sk-content'), [apiKeyBlock('sk-tool')]) + expect(cache.get('msg-1')).toEqual(['sk-tool']) + }) }) describe('restoreRevealedSimKeysForMessage', () => { @@ -76,6 +149,32 @@ describe('sim-key-redaction', () => { expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"') }) + it('fills a value-less {"type":"sim_key"} placeholder (no redacted flag needed)', () => { + const cache = new Map([['msg-1', ['sk-sim-A']]]) + const msg: ChatMessage = { + id: 'msg-1', + role: 'assistant', + content: `Here is your key: ${placeholder} save it.`, + contentBlocks: [{ type: 'text', content: `Here is your key: ${placeholder} save it.` }], + } + const restored = restoreRevealedSimKeysForMessage(msg, cache) + expect(restored.content).toContain('"sk-sim-A"') + expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"') + }) + + it('fills value-less and redacted placeholders positionally in one message', () => { + const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) + const msg: ChatMessage = { + id: 'msg-1', + role: 'assistant', + content: `first ${placeholder} second ${redacted}`, + } + const restored = restoreRevealedSimKeysForMessage(msg, cache) + expect(restored.content).toBe( + `first ${credential('sk-sim-A')} second ${credential('sk-sim-B')}` + ) + }) + it('substitutes multiple keys in stream order', () => { const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) const msg: ChatMessage = { diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.ts b/apps/sim/lib/copilot/chat/sim-key-redaction.ts index d5aba0f302d..cb59a9c56df 100644 --- a/apps/sim/lib/copilot/chat/sim-key-redaction.ts +++ b/apps/sim/lib/copilot/chat/sim-key-redaction.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import type { PersistedContentBlock } from '@/lib/copilot/chat/persisted-message' import { MothershipStreamV1EventType, @@ -23,17 +24,15 @@ import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/ho */ const CREDENTIAL_TAG_PATTERN = /([\s\S]*?)<\/credential>/g -const REDACTED_TAG_PATTERN = /[^<]*"redacted"\s*:\s*true[^<]*<\/credential>/ const SIM_KEY_TYPE = 'sim_key' -const REDACTED_SIM_KEY_TAG = `${JSON.stringify({ - type: SIM_KEY_TYPE, - redacted: true, -})}` +// The persisted / secret-stripped form of a sim_key tag: value-less, which is +// exactly how the UI renders the masked state. No `redacted` flag needed — a +// sim_key chip is masked iff it has no value. +const VALUELESS_SIM_KEY_TAG = `${JSON.stringify({ type: SIM_KEY_TYPE })}` interface CredentialTagBody { type?: unknown value?: unknown - redacted?: unknown } function parseCredentialBody(body: string): CredentialTagBody | null { @@ -44,22 +43,34 @@ function parseCredentialBody(body: string): CredentialTagBody | null { } } -function hasRedactedSimKeyTag(content: string | undefined): boolean { - return typeof content === 'string' && REDACTED_TAG_PATTERN.test(content) +/** + * True when `content` holds a `sim_key` credential tag that still needs its + * value filled in — i.e. any value-less `sim_key` tag: the model's + * `{"type":"sim_key"}` placeholder, the persisted form, or a legacy + * `{"type":"sim_key","redacted":true}` tag. All are recognized by the absence + * of a `value`. + */ +function hasFillableSimKeyTag(content: string | undefined): boolean { + if (typeof content !== 'string' || !content.includes('')) return false + for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) { + const parsed = parseCredentialBody(match[1]) + if (parsed?.type === SIM_KEY_TYPE && parsed.value === undefined) return true + } + return false } // Write side --------------------------------------------------------------- /** - * Replace every revealed `` tag in `content` with a - * placeholder marked `redacted: true`. Other credential types (e.g. OAuth - * `link`) and malformed bodies pass through unchanged. + * Replace every `` tag in `content` with the + * value-less placeholder, so a revealed key is never persisted. Other credential + * types (e.g. OAuth `link`) and malformed bodies pass through unchanged. */ export function redactSensitiveContent(content: T): T { if (typeof content !== 'string' || !content.includes('')) return content return content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => { const parsed = parseCredentialBody(body) - return parsed?.type === SIM_KEY_TYPE ? REDACTED_SIM_KEY_TAG : match + return parsed?.type === SIM_KEY_TYPE ? VALUELESS_SIM_KEY_TAG : match }) as T } @@ -83,6 +94,23 @@ export function redactToolCallResult( } } +/** + * The model-facing result of `generate_api_key`. The generated key is a + * client-only artifact — it rides the SSE tool result to the browser and renders + * as the `sim_key` chip — so the model (and the persisted conversation) must + * never receive it. Rather than subtract the secret from the full payload, the + * model's result IS the status: on success it gets only the tool's message (no + * key, no id/name/workspaceId); a failure passes through so the model still sees + * the error. Every other tool's terminal data is returned unchanged. + */ +export function toolResultForModel(toolName: string | undefined, data: unknown): unknown { + if (toolName !== GenerateApiKey.id) return data + if (!isRecordLike(data)) return data + const record = data + if (typeof record.key !== 'string') return data + return record.message +} + function isMergeableAssistantTextBlock(block: PersistedContentBlock): boolean { return ( block.type === MothershipStreamV1EventType.text && @@ -156,34 +184,76 @@ export type RevealedSimKeysByMessage = Map /** * Scan an assembled assistant message for `` tags - * and return their values in stream order, skipping anything already redacted. + * and return their values in stream order; value-less (masked/placeholder) tags + * carry no string value and are skipped. */ export function extractRevealedSimKeys(content: string): string[] { if (!content || !content.includes('')) return [] const values: string[] = [] for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) { const parsed = parseCredentialBody(match[1]) - if (parsed?.type === SIM_KEY_TYPE && !parsed.redacted && typeof parsed.value === 'string') { + if (parsed?.type === SIM_KEY_TYPE && typeof parsed.value === 'string') { values.push(parsed.value) } } return values } +/** Minimal shape of a rendered/streamed block carrying a tool result. */ +interface ToolResultBlockLike { + toolCall?: { name?: string; result?: unknown } | null +} + +/** + * Pull the freshly-generated key(s) out of `generate_api_key` tool results in + * block order. This is the authoritative source for the `sim_key` chip now that + * the model never sees (or emits) the value — it only emits a redacted + * placeholder, and the real value rides in the tool result on the live stream. + * `[REDACTED]` outputs (post-persist/refetch) are skipped so a reloaded + * transcript doesn't cache the masked marker over a live value. + */ +export function extractRevealedSimKeysFromBlocks( + blocks: ReadonlyArray | undefined +): string[] { + if (!blocks?.length) return [] + const values: string[] = [] + for (const block of blocks) { + const toolCall = block.toolCall + if (!toolCall || toolCall.name !== GenerateApiKey.id) continue + const result = toolCall.result + if (!isRecordLike(result)) continue + const output = result.output + if (!isRecordLike(output)) continue + const key = output.key + if (typeof key === 'string' && key.length > 0 && key !== REDACTED_MARKER) { + values.push(key) + } + } + return values +} + /** * Extend the cache entries for the given keys with any newly-revealed values. * Each key in `keys` is written the same array — passing both the live-stream * id and the persisted `requestId` lets the post-finalize refetch hit the * cache after the message is renamed to its real UUID. The longest captured * list wins so a rerun that surfaces fewer values can't shrink the entry. + * + * Values are sourced from the `generate_api_key` tool results (`blocks`) first — + * that is where the key now lives, since the model only emits a redacted + * placeholder tag — falling back to any inline `sim_key` tag values in + * `content` for backward compatibility with pre-change transcripts. */ export function captureRevealedSimKeys( cache: RevealedSimKeysByMessage, keys: ReadonlyArray, - content: string + content: string, + blocks?: ReadonlyArray ): void { - if (!content.includes('')) return - const next = extractRevealedSimKeys(content) + const fromBlocks = extractRevealedSimKeysFromBlocks(blocks) + // extractRevealedSimKeys already returns [] when `content` has no tag, so no + // separate includes() guard is needed. + const next = fromBlocks.length > 0 ? fromBlocks : extractRevealedSimKeys(content) if (next.length === 0) return for (const key of keys) { if (!key) continue @@ -208,7 +278,10 @@ function restoreInString( let changed = false const next = content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => { const parsed = parseCredentialBody(body) - if (parsed?.type === SIM_KEY_TYPE && parsed.redacted === true) { + // Any value-less sim_key tag is a fill slot — the model's placeholder, the + // persisted form, or a legacy `{"redacted":true}` tag. Already-filled tags + // carry a `value` and are left untouched (idempotent). + if (parsed?.type === SIM_KEY_TYPE && parsed.value === undefined) { const value = revealedValues[cursor] cursor += 1 if (typeof value === 'string') { @@ -235,8 +308,8 @@ export function restoreRevealedSimKeysForMessage( cache.get(message.id) ?? (message.requestId ? cache.get(message.requestId) : undefined) if (!revealed || revealed.length === 0) return message if ( - !hasRedactedSimKeyTag(message.content) && - !message.contentBlocks?.some((b) => hasRedactedSimKeyTag(b.content)) + !hasFillableSimKeyTag(message.content) && + !message.contentBlocks?.some((b) => hasFillableSimKeyTag(b.content)) ) { return message } @@ -245,7 +318,7 @@ export function restoreRevealedSimKeysForMessage( let blocksChanged = false let blockCursor = 0 const nextBlocks: ContentBlock[] | undefined = message.contentBlocks?.map((block) => { - if (!hasRedactedSimKeyTag(block.content)) return block + if (!hasFillableSimKeyTag(block.content)) return block const restored = restoreInString(block.content as string, revealed, blockCursor) blockCursor = restored.cursor if (!restored.changed) return block diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 7558b06f56c..0eed19fdee8 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -291,8 +291,8 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { .sort(byNameThenId) .map((s) => `- **${s.name}** (${s.id}) — ${s.description}`) sections.push( - `## Skills (${data.skills.length})\n` + - 'To use a skill, call the load_user_skill tool with its name to load the full instructions, then follow them. The descriptions below only say when each skill applies — they are not the instructions.\n' + + `## Agent Block Skills — NOT FOR YOU (${data.skills.length})\n` + + 'These are user-created skills used by agent blocks in the workspace and are NOT instructions for you\n' + lines.join('\n') ) } diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/copilot/generated/metrics-v1.ts index fefc4534ea3..06c74839c47 100644 --- a/apps/sim/lib/copilot/generated/metrics-v1.ts +++ b/apps/sim/lib/copilot/generated/metrics-v1.ts @@ -17,6 +17,8 @@ export const Metric = { CopilotCacheAttempted: 'copilot.cache.attempted', CopilotCacheHit: 'copilot.cache.hit', CopilotCacheWrite: 'copilot.cache.write', + CopilotChatBlobBytes: 'copilot.chat.blob.bytes', + CopilotChatBlobCount: 'copilot.chat.blob.count', CopilotFileReadDuration: 'copilot.file.read.duration', CopilotFileReadSize: 'copilot.file.read.size', CopilotMessagesSerializeDuration: 'copilot.messages.serialize.duration', @@ -28,6 +30,8 @@ export const Metric = { CopilotVfsMaterializeDuration: 'copilot.vfs.materialize.duration', GenAiClientCacheTokenUsage: 'gen_ai.client.cache.token.usage', GenAiClientTokenUsage: 'gen_ai.client.token.usage', + LlmClientCompactions: 'llm.client.compactions', + LlmClientContextTokens: 'llm.client.context_tokens', LlmClientErrors: 'llm.client.errors', LlmClientOutputCutoff: 'llm.client.output_cutoff', LlmClientStreamDuration: 'llm.client.stream.duration', @@ -42,6 +46,8 @@ export const MetricValues: readonly MetricValue[] = [ 'copilot.cache.attempted', 'copilot.cache.hit', 'copilot.cache.write', + 'copilot.chat.blob.bytes', + 'copilot.chat.blob.count', 'copilot.file.read.duration', 'copilot.file.read.size', 'copilot.messages.serialize.duration', @@ -53,6 +59,8 @@ export const MetricValues: readonly MetricValue[] = [ 'copilot.vfs.materialize.duration', 'gen_ai.client.cache.token.usage', 'gen_ai.client.token.usage', + 'llm.client.compactions', + 'llm.client.context_tokens', 'llm.client.errors', 'llm.client.output_cutoff', 'llm.client.stream.duration', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4a4c24a1594..a7c2eee08ec 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -11,9 +11,9 @@ export interface ToolCatalogEntry { | 'auth' | 'check_deployment_status' | 'complete_scheduled_task' + | 'cp' | 'crawl_website' | 'create_file' - | 'create_file_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' @@ -49,7 +49,6 @@ export interface ToolCatalogEntry { | 'grep' | 'knowledge' | 'knowledge_base' - | 'list_file_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -63,30 +62,29 @@ export interface ToolCatalogEntry { | 'manage_skill' | 'materialize_file' | 'media' - | 'move_file' - | 'move_file_folder' - | 'move_workflow' + | 'mkdir' + | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' - | 'rename_file' - | 'rename_file_folder' - | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' @@ -98,7 +96,6 @@ export interface ToolCatalogEntry { | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' @@ -109,9 +106,9 @@ export interface ToolCatalogEntry { | 'auth' | 'check_deployment_status' | 'complete_scheduled_task' + | 'cp' | 'crawl_website' | 'create_file' - | 'create_file_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' @@ -147,7 +144,6 @@ export interface ToolCatalogEntry { | 'grep' | 'knowledge' | 'knowledge_base' - | 'list_file_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -161,30 +157,29 @@ export interface ToolCatalogEntry { | 'manage_skill' | 'materialize_file' | 'media' - | 'move_file' - | 'move_file_folder' - | 'move_workflow' + | 'mkdir' + | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' - | 'rename_file' - | 'rename_file_folder' - | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' @@ -196,12 +191,11 @@ export interface ToolCatalogEntry { | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' parameters: unknown - requiredPermission?: 'admin' | 'read' | 'write' + requiredPermission?: 'admin' | 'write' resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: @@ -211,9 +205,9 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' - | 'research' | 'run' | 'scheduled_task' + | 'search' | 'superagent' | 'table' | 'workflow' @@ -282,6 +276,36 @@ export const CompleteScheduledTask: ToolCatalogEntry = { }, } +export const Cp: ToolCatalogEntry = { + id: 'cp', + name: 'cp', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + destination: { + type: 'string', + description: + 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', + }, + sources: { + type: 'array', + description: + 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', + items: { type: 'string' }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "My Workflow" or "Template to Archive", not a full sentence like "Copying My Workflow".', + }, + }, + required: ['sources', 'destination', 'toolTitle'], + }, + requiredPermission: 'write', +} + export const CrawlWebsite: ToolCatalogEntry = { id: 'crawl_website', name: 'crawl_website', @@ -333,7 +357,7 @@ export const CreateFile: ToolCatalogEntry = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -375,29 +399,6 @@ export const CreateFile: ToolCatalogEntry = { capabilities: ['file_output'], } -export const CreateFileFolder: ToolCatalogEntry = { - id: 'create_file_folder', - name: 'create_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - required: ['path'], - }, - requiredPermission: 'write', -} - export const CreateWorkflow: ToolCatalogEntry = { id: 'create_workflow', name: 'create_workflow', @@ -911,7 +912,7 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1186,7 +1187,8 @@ export const Ffmpeg: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1350,7 +1352,8 @@ export const FunctionExecute: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1515,7 +1518,8 @@ export const GenerateAudio: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1649,7 +1653,8 @@ export const GenerateImage: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1807,7 +1812,8 @@ export const GenerateVideo: ToolCatalogEntry = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -2296,23 +2302,6 @@ export const KnowledgeBase: ToolCatalogEntry = { }, } -export const ListFileFolders: ToolCatalogEntry = { - id: 'list_file_folders', - name: 'list_file_folders', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - }, - requiredPermission: 'read', -} - export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', @@ -2501,35 +2490,16 @@ export const ManageFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', - }, folderId: { type: 'string', description: 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', }, - name: { - type: 'string', - description: - 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', - }, - operation: { - type: 'string', - description: 'The operation to perform.', - enum: ['create', 'rename', 'move', 'delete'], - }, - parentId: { - type: 'string', - description: - 'Destination parent folder ID, used as a fallback when destinationPath is not given.', - }, + operation: { type: 'string', description: 'The operation to perform.', enum: ['delete'] }, path: { type: 'string', description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', }, }, required: ['operation'], @@ -2746,72 +2716,57 @@ export const Media: ToolCatalogEntry = { internal: true, } -export const MoveFile: ToolCatalogEntry = { - id: 'move_file', - name: 'move_file', +export const Mkdir: ToolCatalogEntry = { + id: 'mkdir', + name: 'mkdir', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', - }, paths: { type: 'array', - description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', + description: + 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string' }, }, - }, - required: ['paths'], - }, - requiredPermission: 'write', -} - -export const MoveFileFolder: ToolCatalogEntry = { - id: 'move_file_folder', - name: 'move_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - destinationPath: { + toolTitle: { type: 'string', description: - 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', + 'Target-only UI phrase for the action row, e.g. "Reports/2026" or "2 folders", not a full sentence like "Creating Reports".', }, }, - required: ['path'], + required: ['paths', 'toolTitle'], }, requiredPermission: 'write', } -export const MoveWorkflow: ToolCatalogEntry = { - id: 'move_workflow', - name: 'move_workflow', +export const Mv: ToolCatalogEntry = { + id: 'mv', + name: 'mv', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - folderId: { + destination: { type: 'string', - description: 'Target folder ID. Omit or pass empty string to move to workspace root.', + description: + 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, - workflowIds: { + sources: { type: 'array', - description: 'The workflow IDs to move.', + description: + 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string' }, }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md to Reports" or "3 files to Images", not a full sentence like "Moving draft.md".', + }, }, - required: ['workflowIds'], + required: ['sources', 'destination', 'toolTitle'], }, requiredPermission: 'write', } @@ -2824,6 +2779,11 @@ export const OauthGetAuthLink: ToolCatalogEntry = { parameters: { type: 'object', properties: { + credentialId: { + type: 'string', + description: + 'Optional. The id of an EXISTING credential (from environment/credentials.json) to reconnect/re-authorize in place. Only when the user explicitly asks to reconnect or repair that credential — never for adding another account.', + }, providerName: { type: 'string', description: @@ -3015,6 +2975,55 @@ export const QueryLogs: ToolCatalogEntry = { }, } +export const QueryUserTable: ToolCatalogEntry = { + id: 'query_user_table', + name: 'query_user_table', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { type: 'string', description: 'Table ID (required for all operations)' }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Read: ToolCatalogEntry = { id: 'read', name: 'read', @@ -3115,87 +3124,6 @@ export const Redeploy: ToolCatalogEntry = { requiredPermission: 'admin', } -export const RenameFile: ToolCatalogEntry = { - id: 'rename_file', - name: 'rename_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - newName: { - type: 'string', - description: - 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', - }, - path: { - type: 'string', - description: 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', - }, - }, - required: ['path', 'newName'], - }, - resultSchema: { - type: 'object', - properties: { - data: { type: 'object', description: 'Contains id and the new name.' }, - message: { type: 'string', description: 'Human-readable outcome.' }, - success: { type: 'boolean', description: 'Whether the rename succeeded.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - -export const RenameFileFolder: ToolCatalogEntry = { - id: 'rename_file_folder', - name: 'rename_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'New folder name.' }, - path: { - type: 'string', - description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', - }, - }, - required: ['path', 'name'], - }, - requiredPermission: 'write', -} - -export const RenameWorkflow: ToolCatalogEntry = { - id: 'rename_workflow', - name: 'rename_workflow', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'The new name for the workflow.' }, - workflowId: { type: 'string', description: 'The workflow ID to rename.' }, - }, - required: ['workflowId', 'name'], - }, - requiredPermission: 'write', -} - -export const Research: ToolCatalogEntry = { - id: 'research', - name: 'research', - route: 'subagent', - mode: 'async', - parameters: { - properties: { topic: { description: 'The topic to research.', type: 'string' } }, - required: ['topic'], - type: 'object', - }, - subagentId: 'research', - internal: true, -} - export const Respond: ToolCatalogEntry = { id: 'respond', name: 'respond', @@ -3209,6 +3137,12 @@ export const Respond: ToolCatalogEntry = { 'The result — facts, status, VFS paths to persisted data, whatever the caller needs to act on.', type: 'string', }, + paths: { + description: + 'Affected VFS file paths. Required when the File Agent reports a successful file mutation.', + items: { type: 'string' }, + type: 'array', + }, success: { description: 'Whether the task completed successfully', type: 'boolean' }, type: { description: 'Optional logical result type override', type: 'string' }, }, @@ -3293,6 +3227,99 @@ export const RunBlock: ToolCatalogEntry = { clientExecutable: true, } +export const RunCode: ToolCatalogEntry = { + id: 'run_code', + name: 'run_code', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + requiredPermission: 'write', + capabilities: ['file_input', 'directory_input', 'table_input'], +} + export const RunFromBlock: ToolCatalogEntry = { id: 'run_from_block', name: 'run_from_block', @@ -3456,6 +3483,26 @@ export const ScrapePage: ToolCatalogEntry = { }, } +export const Search: ToolCatalogEntry = { + id: 'search', + name: 'search', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'search', + internal: true, +} + export const SearchDocumentation: ToolCatalogEntry = { id: 'search_documentation', name: 'search_documentation', @@ -3471,6 +3518,49 @@ export const SearchDocumentation: ToolCatalogEntry = { }, } +export const SearchKnowledgeBase: ToolCatalogEntry = { + id: 'search_knowledge_base', + name: 'search_knowledge_base', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { type: 'string', description: "Search query text (required for 'query')" }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const SearchLibraryDocs: ToolCatalogEntry = { id: 'search_library_docs', name: 'search_library_docs', @@ -3755,50 +3845,6 @@ export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const UserMemory: ToolCatalogEntry = { - id: 'user_memory', - name: 'user_memory', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { type: 'number', description: 'Number of results for search (default 10)' }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { type: 'string', description: 'Search query to find relevant memories' }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { type: 'string', description: 'Value to remember' }, - }, - required: ['operation'], - }, -} - export const UserTable: ToolCatalogEntry = { id: 'user_table', name: 'user_table', @@ -4141,10 +4187,23 @@ export const Workflow: ToolCatalogEntry = { properties: { prompt: { description: - "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", + 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', + type: 'string', + }, + sessionId: { + description: + 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, + required: ['title'], type: 'object', }, subagentId: 'workflow', @@ -4380,21 +4439,13 @@ export const ManageCustomToolOperationValues = [ ] as const export const ManageFolderOperation = { - create: 'create', - rename: 'rename', - move: 'move', delete: 'delete', } as const export type ManageFolderOperation = (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] -export const ManageFolderOperationValues = [ - ManageFolderOperation.create, - ManageFolderOperation.rename, - ManageFolderOperation.move, - ManageFolderOperation.delete, -] as const +export const ManageFolderOperationValues = [ManageFolderOperation.delete] as const export const ManageMcpToolOperation = { add: 'add', @@ -4461,22 +4512,36 @@ export const MaterializeFileOperationValues = [ MaterializeFileOperation.import, ] as const -export const UserMemoryOperation = { - add: 'add', - search: 'search', - delete: 'delete', - correct: 'correct', - list: 'list', +export const QueryUserTableOperation = { + get: 'get', + getSchema: 'get_schema', + getRow: 'get_row', + queryRows: 'query_rows', +} as const + +export type QueryUserTableOperation = + (typeof QueryUserTableOperation)[keyof typeof QueryUserTableOperation] + +export const QueryUserTableOperationValues = [ + QueryUserTableOperation.get, + QueryUserTableOperation.getSchema, + QueryUserTableOperation.getRow, + QueryUserTableOperation.queryRows, +] as const + +export const SearchKnowledgeBaseOperation = { + get: 'get', + query: 'query', + listTags: 'list_tags', } as const -export type UserMemoryOperation = (typeof UserMemoryOperation)[keyof typeof UserMemoryOperation] +export type SearchKnowledgeBaseOperation = + (typeof SearchKnowledgeBaseOperation)[keyof typeof SearchKnowledgeBaseOperation] -export const UserMemoryOperationValues = [ - UserMemoryOperation.add, - UserMemoryOperation.search, - UserMemoryOperation.delete, - UserMemoryOperation.correct, - UserMemoryOperation.list, +export const SearchKnowledgeBaseOperationValues = [ + SearchKnowledgeBaseOperation.get, + SearchKnowledgeBaseOperation.query, + SearchKnowledgeBaseOperation.listTags, ] as const export const UserTableOperation = { @@ -4569,9 +4634,9 @@ export const TOOL_CATALOG: Record = { [Auth.id]: Auth, [CheckDeploymentStatus.id]: CheckDeploymentStatus, [CompleteScheduledTask.id]: CompleteScheduledTask, + [Cp.id]: Cp, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, - [CreateFileFolder.id]: CreateFileFolder, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteFile.id]: DeleteFile, @@ -4607,7 +4672,6 @@ export const TOOL_CATALOG: Record = { [Grep.id]: Grep, [Knowledge.id]: Knowledge, [KnowledgeBase.id]: KnowledgeBase, - [ListFileFolders.id]: ListFileFolders, [ListIntegrationTools.id]: ListIntegrationTools, [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, @@ -4621,30 +4685,29 @@ export const TOOL_CATALOG: Record = { [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, - [MoveFile.id]: MoveFile, - [MoveFileFolder.id]: MoveFileFolder, - [MoveWorkflow.id]: MoveWorkflow, + [Mkdir.id]: Mkdir, + [Mv.id]: Mv, [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, + [QueryUserTable.id]: QueryUserTable, [Read.id]: Read, [Redeploy.id]: Redeploy, - [RenameFile.id]: RenameFile, - [RenameFileFolder.id]: RenameFileFolder, - [RenameWorkflow.id]: RenameWorkflow, - [Research.id]: Research, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, [Run.id]: Run, [RunBlock.id]: RunBlock, + [RunCode.id]: RunCode, [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, + [Search.id]: Search, [SearchDocumentation.id]: SearchDocumentation, + [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, @@ -4656,7 +4719,6 @@ export const TOOL_CATALOG: Record = { [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, - [UserMemory.id]: UserMemory, [UserTable.id]: UserTable, [Workflow.id]: Workflow, [WorkspaceFile.id]: WorkspaceFile, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index dcaea0db6ea..579a40cec7d 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -61,6 +61,33 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + cp: { + parameters: { + type: 'object', + properties: { + destination: { + type: 'string', + description: + 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', + }, + sources: { + type: 'array', + description: + 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', + items: { + type: 'string', + }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "My Workflow" or "Template to Archive", not a full sentence like "Copying My Workflow".', + }, + }, + required: ['sources', 'destination', 'toolTitle'], + }, + resultSchema: undefined, + }, crawl_website: { parameters: { type: 'object', @@ -117,7 +144,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -162,24 +189,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - required: ['path'], - }, - resultSchema: undefined, - }, create_workflow: { parameters: { type: 'object', @@ -726,7 +735,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { files: { type: 'array', description: - 'Files to create or overwrite. Parent folders must already exist for create mode.', + 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -996,7 +1005,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1163,7 +1173,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1324,7 +1335,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1461,7 +1473,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -1619,7 +1632,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: 'File outputs. Parent folders must already exist for create mode.', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', items: { type: 'object', properties: { @@ -2105,18 +2119,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { - parameters: { - type: 'object', - properties: { - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Defaults to the current workspace.', - }, - }, - }, - resultSchema: undefined, - }, list_integration_tools: { parameters: { properties: { @@ -2300,35 +2302,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', - }, folderId: { type: 'string', description: 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', }, - name: { - type: 'string', - description: - 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', - }, operation: { type: 'string', description: 'The operation to perform.', - enum: ['create', 'rename', 'move', 'delete'], - }, - parentId: { - type: 'string', - description: - 'Destination parent folder ID, used as a fallback when destinationPath is not given.', + enum: ['delete'], }, path: { type: 'string', description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', }, }, required: ['operation'], @@ -2532,62 +2519,52 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + mkdir: { parameters: { type: 'object', properties: { - destinationPath: { - type: 'string', - description: - 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', - }, paths: { type: 'array', - description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', + description: + 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string', }, }, - }, - required: ['paths'], - }, - resultSchema: undefined, - }, - move_file_folder: { - parameters: { - type: 'object', - properties: { - destinationPath: { + toolTitle: { type: 'string', description: - 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', + 'Target-only UI phrase for the action row, e.g. "Reports/2026" or "2 folders", not a full sentence like "Creating Reports".', }, }, - required: ['path'], + required: ['paths', 'toolTitle'], }, resultSchema: undefined, }, - move_workflow: { + mv: { parameters: { type: 'object', properties: { - folderId: { + destination: { type: 'string', - description: 'Target folder ID. Omit or pass empty string to move to workspace root.', + description: + 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, - workflowIds: { + sources: { type: 'array', - description: 'The workflow IDs to move.', + description: + 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', }, }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md to Reports" or "3 files to Images", not a full sentence like "Moving draft.md".', + }, }, - required: ['workflowIds'], + required: ['sources', 'destination', 'toolTitle'], }, resultSchema: undefined, }, @@ -2595,6 +2572,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + credentialId: { + type: 'string', + description: + 'Optional. The id of an EXISTING credential (from environment/credentials.json) to reconnect/re-authorize in place. Only when the user explicitly asks to reconnect or repair that credential — never for adding another account.', + }, providerName: { type: 'string', description: @@ -2783,6 +2765,68 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + query_user_table: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: 'MongoDB-style filter for query_rows', + }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { + type: 'string', + description: 'Row ID (required for get_row)', + }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { + type: 'string', + description: 'Table ID (required for all operations)', + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, read: { parameters: { type: 'object', @@ -2889,89 +2933,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { - parameters: { - type: 'object', - properties: { - newName: { - type: 'string', - description: - 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', - }, - path: { - type: 'string', - description: - 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', - }, - }, - required: ['path', 'newName'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: 'Contains id and the new name.', - }, - message: { - type: 'string', - description: 'Human-readable outcome.', - }, - success: { - type: 'boolean', - description: 'Whether the rename succeeded.', - }, - }, - required: ['success', 'message'], - }, - }, - rename_file_folder: { - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'New folder name.', - }, - path: { - type: 'string', - description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', - }, - }, - required: ['path', 'name'], - }, - resultSchema: undefined, - }, - rename_workflow: { - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'The new name for the workflow.', - }, - workflowId: { - type: 'string', - description: 'The workflow ID to rename.', - }, - }, - required: ['workflowId', 'name'], - }, - resultSchema: undefined, - }, - research: { - parameters: { - properties: { - topic: { - description: 'The topic to research.', - type: 'string', - }, - }, - required: ['topic'], - type: 'object', - }, - resultSchema: undefined, - }, respond: { parameters: { additionalProperties: true, @@ -2981,6 +2942,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'The result — facts, status, VFS paths to persisted data, whatever the caller needs to act on.', type: 'string', }, + paths: { + description: + 'Affected VFS file paths. Required when the File Agent reports a successful file mutation.', + items: { + type: 'string', + }, + type: 'array', + }, success: { description: 'Whether the task completed successfully', type: 'boolean', @@ -3062,6 +3031,99 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + run_code: { + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + resultSchema: undefined, + }, run_from_block: { parameters: { type: 'object', @@ -3209,6 +3271,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + search: { + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, search_documentation: { parameters: { type: 'object', @@ -3226,6 +3302,56 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + search_knowledge_base: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { + type: 'string', + description: "Search query text (required for 'query')", + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, search_library_docs: { parameters: { type: 'object', @@ -3505,55 +3631,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { - type: 'number', - description: 'Number of results for search (default 10)', - }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { - type: 'string', - description: 'Search query to find relevant memories', - }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { - type: 'string', - description: 'Value to remember', - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, user_table: { parameters: { type: 'object', @@ -3922,10 +3999,23 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { prompt: { description: - "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", + 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', + type: 'string', + }, + sessionId: { + description: + 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, + required: ['title'], type: 'object', }, resultSchema: undefined, diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index c5024ee3571..a892ff3561d 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -89,6 +89,12 @@ export const TraceAttr = { ChatArtifactKeys: 'chat.artifact_keys', ChatArtifactsBytes: 'chat.artifacts_bytes', ChatAuthType: 'chat.auth_type', + ChatBlobOp: 'chat.blob.op', + ChatBlobSite: 'chat.blob.site', + ChatBlobBytesOffloaded: 'chat.blob_bytes_offloaded', + ChatBlobBytesResolved: 'chat.blob_bytes_resolved', + ChatBlobsOffloaded: 'chat.blobs_offloaded', + ChatBlobsResolved: 'chat.blobs_resolved', ChatContextCount: 'chat.context_count', ChatContextUsage: 'chat.context_usage', ChatContinuationMessagesBefore: 'chat.continuation.messages_before', @@ -420,6 +426,7 @@ export const TraceAttr = { GenAiUsageCacheCreationTokens: 'gen_ai.usage.cache_creation_tokens', GenAiUsageCacheReadInputTokens: 'gen_ai.usage.cache_read.input_tokens', GenAiUsageCacheReadTokens: 'gen_ai.usage.cache_read_tokens', + GenAiUsageContextTokens: 'gen_ai.usage.context_tokens', GenAiUsageInputTokens: 'gen_ai.usage.input_tokens', GenAiUsageOutputTokens: 'gen_ai.usage.output_tokens', GenAiUsageThinkingTokens: 'gen_ai.usage.thinking_tokens', @@ -446,9 +453,11 @@ export const TraceAttr = { KnowledgeBaseId: 'knowledge_base.id', KnowledgeBaseName: 'knowledge_base.name', LlmBackend: 'llm.backend', + LlmCompactionPause: 'llm.compaction.pause', LlmErrorStage: 'llm.error_stage', LlmProtocol: 'llm.protocol', LlmRequestBodyBytes: 'llm.request.body_bytes', + LlmRequestCompactionTrigger: 'llm.request.compaction_trigger', LlmStreamBytes: 'llm.stream.bytes', LlmStreamChunks: 'llm.stream.chunks', LlmStreamFirstChunkBytes: 'llm.stream.first_chunk_bytes', @@ -716,6 +725,12 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'chat.artifact_keys', 'chat.artifacts_bytes', 'chat.auth_type', + 'chat.blob.op', + 'chat.blob.site', + 'chat.blob_bytes_offloaded', + 'chat.blob_bytes_resolved', + 'chat.blobs_offloaded', + 'chat.blobs_resolved', 'chat.context_count', 'chat.context_usage', 'chat.continuation.messages_before', @@ -1036,6 +1051,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.usage.cache_creation_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.cache_read_tokens', + 'gen_ai.usage.context_tokens', 'gen_ai.usage.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.thinking_tokens', @@ -1062,9 +1078,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'knowledge_base.id', 'knowledge_base.name', 'llm.backend', + 'llm.compaction.pause', 'llm.error_stage', 'llm.protocol', 'llm.request.body_bytes', + 'llm.request.compaction_trigger', 'llm.stream.bytes', 'llm.stream.chunks', 'llm.stream.first_chunk_bytes', diff --git a/apps/sim/lib/copilot/request/tool-call-state.test.ts b/apps/sim/lib/copilot/request/tool-call-state.test.ts new file mode 100644 index 00000000000..7f5c3dd008a --- /dev/null +++ b/apps/sim/lib/copilot/request/tool-call-state.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import type { ToolCallState } from '@/lib/copilot/request/types' +import { getToolCallTerminalData } from './tool-call-state' + +describe('getToolCallTerminalData', () => { + it('reduces a successful generate_api_key result to only its status message', () => { + const tool: ToolCallState = { + id: 't1', + name: 'generate_api_key', + status: MothershipStreamV1ToolOutcome.success, + result: { + success: true, + output: { + id: 'k1', + name: 'prod', + key: 'sk-sim-secret-value', + message: 'API key "prod" created.', + }, + }, + } + + const data = getToolCallTerminalData(tool) + + // The model gets only the status message — no key, no id/name/workspaceId. + expect(data).toBe('API key "prod" created.') + expect(JSON.stringify(data)).not.toContain('sk-sim-secret-value') + expect(JSON.stringify(data)).not.toContain('k1') + }) + + it('passes through other tools output unchanged', () => { + const tool: ToolCallState = { + id: 't2', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + } + + expect(getToolCallTerminalData(tool)).toEqual({ content: 'file contents' }) + }) + + it('surfaces the error for a failed generate_api_key without inventing a key', () => { + const tool: ToolCallState = { + id: 't3', + name: 'generate_api_key', + status: MothershipStreamV1ToolOutcome.error, + error: 'name is required', + } + + expect(getToolCallTerminalData(tool)).toEqual({ error: 'name is required' }) + }) +}) diff --git a/apps/sim/lib/copilot/request/tool-call-state.ts b/apps/sim/lib/copilot/request/tool-call-state.ts index 70433429043..e5f1b833bff 100644 --- a/apps/sim/lib/copilot/request/tool-call-state.ts +++ b/apps/sim/lib/copilot/request/tool-call-state.ts @@ -1,3 +1,4 @@ +import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction' import { MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolOutcome as TerminalToolCallStatus, @@ -57,17 +58,43 @@ export function requireToolCallError( } export function getToolCallTerminalData( + toolCall: Pick +): unknown { + // getToolCallTerminalData is the single producer of "what the model reads on + // resume", so it is where a tool's result is reduced to its model-facing form — + // e.g. generate_api_key yields only its status message; the generated key never + // reaches the model (it goes to the browser via the SSE tool result instead). + return toolResultForModel(toolCall.name, getToolCallTerminalDataRaw(toolCall)) +} + +function getToolCallTerminalDataRaw( toolCall: Pick ): unknown { const output = getToolCallStateOutput(toolCall) + const failed = !isSuccessfulToolCallStatus(toolCall.status as TerminalToolCallStatus) + if (output !== undefined) { - return output + if (!failed) { + return output + } + /** + * A failed call must always surface its error in the terminal data — this + * is what the model reads on resume. Handlers can fail with an + * empty-but-defined output (the app-tool executor's "Tool not found" ships + * `output: {}`), and preferring that output rendered failures as bare `{}`, + * so the model retried blind instead of reacting to the error. + */ + const error = + typeof toolCall.error === 'string' && toolCall.error.length > 0 + ? toolCall.error + : 'Tool failed without an error message' + if (output && typeof output === 'object' && !Array.isArray(output)) { + return 'error' in output ? output : { ...output, error } + } + return { output, error } } - if ( - toolCall.status === MothershipStreamV1ToolOutcome.success || - toolCall.status === MothershipStreamV1ToolOutcome.skipped - ) { + if (!failed) { return undefined } diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index f479d5e99b7..f7219ed6627 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -33,12 +33,13 @@ import { KnowledgeBase, MaterializeFile, Media, - Research, Run, RunBlock, + RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, + Search, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -47,7 +48,6 @@ import { recordSimToolMetric } from '@/lib/copilot/request/metrics' import { withCopilotToolSpan } from '@/lib/copilot/request/otel' import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' import { - getToolCallStateOutput, getToolCallTerminalData, requireToolCallError, setTerminalToolCallState, @@ -225,12 +225,13 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ RunWorkflow.id, RunWorkflowUntilBlock.id, FunctionExecute.id, + RunCode.id, GenerateImage.id, GenerateAudio.id, GenerateVideo.id, Ffmpeg.id, Media.id, - Research.id, + Search.id, CrawlWebsite.id, KnowledgeBase.id, DownloadToWorkspaceFile.id, @@ -347,7 +348,10 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl } if (toolCall.status === MothershipStreamV1ToolOutcome.success) { - const data = getToolCallStateOutput(toolCall) + // getToolCallTerminalData (not raw output) so the completion signal carries + // the model-facing/redacted result — keeps the sim_key out of every path + // that consumes a completion, matching the error branch below. + const data = getToolCallTerminalData(toolCall) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.success, message: 'Tool completed', @@ -356,7 +360,7 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl } if (toolCall.status === MothershipStreamV1ToolOutcome.skipped) { - const data = getToolCallStateOutput(toolCall) + const data = getToolCallTerminalData(toolCall) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.success, message: 'Tool skipped', @@ -667,7 +671,10 @@ async function executeToolAndReportInner( }) if (result.success) { - const raw = result.output + // Log the model-facing (redacted) view, not result.output — for + // generate_api_key the raw output carries the plaintext key, which must + // never reach application logs. + const raw = getToolCallTerminalData(toolCall) const preview = typeof raw === 'string' ? raw.slice(0, 200) diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index ee77129e269..139757524c5 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -164,6 +164,34 @@ describe('maybeWriteOutputToFile', () => { expect(result.success).toBe(true) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) }) + + it('fails loudly instead of silently skipping declared outputs when workspace context is missing', async () => { + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, + { success: true, output: { result: 'name,age\nAlice,30', stdout: '' } }, + buildContext({ workspaceId: undefined }) + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('NOT written') + // The computed value survives so the model can use it without re-running. + expect(result.output).toEqual({ result: 'name,age\nAlice,30', stdout: '' }) + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('still passes results through untouched when no outputs are declared, even without workspace context', async () => { + const original = { success: true, output: { result: 42, stdout: '' } } + const result = await maybeWriteOutputToFile( + FunctionExecute.id, + {}, + original, + buildContext({ workspaceId: undefined }) + ) + + expect(result).toBe(original) + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) }) describe('extractTabularData', () => { diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 6a2811a6707..5fe59ef72a2 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -206,11 +206,27 @@ export async function maybeWriteOutputToFile( ): Promise { if (!result.success || !result.output) return result if (!OUTPUT_PATH_TOOLS.has(toolName)) return result - if (!context.workspaceId || !context.userId) return result const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath) if (outputFiles.length === 0) return result + // The tool declared workspace file outputs; passing the successful result + // through without writing them would be a silent no-op the model reads as + // "file written", so fail loudly instead — but keep the computed output so + // the model can still use the value without re-running the tool. + if (!context.workspaceId || !context.userId) { + logger.warn('Failing tool result: declared output files but no workspace context', { + toolName, + outputCount: outputFiles.length, + }) + return { + success: false, + error: + 'Declared output file(s) were NOT written: this tool call has no workspace context. The computed result is included in the output, but it was not saved to any file.', + output: result.output, + } + } + const outputObject = result.output && typeof result.output === 'object' && !Array.isArray(result.output) ? (result.output as Record) diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index e2e381f3f31..ff97f23cc6f 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -181,6 +181,90 @@ describe('extractResourcesFromToolResult', () => { }) describe('extractDeletedResourcesFromToolResult', () => { + it('extracts every successfully deleted workflow from the batch result', () => { + const resources = extractDeletedResourcesFromToolResult( + 'delete_workflow', + { workflowIds: ['wf-1', 'wf-2', 'wf-failed'] }, + { + deleted: [ + { workflowId: 'wf-1', name: 'First workflow' }, + { workflowId: 'wf-2', name: 'Second workflow' }, + ], + failed: ['wf-failed'], + } + ) + + expect(resources).toEqual([ + { type: 'workflow', id: 'wf-1', title: 'First workflow' }, + { type: 'workflow', id: 'wf-2', title: 'Second workflow' }, + ]) + }) + + it('extracts deleted files from delete_file result data', () => { + expect( + extractDeletedResourcesFromToolResult( + 'delete_file', + { paths: ['files/one.md', 'files/two.md'] }, + { + success: true, + data: { + deleted: [ + { id: 'file-1', name: 'one.md' }, + { id: 'file-2', name: 'two.md' }, + ], + failed: [], + }, + } + ) + ).toEqual([ + { type: 'file', id: 'file-1', title: 'one.md' }, + { type: 'file', id: 'file-2', title: 'two.md' }, + ]) + }) + + it('extracts deleted file folders from delete_file_folder result data', () => { + expect( + extractDeletedResourcesFromToolResult( + 'delete_file_folder', + { paths: ['files/Archive'] }, + { success: true, data: { folders: 1, files: 2, deletedFolderIds: ['folder-1'] } } + ) + ).toEqual([{ type: 'filefolder', id: 'folder-1', title: 'Folder' }]) + }) + + it('extracts only successfully deleted tables from user_table result data', () => { + expect( + extractDeletedResourcesFromToolResult( + 'user_table', + { operation: 'delete', args: { tableIds: ['table-1', 'table-failed'] } }, + { success: true, data: { deleted: ['table-1'], failed: ['table-failed'] } } + ) + ).toEqual([{ type: 'table', id: 'table-1', title: 'Table' }]) + }) + + it('extracts deleted knowledge bases from knowledge_base result data', () => { + expect( + extractDeletedResourcesFromToolResult( + 'knowledge_base', + { operation: 'delete', args: { knowledgeBaseIds: ['kb-1'] } }, + { + success: true, + data: { deleted: [{ id: 'kb-1', name: 'Docs' }], notFound: [] }, + } + ) + ).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }]) + }) + + it('extracts deleted workflow folders from manage_folder delete results', () => { + expect( + extractDeletedResourcesFromToolResult( + 'manage_folder', + { operation: 'delete', folderId: 'folder-1' }, + { deleted: ['folder-1'], failed: [] } + ) + ).toEqual([{ type: 'folder', id: 'folder-1', title: 'Folder' }]) + }) + it('removes scheduledtask resources on manage_scheduled_task delete', () => { const resources = extractDeletedResourcesFromToolResult( 'manage_scheduled_task', diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index d9e311f4720..7874718ef5b 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,6 +1,8 @@ import { CreateFile, CreateWorkflow, + DeleteFile, + DeleteFileFolder, DeleteWorkflow, DownloadToWorkspaceFile, EditWorkflow, @@ -11,6 +13,7 @@ import { GenerateVideo, Knowledge, KnowledgeBase, + ManageFolder, ManageScheduledTask, UserTable, WorkspaceFile, @@ -241,9 +244,12 @@ export function extractResourcesFromToolResult( const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { [DeleteWorkflow.id]: 'workflow', + [DeleteFile.id]: 'file', + [DeleteFileFolder.id]: 'filefolder', [WorkspaceFile.id]: 'file', [UserTable.id]: 'table', [KnowledgeBase.id]: 'knowledgebase', + [ManageFolder.id]: 'folder', [ManageScheduledTask.id]: 'scheduledtask', } @@ -271,8 +277,24 @@ export function extractDeletedResourcesFromToolResult( switch (toolName) { case DeleteWorkflow.id: { + const deleted = Array.isArray(result.deleted) ? result.deleted : [] + const resources = deleted.flatMap((entry): ChatResource[] => { + const deletedWorkflow = asRecord(entry) + const workflowId = deletedWorkflow.workflowId + if (typeof workflowId !== 'string' || !workflowId) return [] + return [ + { + type: resourceType, + id: workflowId, + title: typeof deletedWorkflow.name === 'string' ? deletedWorkflow.name : 'Workflow', + }, + ] + }) + if (resources.length > 0) return resources + + // Backward compatibility for historical single-workflow tool results. const workflowId = (result.workflowId as string) ?? (params?.workflowId as string) - if (workflowId && result.deleted) { + if (workflowId && result.deleted === true) { return [ { type: resourceType, id: workflowId, title: (result.name as string) || 'Workflow' }, ] @@ -280,6 +302,31 @@ export function extractDeletedResourcesFromToolResult( return [] } + case DeleteFile.id: { + const deleted = Array.isArray(data.deleted) ? data.deleted : [] + return deleted.flatMap((entry): ChatResource[] => { + const deletedFile = asRecord(entry) + const fileId = deletedFile.id + if (typeof fileId !== 'string' || !fileId) return [] + return [ + { + type: resourceType, + id: fileId, + title: typeof deletedFile.name === 'string' ? deletedFile.name : 'File', + }, + ] + }) + } + + case DeleteFileFolder.id: { + const deletedFolderIds = Array.isArray(data.deletedFolderIds) + ? data.deletedFolderIds.filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ) + : [] + return deletedFolderIds.map((id) => ({ type: resourceType, id, title: 'Folder' })) + } + case WorkspaceFile.id: { if (operation !== 'delete') return [] const target = getWorkspaceFileTarget(params) @@ -292,6 +339,12 @@ export function extractDeletedResourcesFromToolResult( case UserTable.id: { if (operation !== 'delete') return [] + const deleted = Array.isArray(data.deleted) + ? data.deleted.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] + if (deleted.length > 0) { + return deleted.map((id) => ({ type: resourceType, id, title: 'Table' })) + } const tableId = (args.tableId as string) ?? (params?.tableId as string) if (tableId) { return [{ type: resourceType, id: tableId, title: 'Table' }] @@ -301,6 +354,23 @@ export function extractDeletedResourcesFromToolResult( case KnowledgeBase.id: { if (operation !== 'delete') return [] + const deleted = Array.isArray(data.deleted) ? data.deleted : [] + const resources = deleted.flatMap((entry): ChatResource[] => { + const deletedKnowledgeBase = asRecord(entry) + const knowledgeBaseId = deletedKnowledgeBase.id + if (typeof knowledgeBaseId !== 'string' || !knowledgeBaseId) return [] + return [ + { + type: resourceType, + id: knowledgeBaseId, + title: + typeof deletedKnowledgeBase.name === 'string' + ? deletedKnowledgeBase.name + : 'Knowledge Base', + }, + ] + }) + if (resources.length > 0) return resources const kbId = (data.id as string) ?? (args.knowledgeBaseId as string) if (kbId) { return [{ type: resourceType, id: kbId, title: (data.name as string) || 'Knowledge Base' }] @@ -308,6 +378,14 @@ export function extractDeletedResourcesFromToolResult( return [] } + case ManageFolder.id: { + if (operation !== 'delete') return [] + const deletedIds = Array.isArray(result.deleted) ? (result.deleted as unknown[]) : [] + return deletedIds.flatMap((id): ChatResource[] => + typeof id === 'string' && id ? [{ type: resourceType, id, title: 'Folder' }] : [] + ) + } + case ManageScheduledTask.id: { if (operation !== 'delete') return [] const deletedIds = Array.isArray(result.deleted) ? (result.deleted as string[]) : [] diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index b39027e3526..4f6b21f5f1f 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { CheckDeploymentStatus, CompleteScheduledTask, + Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, DeleteWorkflow, @@ -32,16 +33,17 @@ import { ManageScheduledTask, ManageSkill, MaterializeFile, - MoveWorkflow, + Mkdir as MkdirTool, + Mv as MvTool, OauthGetAuthLink, OauthRequestAccess, OpenResource, PromoteToLive, Read as ReadTool, Redeploy, - RenameWorkflow, RestoreResource, RunBlock, + RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -87,7 +89,9 @@ import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/han import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' +import { executeRunCode } from '../tools/handlers/run-code' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' +import { executeVfsCp, executeVfsMkdir, executeVfsMv } from '../tools/handlers/vfs-mutate' import { executeCreateWorkflow, executeDeleteWorkflow, @@ -142,9 +146,12 @@ function buildHandlerMap(): Record { [CreateWorkflow.id]: h(executeCreateWorkflow), [DeleteWorkflow.id]: h(executeDeleteWorkflow), - [RenameWorkflow.id]: h(executeRenameWorkflow), - [MoveWorkflow.id]: h(executeMoveWorkflow), [ManageFolder.id]: h(executeManageFolder), + // rename_workflow / move_workflow were removed from the mothership catalog + // in favor of mv; the executors stay registered under literal names so + // in-flight checkpoints still resume. Delete after the mv release soaks. + rename_workflow: h(executeRenameWorkflow), + move_workflow: h(executeMoveWorkflow), [RunWorkflow.id]: h(executeRunWorkflow), [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), [RunFromBlock.id]: h(executeRunFromBlock), @@ -175,6 +182,9 @@ function buildHandlerMap(): Record { [GrepTool.id]: h(executeVfsGrep), [GlobTool.id]: h(executeVfsGlob), [ReadTool.id]: h(executeVfsRead), + [MvTool.id]: h(executeVfsMv), + [CpTool.id]: h(executeVfsCp), + [MkdirTool.id]: h(executeVfsMkdir), [ManageCustomTool.id]: h(executeManageCustomTool), [ManageMcpTool.id]: h(executeManageMcpTool), @@ -188,6 +198,7 @@ function buildHandlerMap(): Record { [ListIntegrationTools.id]: h(executeListIntegrationTools), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), + [RunCode.id]: h(executeRunCode), ...buildServerToolHandlers(), } diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts index 627927df4cf..d79f294b71f 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts @@ -12,9 +12,7 @@ describe('isToolHiddenInUi', () => { expect(isToolHiddenInUi('load_agent_skill')).toBe(true) }) - it('does not hide user skill loads, ordinary tools, or undefined', () => { - // load_user_skill renders like the old per-skill loaders so the load is visible. - expect(isToolHiddenInUi('load_user_skill')).toBe(false) + it('does not hide ordinary tools or undefined', () => { expect(isToolHiddenInUi('read')).toBe(false) expect(isToolHiddenInUi(undefined)).toBe(false) }) diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.ts index 5154ad2db1d..fa84b209f94 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.ts @@ -1,6 +1,5 @@ // load_agent_skill is retained for historical persisted messages; it is no -// longer emitted now that internal skills autoload. load_user_skill is NOT -// hidden — it renders like the old per-skill loaders so users see the skill load. +// longer emitted now that internal skills autoload. const HIDDEN_TOOL_NAMES = new Set(['load_agent_skill', 'load_custom_tool', 'load_integration_tool']) export function isToolHiddenInUi(toolName: string | undefined): boolean { diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index 7391a829ead..2f5d592269e 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -1,6 +1,6 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { getWorkflowById } from '@/lib/workflows/utils' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowRecord = NonNullable>> @@ -47,22 +47,23 @@ export async function ensureWorkspaceAccess( workspaceId: string, userId: string, level: 'read' | 'write' | 'admin' = 'read' -): Promise { +): Promise { const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.exists || !access.hasAccess) { throw new Error(`Workspace ${workspaceId} not found`) } - if (level === 'read') return + if (level === 'read') return access if (level === 'admin') { if (!access.canAdmin) { throw new Error('Admin access required for this workspace') } - return + return access } if (!access.canWrite) { throw new Error('Write or admin access required for this workspace') } + return access } diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index a514f6f735c..7f2a8d64930 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -46,7 +46,14 @@ async function executeSave(fileName: string, chatId: string): Promise ({ + mockEnsureWorkspaceAccess: vi.fn(), + mockGetCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: mockEnsureWorkspaceAccess, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mockGetCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: vi.fn(() => [ + { providerId: 'google-email', name: 'Gmail' }, + { providerId: 'slack', name: 'Slack' }, + { providerId: 'trello', name: 'Trello' }, + { providerId: 'shopify', name: 'Shopify' }, + ]), +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' + +const BASE_URL = 'https://sim.test' +const WORKSPACE_ID = 'ws-1' +const USER_ID = 'user-1' +const CREDENTIAL_ID = 'cred-1' + +const context = { + workspaceId: WORKSPACE_ID, + userId: USER_ID, + chatId: 'chat-1', +} as unknown as ExecutionContext + +const WORKSPACE_ACCESS = { + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + workspace: { id: WORKSPACE_ID }, +} + +function oauthCredentialActor(overrides: Record = {}) { + return { + credential: { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'oauth', + providerId: 'google-email', + ...((overrides.credential as Record) ?? {}), + }, + member: null, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), + } +} + +describe('executeOAuthGetAuthLink', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_APP_URL = BASE_URL + mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) + }) + + describe('connect (no credentialId)', () => { + it('returns an authorize URL without a credentialId param', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) + + expect(result.success).toBe(true) + const url = new URL((result.output as { oauth_url: string }).oauth_url) + expect(url.pathname).toBe('/api/auth/oauth2/authorize') + expect(url.searchParams.get('providerId')).toBe('google-email') + expect(url.searchParams.get('credentialId')).toBeNull() + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + }) + + describe('reconnect (credentialId passed)', () => { + it('returns an authorize URL carrying the credentialId and a reconnect message', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(true) + const output = result.output as { oauth_url: string; message: string } + const url = new URL(output.oauth_url) + expect(url.searchParams.get('credentialId')).toBe(CREDENTIAL_ID) + expect(output.message).toContain('Reconnect') + expect(output.message).toContain(CREDENTIAL_ID) + }) + + it('reuses the already-resolved workspace access for the credential lookup', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, { + workspaceAccess: WORKSPACE_ACCESS, + }) + }) + + it('fails with an agent-visible error for a nonexistent credential', async () => { + mockGetCredentialActorContext.mockResolvedValue({ + credential: null, + member: null, + hasWorkspaceAccess: false, + canWriteWorkspace: false, + isAdmin: false, + }) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: 'cred-hallucinated' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not found in this workspace') + }) + + it('fails when the credential belongs to another workspace', async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) + ) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not found in this workspace') + }) + + it('fails when the credential is not an OAuth credential', async () => { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { type: 'env_workspace' } }) + ) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('not an OAuth credential') + }) + + it('fails naming the actual provider when providerName does not match the credential', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + + const result = await executeOAuthGetAuthLink( + { providerName: 'slack', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('google-email') + }) + + it('fails when the caller is not a credential admin', async () => { + mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) + + const result = await executeOAuthGetAuthLink( + { providerName: 'google-email', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Admin access') + }) + + it('rejects reconnect for Trello and directs the user to the integrations page', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'trello', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('integrations page') + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + + it('rejects reconnect for Shopify and directs the user to the integrations page', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'shopify', credentialId: CREDENTIAL_ID }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('integrations page') + expect(mockGetCredentialActorContext).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 3919b0ba557..f0a5f9dbbb0 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -2,32 +2,44 @@ import { toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCredentialActorContext } from '@/lib/credentials/access' import { getAllOAuthServices } from '@/lib/oauth/utils' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' export async function executeOAuthGetAuthLink( rawParams: Record, context: ExecutionContext ): Promise { const providerName = String(rawParams.providerName || rawParams.provider_name || '') + const rawCredentialId = rawParams.credentialId || rawParams.credential_id + const credentialId = rawCredentialId ? String(rawCredentialId) : undefined const baseUrl = getBaseUrl() try { if (!context.workspaceId || !context.userId) { throw new Error('workspaceId and userId are required to generate an OAuth link') } - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + const workspaceAccess = await ensureWorkspaceAccess( + context.workspaceId, + context.userId, + 'write' + ) const result = await generateOAuthLink( context.workspaceId, context.workflowId, context.chatId, providerName, - baseUrl + baseUrl, + credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined ) + const action = credentialId ? 'reconnect' : 'connect' return { success: true, output: { - message: `Authorization URL generated for ${result.serviceName}.`, + message: credentialId + ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` + : `Authorization URL generated for ${result.serviceName}.`, oauth_url: result.url, - instructions: `Open this URL in your browser to connect ${result.serviceName}: ${result.url}`, + instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${result.url}`, provider: result.serviceName, providerId: result.providerId, }, @@ -72,13 +84,20 @@ export async function executeOAuthRequestAccess( * calls Better Auth, so the draft's TTL starts at click and the signed `state` * cookie is planted in the user's browser and the OAuth callback's state check * passes. + * + * When `reconnect` is set, the URL carries the existing credential id so the + * authorize endpoint creates a reconnect draft and the OAuth callback rebinds + * the credential in place instead of creating a new one. Validation happens + * here too (not just at click time) so a bad id fails in the tool result where + * the agent can see it, rather than as a silent browser redirect. */ async function generateOAuthLink( workspaceId: string | undefined, workflowId: string | undefined, chatId: string | undefined, providerName: string, - baseUrl: string + baseUrl: string, + reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess } ): Promise<{ url: string; providerId: string; serviceName: string }> { if (!workspaceId) { throw new Error('workspaceId is required to generate an OAuth link') @@ -105,6 +124,39 @@ async function generateOAuthLink( } const { providerId, name: serviceName } = matched + + if (reconnect) { + if (providerId === 'trello' || providerId === 'shopify') { + throw new Error( + `Reconnect is not supported for ${serviceName} from chat. Ask the user to open the ` + + `integrations page and press Reconnect on the credential there.` + ) + } + const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, { + workspaceAccess: reconnect.workspaceAccess, + }) + if (!actor.credential || actor.credential.workspaceId !== workspaceId) { + throw new Error( + `Credential "${reconnect.credentialId}" was not found in this workspace. Read ` + + `environment/credentials.json for valid credential ids.` + ) + } + if (actor.credential.type !== 'oauth') { + throw new Error( + `Credential "${reconnect.credentialId}" is not an OAuth credential and cannot be reconnected.` + ) + } + if (actor.credential.providerId !== providerId) { + throw new Error( + `Credential "${reconnect.credentialId}" belongs to provider "${actor.credential.providerId}", ` + + `not "${providerId}". Pass the matching providerName.` + ) + } + if (!actor.isAdmin) { + throw new Error('Admin access on the credential is required to reconnect it.') + } + } + const callbackURL = workflowId && workspaceId ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` @@ -138,6 +190,9 @@ async function generateOAuthLink( authorizeUrl.searchParams.set('providerId', providerId) authorizeUrl.searchParams.set('workspaceId', workspaceId) authorizeUrl.searchParams.set('callbackURL', callbackURL) + if (reconnect) { + authorizeUrl.searchParams.set('credentialId', reconnect.credentialId) + } return { url: authorizeUrl.toString(), providerId, serviceName } } diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts new file mode 100644 index 00000000000..e27babc4512 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/run-code.ts @@ -0,0 +1,31 @@ +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' + +/** + * Compute-only variant of function_execute for info-gathering agents: same + * sandbox and inputs, but it must never create or overwrite workspace + * resources. The write vectors (outputs.files, outputTable) are rejected here + * on top of the Go executor's fail-fast guard; run_code is also absent from + * the name-gated output post-processors (OUTPUT_PATH_TOOLS etc.), so even a + * leaked arg could not write anything. + */ +export async function executeRunCode( + params: Record, + context: ToolExecutionContext +): Promise { + if ('outputs' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputs (workspace file writes) is not available; return the data and report it instead', + } + } + if ('outputTable' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', + } + } + return executeFunctionExecute(params, context) +} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts new file mode 100644 index 00000000000..30847fdcc07 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -0,0 +1,509 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + ensureWorkspaceAccess: vi.fn(), + ensureWorkflowAccess: vi.fn(), + getDefaultWorkspaceId: vi.fn(), + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + getWorkspaceFileByName: vi.fn(), + findWorkspaceFileFolderIdByPath: vi.fn(), + ensureWorkspaceFileFolderPath: vi.fn(), + performMoveRenameWorkspaceFile: vi.fn(), + performUpdateWorkspaceFileFolder: vi.fn(), + performCreateFolder: vi.fn(), + performUpdateFolder: vi.fn(), + performUpdateWorkflow: vi.fn(), + duplicateWorkflow: vi.fn(), + listFolders: vi.fn(), + verifyFolderWorkspace: vi.fn(), + listTables: vi.fn(), + renameTable: vi.fn(), + getKnowledgeBases: vi.fn(), + updateKnowledgeBase: vi.fn(), + checkKnowledgeBaseWriteAccess: vi.fn(), + workflowRows: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => Promise.resolve(mocks.workflowRows()), + }), + }), + }, + workflow: { id: 'id', name: 'name', folderId: 'folderId', workspaceId: 'workspaceId' }, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, + ensureWorkflowAccess: mocks.ensureWorkflowAccess, + getDefaultWorkspaceId: mocks.getDefaultWorkspaceId, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFileByName: mocks.getWorkspaceFileByName, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, + ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, + normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performMoveRenameWorkspaceFile: mocks.performMoveRenameWorkspaceFile, + performUpdateWorkspaceFileFolder: mocks.performUpdateWorkspaceFileFolder, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performCreateFolder: mocks.performCreateFolder, + performUpdateFolder: mocks.performUpdateFolder, + performUpdateWorkflow: mocks.performUpdateWorkflow, +})) + +vi.mock('@/lib/workflows/persistence/duplicate', () => ({ + duplicateWorkflow: mocks.duplicateWorkflow, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + listFolders: mocks.listFolders, + verifyFolderWorkspace: mocks.verifyFolderWorkspace, +})) + +vi.mock('@/lib/table/service', () => ({ + listTables: mocks.listTables, + renameTable: mocks.renameTable, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBases: mocks.getKnowledgeBases, + updateKnowledgeBase: mocks.updateKnowledgeBase, +})) + +vi.mock('@/app/api/knowledge/utils', () => ({ + checkKnowledgeBaseWriteAccess: mocks.checkKnowledgeBaseWriteAccess, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeVfsCp, executeVfsMkdir, executeVfsMv } from './vfs-mutate' + +const context = { userId: 'user-1', workspaceId: 'ws-1' } as ExecutionContext + +describe('vfs mv/cp', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) + mocks.ensureWorkflowAccess.mockResolvedValue({ workspaceId: 'ws-1', workflow: {} }) + mocks.assertFolderMutable.mockResolvedValue(undefined) + mocks.assertWorkflowMutable.mockResolvedValue(undefined) + mocks.verifyFolderWorkspace.mockResolvedValue(true) + mocks.listFolders.mockResolvedValue([]) + mocks.workflowRows.mockReturnValue([]) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') + }) + + describe('category rules', () => { + it('rejects cross-category moves', async () => { + const result = await executeVfsMv( + { sources: ['files/report.pdf'], destination: 'workflows/report' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('across categories') + }) + + it('rejects uploads with a materialize_file pointer', async () => { + const result = await executeVfsMv( + { sources: ['uploads/data.csv'], destination: 'files/data.csv' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('materialize_file') + }) + + it('rejects read-only categories', async () => { + const result = await executeVfsMv( + { sources: ['components/blocks/gmail.json'], destination: 'components/blocks/g.json' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('not a movable resource') + }) + + it('aborts before mutating when the request was cancelled', async () => { + const abortedContext = { + userId: 'user-1', + workspaceId: 'ws-1', + abortSignal: { aborted: true }, + } as unknown as ExecutionContext + const result = await executeVfsMv( + { sources: ['files/a.md'], destination: 'files/b.md' }, + abortedContext + ) + expect(result.success).toBe(false) + expect(result.error).toContain('aborted') + expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + }) + }) + + describe('files', () => { + it('moves and renames a file in one call, auto-creating destination folders', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) + mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ + success: true, + file: { id: 'file-1', name: 'final.md' }, + }) + + const result = await executeVfsMv( + { sources: ['files/draft.md'], destination: 'files/Reports/2026/final.md' }, + context + ) + + expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { + folderId: null, + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + pathSegments: ['Reports', '2026'], + }) + expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + fileId: 'file-1', + targetFolderId: 'ensured-folder', + newName: 'final.md', + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ from: 'files/draft.md', to: 'files/Reports/2026/final.md', kind: 'file' }], + }) + }) + + it('moves into an existing folder keeping the name without creating anything', async () => { + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') + mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) + mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ + success: true, + file: { id: 'file-1', name: 'a.png' }, + }) + + const result = await executeVfsMv( + { sources: ['files/a.png'], destination: 'files/Images' }, + context + ) + + expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ targetFolderId: 'folder-images', newName: 'a.png' }) + ) + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) + }) + + it('requires a folder destination for multiple sources', async () => { + const result = await executeVfsMv( + { sources: ['files/a.png', 'files/b.png'], destination: 'files/Images/c.png' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('must be a folder') + }) + + it('resolves sources at their exact path only — no cross-folder name fallback', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + + const result = await executeVfsMv( + { sources: ['files/report.pdf'], destination: 'files/Archive/' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Not found') + expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + }) + + it('rejects copying workspace files — cp is workflows-only', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'template.md' }) + + const result = await executeVfsCp( + { sources: ['files/template.md'], destination: 'files/Reports/january.md' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('cp only duplicates workflows') + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + }) + + it('moves and renames a file folder via performUpdateWorkspaceFileFolder', async () => { + mocks.findWorkspaceFileFolderIdByPath + .mockResolvedValueOnce(null) // destination is not an existing folder + .mockResolvedValueOnce('folder-src') // source resolves as folder + mocks.performUpdateWorkspaceFileFolder.mockResolvedValue({ + success: true, + folder: { name: 'Reports 2025' }, + }) + + const result = await executeVfsMv( + { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, + context + ) + + expect(mocks.performUpdateWorkspaceFileFolder).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + folderId: 'folder-src', + userId: 'user-1', + name: 'Reports 2025', + parentId: 'ensured-folder', + }) + expect(result.success).toBe(true) + }) + + it('rejects reserved alias backing paths', async () => { + const result = await executeVfsMv( + { sources: ['files/.plans/wf_1/launch.md'], destination: 'files/launch.md' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('Reserved system paths') + }) + }) + + describe('workflows', () => { + it('renames a workflow at root', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Old Name', folderId: null }]) + mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, + context + ) + + expect(mocks.assertWorkflowMutable).toHaveBeenCalledWith('wf-1') + expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', name: 'New Name', folderId: null }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'workflows/New%20Name' }] }) + }) + + it('moves a workflow into an existing folder keeping its name', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Archive', parentId: null }, + ]) + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'My Workflow', folderId: null }]) + mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/My%20Workflow'], destination: 'workflows/Archive' }, + context + ) + + expect(mocks.assertFolderMutable).toHaveBeenCalledWith('fold-1') + expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', name: undefined, folderId: 'fold-1' }) + ) + expect(result.success).toBe(true) + }) + + it('surfaces locked-workflow rejections per item', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Locked One', folderId: null }]) + mocks.assertWorkflowMutable.mockRejectedValue(new Error('Workflow is locked')) + + const result = await executeVfsMv( + { sources: ['workflows/Locked%20One'], destination: 'workflows/Renamed' }, + context + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('locked') + }) + + it('duplicates a workflow with cp (locked source allowed)', async () => { + mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Template', folderId: null }]) + mocks.duplicateWorkflow.mockResolvedValue({ id: 'wf-2', name: 'My Copy' }) + + const result = await executeVfsCp( + { sources: ['workflows/Template'], destination: 'workflows/My Copy' }, + context + ) + + expect(mocks.assertWorkflowMutable).not.toHaveBeenCalled() + expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + sourceWorkflowId: 'wf-1', + workspaceId: 'ws-1', + folderId: null, + name: 'My Copy', + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'workflows/My%20Copy', id: 'wf-2' }] }) + }) + + it('rejects copying workflow folders', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Projects', parentId: null }, + ]) + const result = await executeVfsCp( + { sources: ['workflows/Projects'], destination: 'workflows/Projects Copy' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('cannot be copied') + }) + + it('moves and renames a workflow folder', async () => { + mocks.listFolders.mockResolvedValue([ + { folderId: 'fold-1', folderName: 'Q1', parentId: null }, + { folderId: 'fold-2', folderName: 'Archive', parentId: null }, + ]) + mocks.performUpdateFolder.mockResolvedValue({ success: true }) + + const result = await executeVfsMv( + { sources: ['workflows/Q1'], destination: 'workflows/Archive/Q1 2026' }, + context + ) + + expect(mocks.performUpdateFolder).toHaveBeenCalledWith( + expect.objectContaining({ folderId: 'fold-1', name: 'Q1 2026', parentId: 'fold-2' }) + ) + expect(result.success).toBe(true) + }) + }) + + describe('mkdir', () => { + it('creates a nested file folder chain', async () => { + const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) + + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + pathSegments: ['Reports', '2026'], + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], + }) + }) + + it('creates a workflow folder via performCreateFolder', async () => { + mocks.listFolders.mockResolvedValue([]) + mocks.performCreateFolder.mockResolvedValue({ success: true, folder: { id: 'fold-new' } }) + + const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) + + expect(mocks.performCreateFolder).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + name: 'Archive', + parentId: undefined, + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ to: 'workflows/Archive', kind: 'workflow_folder', id: 'fold-new' }], + }) + }) + + it('rejects flat namespaces and reserved paths', async () => { + const result = await executeVfsMkdir({ paths: ['tables/CRM', 'files/.plans/wf_1'] }, context) + expect(result.success).toBe(false) + expect(result.output).toMatchObject({ + results: [ + { from: 'tables/CRM', error: expect.stringContaining('flat namespace') }, + { from: 'files/.plans/wf_1', error: expect.stringContaining('Reserved') }, + ], + }) + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + }) + + it('rejects creation inside a locked workflow folder', async () => { + mocks.listFolders.mockResolvedValue([]) + mocks.assertFolderMutable.mockRejectedValue(new Error('Folder is locked')) + + const result = await executeVfsMkdir({ paths: ['workflows/Locked/Sub'] }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('locked') + expect(mocks.performCreateFolder).not.toHaveBeenCalled() + }) + }) + + describe('tables and knowledge bases (flat namespaces)', () => { + it('renames a table', async () => { + mocks.listTables.mockResolvedValue([{ id: 'tbl-1', name: 'Leads' }]) + mocks.renameTable.mockResolvedValue({ id: 'tbl-1', name: 'Customers' }) + + const result = await executeVfsMv( + { sources: ['tables/Leads'], destination: 'tables/Customers' }, + context + ) + + expect(mocks.renameTable).toHaveBeenCalledWith('tbl-1', 'Customers', expect.any(String)) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] }) + }) + + it('rejects nested table destinations as flat-namespace violations', async () => { + const result = await executeVfsMv( + { sources: ['tables/Leads'], destination: 'tables/CRM/Leads' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('flat namespace') + expect(mocks.renameTable).not.toHaveBeenCalled() + }) + + it('rejects copying tables', async () => { + const result = await executeVfsCp( + { sources: ['tables/Leads'], destination: 'tables/Leads Copy' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('cannot be copied') + }) + + it('renames a knowledge base after a write-access check', async () => { + mocks.getKnowledgeBases.mockResolvedValue([{ id: 'kb-1', name: 'Docs' }]) + mocks.checkKnowledgeBaseWriteAccess.mockResolvedValue({ hasAccess: true }) + mocks.updateKnowledgeBase.mockResolvedValue({ id: 'kb-1', name: 'Product Docs' }) + + const result = await executeVfsMv( + { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, + context + ) + + expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1') + expect(mocks.updateKnowledgeBase).toHaveBeenCalledWith( + 'kb-1', + { name: 'Product Docs' }, + expect.any(String) + ) + expect(result.success).toBe(true) + }) + + it('rejects the reserved knowledgebases/connectors name', async () => { + const result = await executeVfsMv( + { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/connectors' }, + context + ) + expect(result.success).toBe(false) + expect(result.error).toContain('reserved') + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts new file mode 100644 index 00000000000..44682d3e615 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -0,0 +1,768 @@ +import { db, workflow as workflowTable } from '@sim/db' +import { createLogger } from '@sim/logger' +import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' +import { toError } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { + ensureWorkflowAccess, + ensureWorkspaceAccess, + getDefaultWorkspaceId, +} from '@/lib/copilot/tools/handlers/access' +import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { + buildVfsFolderPathMap, + canonicalWorkflowVfsDir, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' +import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' +import { generateRequestId } from '@/lib/core/utils/request' +import { getKnowledgeBases, updateKnowledgeBase } from '@/lib/knowledge/service' +import { listTables, renameTable } from '@/lib/table/service' +import { + ensureWorkspaceFileFolderPath, + findWorkspaceFileFolderIdByPath, + normalizeWorkspaceFileItemName, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + getWorkspaceFileByName, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + performCreateFolder, + performUpdateFolder, + performUpdateWorkflow, +} from '@/lib/workflows/orchestration' +import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' +import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' +import { + performMoveRenameWorkspaceFile, + performUpdateWorkspaceFileFolder, +} from '@/lib/workspace-files/orchestration' +import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' + +const logger = createLogger('VfsMutateTools') + +type MutateVerb = 'mv' | 'cp' + +type MutateCategory = 'files' | 'workflows' | 'tables' | 'knowledgebases' + +const MUTATE_CATEGORIES = new Set(['files', 'workflows', 'tables', 'knowledgebases']) + +const CATEGORY_REJECTIONS: Record = { + uploads: + 'uploads/ files are chat-scoped and immutable. Use materialize_file to promote one into files/ first.', + 'recently-deleted': + 'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.', +} + +interface VfsMutateOutcome { + from: string + to?: string + kind: 'file' | 'file_folder' | 'workflow' | 'workflow_folder' | 'table' | 'knowledge_base' + id?: string + error?: string +} + +/** Top-level VFS segment of a raw (possibly encoded) path. */ +function topLevelSegment(path: string): string { + return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' +} + +function classifyCategory(path: string): { category: MutateCategory } | { error: string } { + const top = topLevelSegment(path) + if (MUTATE_CATEGORIES.has(top)) return { category: top as MutateCategory } + const rejection = CATEGORY_REJECTIONS[top] + if (rejection) return { error: rejection } + return { + error: `"${path}" is not a movable resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, + } +} + +function normalizeSources(raw: unknown): string[] { + if (typeof raw === 'string') return raw.trim() ? [raw.trim()] : [] + if (!Array.isArray(raw)) return [] + return raw.filter((s): s is string => typeof s === 'string' && s.trim().length > 0) +} + +function hasTrailingSlash(path: string): boolean { + return /\/\s*$/.test(path) +} + +function assertMutationNotAborted(context: ExecutionContext): void { + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before the mutation could be applied.') + } +} + +function buildResult(verb: MutateVerb | 'mkdir', outcomes: VfsMutateOutcome[]): ToolCallResult { + const failed = outcomes.filter((o) => o.error) + if (failed.length === outcomes.length) { + return { + success: false, + error: failed[0]?.error || `${verb} failed`, + output: { results: outcomes }, + } + } + return { success: true, output: { results: outcomes } } +} + +export async function executeVfsMv( + params: Record, + context: ExecutionContext +): Promise { + return executeVfsMutate('mv', params, context) +} + +export async function executeVfsCp( + params: Record, + context: ExecutionContext +): Promise { + return executeVfsMutate('cp', params, context) +} + +/** + * mkdir -p over the VFS: creates each folder path (missing parents included) + * under files/ or workflows/. Existing folders are not an error. + */ +export async function executeVfsMkdir( + params: Record, + context: ExecutionContext +): Promise { + try { + const paths = normalizeSources(params.paths) + if (paths.length === 0) { + return { success: false, error: 'paths is required (an array of folder VFS paths)' } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + assertMutationNotAborted(context) + + let ensureWorkflowFolder: ((segments: string[]) => Promise) | undefined + + const outcomes: VfsMutateOutcome[] = [] + for (const path of paths) { + const top = topLevelSegment(path) + const segments = decodeVfsPathSegments(path).slice(1) + const kind = top === 'workflows' ? 'workflow_folder' : 'file_folder' + + if (top !== 'files' && top !== 'workflows') { + const rejection = + top === 'tables' || top === 'knowledgebases' + ? `${top}/ is a flat namespace with no folders.` + : (CATEGORY_REJECTIONS[top] ?? + `"${path}" is not a folder target. mkdir supports files/ and workflows/ paths.`) + outcomes.push({ from: path, kind, error: rejection }) + continue + } + if (segments.length === 0) { + outcomes.push({ from: path, kind, error: 'Path must include at least one folder segment' }) + continue + } + if (top === 'files' && isWorkflowAliasBackingPath(path)) { + outcomes.push({ from: path, kind, error: `Reserved system path: ${path}` }) + continue + } + + try { + assertMutationNotAborted(context) + let folderId: string | null + if (top === 'files') { + folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId: context.userId, + pathSegments: segments, + }) + } else { + ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( + workspaceId, + context.userId, + await loadWorkflowFolderIndex(workspaceId) + ) + folderId = await ensureWorkflowFolder(segments) + } + outcomes.push({ + from: path, + to: `${top}/${encodeVfsPathSegments(segments)}`, + kind, + id: folderId ?? undefined, + }) + } catch (error) { + outcomes.push({ from: path, kind, error: toError(error).message }) + } + } + + return buildResult('mkdir', outcomes) + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +async function executeVfsMutate( + verb: MutateVerb, + params: Record, + context: ExecutionContext +): Promise { + try { + const sources = normalizeSources(params.sources) + const destination = typeof params.destination === 'string' ? params.destination.trim() : '' + if (sources.length === 0) { + return { success: false, error: 'sources is required (an array of canonical VFS paths)' } + } + if (!destination) { + return { success: false, error: 'destination is required' } + } + + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + assertMutationNotAborted(context) + + const classified = classifyCategory(sources[0]) + if ('error' in classified) return { success: false, error: classified.error } + const { category } = classified + + for (const source of sources.slice(1)) { + const other = classifyCategory(source) + if ('error' in other) return { success: false, error: other.error } + if (other.category !== category) { + return { + success: false, + error: `All sources must share one category; got ${category}/ and ${other.category}/.`, + } + } + } + + const destTop = topLevelSegment(destination) + if (destTop !== category) { + return { + success: false, + error: `Cannot ${verb} across categories: ${category}/ sources cannot target "${destination}". Resources stay within their category.`, + } + } + + switch (category) { + case 'files': + return await mutateWorkspaceFiles(verb, sources, destination, context, workspaceId) + case 'workflows': + return await mutateWorkflows(verb, sources, destination, context, workspaceId) + default: + return await renameFlatResource(verb, category, sources, destination, context, workspaceId) + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +interface DestinationPlan { + /** True when sources move INTO the destination folder keeping their names. */ + dirMode: boolean + /** Decoded display-name segments of the destination folder. */ + folderSegments: string[] + /** New leaf name; set only when `dirMode` is false. */ + leafName?: string + /** + * Resolve the destination folder id, creating missing folders on first call. + * Deferred and memoized so nothing is created until a source is confirmed + * valid — a fully-failed mv/cp must not leave folders behind. + */ + ensureFolderId: () => Promise +} + +/** + * Shared destination interpretation for every category with folders: an + * existing folder (or a trailing "/") means move/copy INTO it keeping names; + * otherwise the last segment is the new name and the preceding segments are + * the target folder. Folder creation is deferred to `ensureFolderId`. + */ +async function planDestination(args: { + destination: string + sourceCount: number + lookupFolder: (segments: string[]) => Promise + ensureFolderPath: (segments: string[]) => Promise +}): Promise { + const rest = decodeVfsPathSegments(args.destination).slice(1) + const plan = ( + dirMode: boolean, + folderSegments: string[], + leafName?: string, + knownFolderId?: string | null + ): DestinationPlan => { + let memo: Promise | undefined + return { + dirMode, + folderSegments, + leafName, + ensureFolderId: () => + (memo ??= + knownFolderId !== undefined + ? Promise.resolve(knownFolderId) + : folderSegments.length > 0 + ? args.ensureFolderPath(folderSegments) + : Promise.resolve(null)), + } + } + + if (rest.length === 0) return plan(true, [], undefined, null) + if (hasTrailingSlash(args.destination)) return plan(true, rest) + const existing = await args.lookupFolder(rest) + if (existing) return plan(true, rest, undefined, existing) + if (args.sourceCount > 1) { + return { + error: `With multiple sources the destination must be a folder. "${args.destination}" does not exist — end it with "/" to create it.`, + } + } + return plan(false, rest.slice(0, -1), rest.at(-1) as string) +} + +/** + * Resolve a `files/...` source to the file at EXACTLY that path (folder- + * anchored). Deliberately not the lenient read-side resolver — on a + * destructive path a bare-name fallback could match a file in a different + * folder than the one named. + */ +async function resolveFileAtExactPath( + workspaceId: string, + segments: string[] +): Promise { + const fileName = normalizeWorkspaceFileItemName(segments.at(-1) ?? '', 'File') + if (segments.length === 1) { + return getWorkspaceFileByName(workspaceId, fileName, { folderId: null }) + } + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) + if (!folderId) return null + return getWorkspaceFileByName(workspaceId, fileName, { folderId }) +} + +async function mutateWorkspaceFiles( + verb: MutateVerb, + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + if (verb === 'cp') { + return { + success: false, + error: 'Workspace files cannot be copied — cp only duplicates workflows.', + } + } + for (const path of [...sources, destination]) { + if (isWorkflowAliasBackingPath(path)) { + return { + success: false, + error: `Reserved system paths cannot be moved or renamed: ${path}`, + } + } + } + + const dest = await planDestination({ + destination, + sourceCount: sources.length, + lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), + ensureFolderPath: (segments) => + ensureWorkspaceFileFolderPath({ + workspaceId, + userId: context.userId, + pathSegments: segments, + }), + }) + if ('error' in dest) return { success: false, error: dest.error } + + // Resolve every source read-only before mutating anything, so a fully + // invalid call cannot create destination folders as a side effect. + type SourceRef = + | { source: string; file: WorkspaceFileRecord } + | { source: string; folderId: string } + | { source: string; error: string } + const refs: SourceRef[] = [] + for (const source of sources) { + const segments = decodeVfsPathSegments(source).slice(1) + if (segments.length === 0) { + refs.push({ source, error: 'Source must name a file or folder under files/' }) + continue + } + const file = await resolveFileAtExactPath(workspaceId, segments) + if (file) { + refs.push({ source, file }) + continue + } + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (folderId) refs.push({ source, folderId }) + else refs.push({ source, error: `Not found: ${source}` }) + } + + const outcomes: VfsMutateOutcome[] = [] + for (const ref of refs) { + if ('error' in ref) { + outcomes.push({ from: ref.source, kind: 'file', error: ref.error }) + continue + } + + if ('file' in ref) { + assertMutationNotAborted(context) + const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) + const targetFolderId = await dest.ensureFolderId() + const result = await performMoveRenameWorkspaceFile({ + workspaceId, + userId: context.userId, + fileId: ref.file.id, + targetFolderId, + newName: targetName, + }) + outcomes.push( + result.success && result.file + ? { + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, + kind: 'file', + id: ref.file.id, + } + : { from: ref.source, kind: 'file', error: result.error || 'Failed to move file' } + ) + continue + } + + assertMutationNotAborted(context) + const targetFolderId = await dest.ensureFolderId() + if (targetFolderId === ref.folderId) { + outcomes.push({ + from: ref.source, + kind: 'file_folder', + error: 'Cannot move a folder into itself', + }) + continue + } + const result = await performUpdateWorkspaceFileFolder({ + workspaceId, + folderId: ref.folderId, + userId: context.userId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }) + outcomes.push( + result.success && result.folder + ? { + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, + kind: 'file_folder', + id: ref.folderId, + } + : { from: ref.source, kind: 'file_folder', error: result.error || 'Failed to move folder' } + ) + } + + return buildResult(verb, outcomes) +} + +interface WorkflowFolderIndex { + folderPathById: Map + folderIdByPath: Map +} + +async function loadWorkflowFolderIndex(workspaceId: string): Promise { + const folderPathById = buildVfsFolderPathMap(await listFolders(workspaceId)) + const folderIdByPath = new Map() + for (const [id, path] of folderPathById.entries()) folderIdByPath.set(path, id) + return { folderPathById, folderIdByPath } +} + +/** + * mkdir -p for workflow folders: resolves each segment against the index, + * creating missing ones (locked parents rejected) and keeping the index maps + * current so later paths in the same call see the new folders. + */ +function makeWorkflowFolderEnsurer( + workspaceId: string, + userId: string, + index: WorkflowFolderIndex +): (segments: string[]) => Promise { + return async (segments) => { + let parentId: string | null = null + let pathSoFar = '' + for (const segment of segments) { + pathSoFar = pathSoFar + ? `${pathSoFar}/${encodeVfsPathSegments([segment])}` + : encodeVfsPathSegments([segment]) + const existing = index.folderIdByPath.get(pathSoFar) + if (existing) { + parentId = existing + continue + } + await assertFolderMutable(parentId) + const created = await performCreateFolder({ + workspaceId, + userId, + name: segment, + parentId: parentId ?? undefined, + }) + if (!created.success || !created.folder) { + throw new Error(created.error || `Failed to create workflow folder "${segment}"`) + } + index.folderIdByPath.set(pathSoFar, created.folder.id) + index.folderPathById.set(created.folder.id, pathSoFar) + parentId = created.folder.id + } + return parentId + } +} + +async function mutateWorkflows( + verb: MutateVerb, + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const index = await loadWorkflowFolderIndex(workspaceId) + const { folderPathById, folderIdByPath } = index + + const workflowRows = await db + .select({ + id: workflowTable.id, + name: workflowTable.name, + folderId: workflowTable.folderId, + }) + .from(workflowTable) + .where(eq(workflowTable.workspaceId, workspaceId)) + const workflowByPath = new Map() + for (const row of workflowRows) { + const dir = canonicalWorkflowVfsDir({ + name: row.name, + folderPath: row.folderId ? folderPathById.get(row.folderId) : null, + }) + if (!workflowByPath.has(dir)) workflowByPath.set(dir, row) + } + + const ensureWorkflowFolderPath = makeWorkflowFolderEnsurer(workspaceId, context.userId, index) + + const dest = await planDestination({ + destination, + sourceCount: sources.length, + lookupFolder: async (segments) => folderIdByPath.get(encodeVfsPathSegments(segments)) ?? null, + ensureFolderPath: ensureWorkflowFolderPath, + }) + if ('error' in dest) return { success: false, error: dest.error } + if (!dest.dirMode && (dest.leafName as string).length > 200) { + return { success: false, error: 'Workflow name must be 200 characters or less' } + } + + // Resolve every source against the in-memory maps before mutating anything. + type SourceRef = + | { source: string; workflow: (typeof workflowRows)[number] } + | { source: string; folderId: string } + | { source: string; error: string } + const refs: SourceRef[] = [] + for (const source of sources) { + const segments = decodeVfsPathSegments(source).slice(1) + if (segments.length === 0) { + refs.push({ source, error: 'Source must name a workflow or folder under workflows/' }) + continue + } + const encoded = encodeVfsPathSegments(segments) + const wf = workflowByPath.get(`workflows/${encoded}`) + if (wf) { + refs.push({ source, workflow: wf }) + continue + } + const folderId = folderIdByPath.get(encoded) + if (folderId) refs.push({ source, folderId }) + else refs.push({ source, error: `Not found: ${source}` }) + } + + const outcomes: VfsMutateOutcome[] = [] + for (const ref of refs) { + if ('error' in ref) { + outcomes.push({ from: ref.source, kind: 'workflow', error: ref.error }) + continue + } + + if ('workflow' in ref) { + const wf = ref.workflow + const targetName = dest.dirMode ? wf.name : (dest.leafName as string) + try { + assertMutationNotAborted(context) + if (verb === 'cp') { + const targetFolderId = await dest.ensureFolderId() + const duplicated = await duplicateWorkflow({ + sourceWorkflowId: wf.id, + userId: context.userId, + workspaceId, + folderId: targetFolderId, + name: targetName, + requestId: generateRequestId(), + }) + outcomes.push({ + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, duplicated.name])}`, + kind: 'workflow', + id: duplicated.id, + }) + } else { + await ensureWorkflowAccess(wf.id, context.userId, 'write') + await assertWorkflowMutable(wf.id) + const targetFolderId = await dest.ensureFolderId() + await assertFolderMutable(targetFolderId) + if (targetFolderId && !(await verifyFolderWorkspace(targetFolderId, workspaceId))) { + outcomes.push({ + from: ref.source, + kind: 'workflow', + error: 'Destination folder not found', + }) + continue + } + const result = await performUpdateWorkflow({ + workflowId: wf.id, + userId: context.userId, + workspaceId, + currentName: wf.name, + currentFolderId: wf.folderId, + name: dest.dirMode ? undefined : targetName, + folderId: targetFolderId, + }) + outcomes.push( + result.success + ? { + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, targetName])}`, + kind: 'workflow', + id: wf.id, + } + : { + from: ref.source, + kind: 'workflow', + error: result.error || 'Failed to move workflow', + } + ) + } + } catch (error) { + outcomes.push({ from: ref.source, kind: 'workflow', error: toError(error).message }) + } + continue + } + + if (verb === 'cp') { + outcomes.push({ + from: ref.source, + kind: 'workflow_folder', + error: 'Workflow folders cannot be copied.', + }) + continue + } + try { + assertMutationNotAborted(context) + await assertFolderMutable(ref.folderId) + const targetFolderId = await dest.ensureFolderId() + if (targetFolderId === ref.folderId) { + outcomes.push({ + from: ref.source, + kind: 'workflow_folder', + error: 'Cannot move a folder into itself', + }) + continue + } + await assertFolderMutable(targetFolderId) + const result = await performUpdateFolder({ + folderId: ref.folderId, + workspaceId, + userId: context.userId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }) + const finalLeaf = dest.dirMode + ? (decodeVfsPathSegments(ref.source).slice(1).at(-1) ?? '') + : (dest.leafName as string) + outcomes.push( + result.success + ? { + from: ref.source, + to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, finalLeaf])}`, + kind: 'workflow_folder', + id: ref.folderId, + } + : { + from: ref.source, + kind: 'workflow_folder', + error: result.error || 'Failed to move folder', + } + ) + } catch (error) { + outcomes.push({ from: ref.source, kind: 'workflow_folder', error: toError(error).message }) + } + } + + return buildResult(verb, outcomes) +} + +async function renameFlatResource( + verb: MutateVerb, + category: 'tables' | 'knowledgebases', + sources: string[], + destination: string, + context: ExecutionContext, + workspaceId: string +): Promise { + const label = category === 'tables' ? 'Tables' : 'Knowledge bases' + const kind = category === 'tables' ? 'table' : 'knowledge_base' + + if (verb === 'cp') { + return { success: false, error: `${label} cannot be copied — duplication is not supported.` } + } + if (sources.length > 1) { + return { success: false, error: `${label} are renamed one at a time.` } + } + + const sourceSegments = decodeVfsPathSegments(sources[0]).slice(1) + const destSegments = decodeVfsPathSegments(destination).slice(1) + if (sourceSegments.length !== 1 || destSegments.length !== 1 || hasTrailingSlash(destination)) { + return { + success: false, + error: `${label} have a flat namespace with no folders — mv only renames them, e.g. mv({sources: ["${category}/Old Name"], destination: "${category}/New Name"}).`, + } + } + + const sourceName = sourceSegments[0] + const newName = destSegments[0] + const canonicalSource = normalizeVfsSegment(sourceName) + + if (category === 'tables') { + const tables = await listTables(workspaceId) + const match = tables.find((t) => normalizeVfsSegment(t.name) === canonicalSource) + if (!match) { + return { success: false, error: `Table not found at ${sources[0]}` } + } + assertMutationNotAborted(context) + const renamed = await renameTable(match.id, newName, generateRequestId()) + return buildResult(verb, [ + { + from: sources[0], + to: `tables/${normalizeVfsSegment(renamed.name)}`, + kind, + id: match.id, + }, + ]) + } + + if (newName.toLowerCase() === 'connectors') { + return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } + } + const kbs = await getKnowledgeBases(context.userId, workspaceId) + const match = kbs.find((kb) => normalizeVfsSegment(kb.name) === canonicalSource) + if (!match) { + return { success: false, error: `Knowledge base not found at ${sources[0]}` } + } + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + if (!access.hasAccess) { + return { + success: false, + error: `Write access required to rename knowledge base "${match.name}"`, + } + } + assertMutationNotAborted(context) + await updateKnowledgeBase(match.id, { name: newName }, generateRequestId()) + logger.info('Renamed knowledge base via mv', { knowledgeBaseId: match.id, workspaceId }) + return buildResult(verb, [ + { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, + ]) +} diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 2e0f7fc5300..98bf953d7fd 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -853,8 +853,7 @@ export async function executeGenerateApiKey( name: result.key.name, key: result.key.key, workspaceId, - message: - 'API key created successfully. Copy this key now — it will not be shown again. Use this key in the x-api-key header when calling workflow API endpoints.', + message: `API key "${result.key.name}" created. You did NOT receive the key value — Sim reveals it to the user ONLY through the secure, copyable chip it renders where you place a {"type":"sim_key"} tag, so you MUST emit that tag now or the user can never see the key (it cannot be shown again). Never print, guess, or fabricate a value. The key authenticates calls to deployed workflow endpoints via the x-api-key header.`, }, } } catch (error) { diff --git a/apps/sim/lib/copilot/tools/server/files/delete-file.ts b/apps/sim/lib/copilot/tools/server/files/delete-file.ts index 0f2934fd7b4..335d2930e10 100644 --- a/apps/sim/lib/copilot/tools/server/files/delete-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/delete-file.ts @@ -25,6 +25,10 @@ interface DeleteFileArgs { interface DeleteFileResult { success: boolean message: string + data?: { + deleted: { id: string; name: string }[] + failed: string[] + } } export const deleteFileServerTool: BaseServerTool = { @@ -102,6 +106,7 @@ export const deleteFileServerTool: BaseServerTool 0, message: parts.join('. '), + data: { deleted: deletable, failed }, } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 109256e23e4..9236438b1e7 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,13 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { - CreateFileFolder, - DeleteFileFolder, - ListFileFolders, - MoveFile, - MoveFileFolder, - RenameFileFolder, -} from '@/lib/copilot/generated/tool-catalog-v1' +import { DeleteFileFolder } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -15,7 +8,9 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' import { + ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, getWorkspaceFileFolder, listWorkspaceFileFolders, @@ -192,7 +187,7 @@ function folderLabel(folder: WorkspaceFileFolderRecord): string { } export const listFileFoldersServerTool: BaseServerTool = { - name: ListFileFolders.id, + name: 'list_file_folders', async execute( params: ListFileFoldersArgs, context?: ServerToolContext @@ -215,7 +210,7 @@ export const listFileFoldersServerTool: BaseServerTool = { - name: CreateFileFolder.id, + name: 'create_file_folder', async execute( params: CreateFileFolderArgs, context?: ServerToolContext @@ -236,22 +231,23 @@ export const createFileFolderServerTool: BaseServerTool 1) { - const resolvedParentId = await findWorkspaceFileFolderIdByPath( + parentId = await ensureWorkspaceFileFolderPath({ workspaceId, - pathSegments.slice(0, -1) - ) - if (!resolvedParentId) { - return { - success: false, - message: `Parent folder not found at files/${pathSegments.slice(0, -1).join('/')}`, - } - } - parentId = resolvedParentId + userId: context.userId, + pathSegments: pathSegments.slice(0, -1), + }) } assertServerToolNotAborted(context) @@ -285,7 +281,7 @@ export const createFileFolderServerTool: BaseServerTool = { - name: RenameFileFolder.id, + name: 'rename_file_folder', async execute( params: RenameFileFolderArgs, context?: ServerToolContext @@ -341,7 +337,7 @@ export const renameFileFolderServerTool: BaseServerTool = { - name: MoveFileFolder.id, + name: 'move_file_folder', async execute( params: MoveFileFolderArgs, context?: ServerToolContext @@ -441,7 +437,7 @@ export const deleteFileFolderServerTool: BaseServerTool 0 || result.deletedItems.files > 0, message: `Deleted ${result.deletedItems.folders} file folder${result.deletedItems.folders === 1 ? '' : 's'} and ${result.deletedItems.files} file${result.deletedItems.files === 1 ? '' : 's'}`, - data: result.deletedItems, + data: { ...result.deletedItems, deletedFolderIds: folderIds }, } } catch (error) { return { success: false, message: toError(error).message } @@ -450,7 +446,7 @@ export const deleteFileFolderServerTool: BaseServerTool = { - name: MoveFile.id, + name: 'move_file', async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index 10bac242eca..a6f9e9f63c8 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { RenameFile } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -31,8 +30,13 @@ interface RenameFileResult { } } +/** + * Removed from the mothership catalog in favor of mv; the executor stays + * registered under its literal name so in-flight checkpoints paused on + * rename_file still resume. Delete after the mv release soaks. + */ export const renameFileServerTool: BaseServerTool = { - name: RenameFile.id, + name: 'rename_file', async execute(params: RenameFileArgs, context?: ServerToolContext): Promise { if (!context?.userId) { throw new Error('Authentication required') diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts new file mode 100644 index 00000000000..8b22ee7d7a4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts @@ -0,0 +1,39 @@ +import { SearchKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' + +type SearchKnowledgeBaseArgs = { + operation: string + args?: Record +} + +type SearchKnowledgeBaseResult = { + success: boolean + message: string + data?: any +} + +const READ_OPERATIONS = new Set(['get', 'query', 'list_tags']) + +/** + * Read-only variant of knowledge_base for info-gathering agents. Copilot + * access control is a per-agent tool allowlist, so read-only access gets its + * own tool name with its own operation contract — enforced here (where + * execution happens) on top of the fail-fast guard in the Go executor. + */ +export const searchKnowledgeBaseServerTool: BaseServerTool< + SearchKnowledgeBaseArgs, + SearchKnowledgeBaseResult +> = { + name: SearchKnowledgeBase.id, + async execute(params: SearchKnowledgeBaseArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!READ_OPERATIONS.has(operation)) { + return { + success: false, + message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's knowledge_base tool`, + } + } + return knowledgeBaseServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 6754920bca4..b5cf76e0e4a 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' import { CreateFile, - CreateFileFolder, DeleteFile, DeleteFileFolder, DownloadToWorkspaceFile, @@ -15,10 +14,6 @@ import { ManageCustomTool, ManageMcpTool, ManageSkill, - MoveFile, - MoveFileFolder, - RenameFile, - RenameFileFolder, UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -49,10 +44,12 @@ import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generat import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' +import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' @@ -118,12 +115,12 @@ const WRITE_ACTIONS: Record = { [WorkspaceFile.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], [editContentServerTool.name]: ['*'], [CreateFile.id]: ['*'], - [RenameFile.id]: ['*'], + rename_file: ['*'], [DeleteFile.id]: ['*'], - [MoveFile.id]: ['*'], - [CreateFileFolder.id]: ['*'], - [RenameFileFolder.id]: ['*'], - [MoveFileFolder.id]: ['*'], + move_file: ['*'], + create_file_folder: ['*'], + rename_file_folder: ['*'], + move_file_folder: ['*'], [DeleteFileFolder.id]: ['*'], [DownloadToWorkspaceFile.id]: ['*'], [GenerateImage.id]: ['generate'], @@ -158,8 +155,10 @@ const baseServerToolRegistry: Record = { [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, + [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool, [enrichmentRunServerTool.name]: enrichmentRunServerTool, [userTableServerTool.name]: userTableServerTool, + [queryUserTableServerTool.name]: queryUserTableServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts new file mode 100644 index 00000000000..e2b07176da5 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts @@ -0,0 +1,51 @@ +import { QueryUserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type QueryUserTableArgs = { + operation: string + args?: Record +} + +type QueryUserTableResult = { + success: boolean + message: string + data?: any +} + +const READ_OPERATIONS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) + +/** + * Read-only variant of user_table for info-gathering agents. Copilot access + * control is a per-agent tool allowlist, so read-only access gets its own tool + * name with its own operation contract — enforced here (where execution + * happens) on top of the fail-fast guard in the Go executor. outputPath is + * rejected because query_rows exports rows to a workspace file through it. + */ +export const queryUserTableServerTool: BaseServerTool = { + name: QueryUserTable.id, + async execute(params: QueryUserTableArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!READ_OPERATIONS.has(operation)) { + return { + success: false, + message: `query_user_table is read-only: operation '${operation}' is not available (allowed: get, get_row, get_schema, query_rows); mutations go through the table agent's user_table tool`, + } + } + if (params?.args && 'outputPath' in params.args) { + return { + success: false, + message: + 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', + } + } + if (params && 'outputPath' in (params as Record)) { + return { + success: false, + message: + 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 9e24963856a..17f25adb1bd 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -452,6 +452,7 @@ export const userTableServerTool: BaseServerTool return { success: deleted.length > 0, message: `Deleted ${deleted.length} table(s)${failed.length > 0 ? `, ${failed.length} not found` : ''}`, + data: { deleted, failed }, } } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts new file mode 100644 index 00000000000..b7504a92a26 --- /dev/null +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getToolDisplayTitle, mvDisplayVerb } from './tool-display' + +describe('mvDisplayVerb', () => { + it('reads a leaf-only change in the same folder as a rename', () => { + expect(mvDisplayVerb('workflows/falling-vacuum', 'workflows/failing-vacuum')).toBe('Renaming') + expect(mvDisplayVerb('files/Reports/a.md', 'files/Reports/b.md')).toBe('Renaming') + expect(mvDisplayVerb('tables/Leads', 'tables/Customers')).toBe('Renaming') + }) + + it('decodes segments so encoded sources compare against plain destinations', () => { + expect(mvDisplayVerb('workflows/My%20Flow', 'workflows/New Flow')).toBe('Renaming') + expect(mvDisplayVerb('files/My%20Docs/a.md', 'files/My Docs/b.md')).toBe('Renaming') + }) + + it('reads parent changes and folder destinations as moves', () => { + expect(mvDisplayVerb('files/a.png', 'files/Images/')).toBe('Moving') + expect(mvDisplayVerb('files/Reports/a.md', 'files/Archive/a.md')).toBe('Moving') + expect(mvDisplayVerb('files/Reports/a.md', 'files/Archive/b.md')).toBe('Moving') + expect(mvDisplayVerb('workflows/My Flow', 'workflows/Archive/')).toBe('Moving') + }) + + it('falls back to Moving when arguments are incomplete', () => { + expect(mvDisplayVerb(undefined, 'files/x.md')).toBe('Moving') + expect(mvDisplayVerb('files/x.md', undefined)).toBe('Moving') + }) +}) + +describe('getToolDisplayTitle for the vfs verbs', () => { + it('uses the derived verb for mv titles', () => { + expect( + getToolDisplayTitle('mv', { + sources: ['workflows/falling-vacuum'], + destination: 'workflows/failing-vacuum', + toolTitle: 'falling-vacuum to failing-vacuum', + }) + ).toBe('Renaming falling-vacuum to failing-vacuum') + expect( + getToolDisplayTitle('mv', { + sources: ['files/a.png', 'files/b.png'], + destination: 'files/Images/', + toolTitle: '2 files to Images', + }) + ).toBe('Moving 2 files to Images') + }) + + it('titles cp and mkdir by intent', () => { + expect(getToolDisplayTitle('cp', { toolTitle: 'My Workflow' })).toBe('Duplicating My Workflow') + expect(getToolDisplayTitle('mkdir', { toolTitle: 'Reports/2026' })).toBe( + 'Creating Reports/2026' + ) + expect(getToolDisplayTitle('cp', {})).toBe('Duplicating workflow') + expect(getToolDisplayTitle('mkdir', {})).toBe('Creating folder') + }) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5ee25a69959..8f6a7f5c282 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -55,6 +55,41 @@ function isWorkflowArtifactPath(path: string, filename: string): boolean { return trimmed.startsWith('workflows/') && trimmed.endsWith(`/${filename}`) } +function decodePathSegment(segment: string): string { + try { + return decodeURIComponent(segment) + } catch { + return segment + } +} + +/** + * Verb for an mv call, derived from its arguments so the row reads as what + * the call actually does: a single source whose parent path matches the + * destination's (only the leaf changes) is a rename; multiple sources, a + * trailing-slash folder destination, or a parent change is a move. Segments + * are decoded so an encoded source compares correctly against a plain-text + * destination leaf. + */ +export function mvDisplayVerb( + source: string | undefined, + destination: string | undefined +): 'Renaming' | 'Moving' { + if (!source || !destination || /\/\s*$/.test(destination)) return 'Moving' + const segments = (path: string) => + path + .trim() + .replace(/^\/+|\/+$/g, '') + .split('/') + .map(decodePathSegment) + const src = segments(source) + const dst = segments(destination) + if (src.length < 2 || dst.length < 2) return 'Moving' + const sameParent = src.slice(0, -1).join('/') === dst.slice(0, -1).join('/') + const leafChanged = src.at(-1) !== dst.at(-1) + return sameParent && leafChanged ? 'Renaming' : 'Moving' +} + function workspaceFileTitle(args: ToolArgs): string { const title = stringArg(args, 'title') if (!title) return '' @@ -74,13 +109,15 @@ function workspaceFileTitle(args: ToolArgs): string { const TOOL_TITLES: Record = { read: 'Reading file', search_library_docs: 'Searching library docs', - user_memory: 'Accessing memory', user_table: 'Managing table', + run_code: 'Running code', + query_user_table: 'Querying table', workspace_file: 'Editing file', edit_content: 'Applying file content', create_workflow: 'Creating workflow', edit_workflow: 'Editing workflow', knowledge_base: 'Managing knowledge base', + search_knowledge_base: 'Searching knowledge base', open_resource: 'Opening resource', generate_image: 'Generating image', generate_video: 'Generating video', @@ -97,6 +134,8 @@ const TOOL_TITLES: Record = { scheduled_task: 'Scheduled Task Agent', agent: 'Tools Agent', research: 'Research Agent', + scout: 'Scout Agent', + search: 'Search Agent', media: 'Media Agent', superagent: 'Executing action', } @@ -130,6 +169,21 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Finding ${target}` : 'Finding files' } + case 'mv': { + const sources = stringArrayArg(args, 'sources') + const verb = + sources.length === 1 ? mvDisplayVerb(sources[0], stringArg(args, 'destination')) : 'Moving' + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `${verb} ${target}` : verb + } + case 'cp': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Duplicating ${target}` : 'Duplicating workflow' + } + case 'mkdir': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Creating ${target}` : 'Creating folder' + } case 'enrichment_run': { const subject = nestedStringArg( args, diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/copilot/vfs/resource-writer.test.ts index f0181d0a396..0acdbbda432 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.test.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.test.ts @@ -281,6 +281,96 @@ describe('resource writer workflow aliases', () => { ) }) + it('auto-creates missing parent folders for plain workspace file creates', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested') + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockResolvedValue({ + id: 'file-report', + name: 'summary.csv', + size: 7, + type: 'text/csv', + url: '/download', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/Reports/2026/summary.csv', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/csv', + }) + + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['Reports', '2026'], + }) + expect(mocks.findWorkspaceFileFolderIdByPath).not.toHaveBeenCalled() + expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('content'), + 'summary.csv', + 'text/csv', + { folderId: 'folder-nested' } + ) + expect(result).toMatchObject({ + id: 'file-report', + vfsPath: 'files/Reports/2026/summary.csv', + mode: 'create', + }) + }) + + it('validates create targets read-only, resolving existing parent folders without creating', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-nested') + mocks.getWorkspaceFileByName.mockResolvedValue(null) + + const validation = await validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/Reports/2026/summary.csv', + mode: 'create', + }, + }) + + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(validation).toMatchObject({ + mode: 'create', + vfsPath: 'files/Reports/2026/summary.csv', + fileName: 'summary.csv', + folderId: 'folder-nested', + }) + }) + + it('accepts create targets with missing parent folders during validation without creating them', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + + const validation = await validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/Reports/2026/summary.csv', + mode: 'create', + }, + }) + + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.getWorkspaceFileByName).not.toHaveBeenCalled() + expect(validation).toMatchObject({ + mode: 'create', + vfsPath: 'files/Reports/2026/summary.csv', + fileName: 'summary.csv', + folderId: null, + }) + }) + it('reports alias path when exact-name alias backing creation conflicts', async () => { mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ kind: 'plan_file', diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 460f6ba36dd..148c0ee0333 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -57,6 +57,7 @@ export type WorkspaceFileWriteValidation = vfsPath: string backingVfsPath?: string fileName: string + /** Null for root targets AND for parent chains that don't exist yet — validation is read-only; missing folders are created at write time. */ folderId: string | null } | { @@ -97,22 +98,41 @@ export function parseWorkspaceFileCreatePath(path: string): { } } +/** + * Resolve a create-mode write target. Pass `createFolders` (the write path) to + * create missing parent folders; without it (the validation path) resolution + * is read-only — a missing parent chain yields `folderId: null`, since the + * folders are created at write time and nothing can conflict there yet. + */ async function resolveCreateTarget( workspaceId: string, - path: string + path: string, + createFolders?: { userId: string } ): Promise { const parsed = parseWorkspaceFileCreatePath(path) - const folderId = - parsed.folderSegments.length > 0 - ? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { - includeReservedSystemFolders: true, - }) - : null - - if (parsed.folderSegments.length > 0 && !folderId) { - throw new Error( - `Directory not yet created: ${displayFolderPath(parsed.folderSegments)}. Create the directory first, then retry the file write.` - ) + let folderId: string | null = null + if (parsed.folderSegments.length > 0) { + if (createFolders) { + folderId = await ensureWorkspaceFileFolderPath({ + workspaceId, + userId: createFolders.userId, + pathSegments: parsed.folderSegments, + }) + if (!folderId) { + throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) + } + } else { + folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { + includeReservedSystemFolders: true, + }) + if (!folderId) { + return { + fileName: parsed.fileName, + folderId: null, + vfsPath: parsed.vfsPath, + } + } + } } const existing = await getWorkspaceFileByName(workspaceId, parsed.fileName, { folderId }) @@ -388,7 +408,9 @@ export async function writeWorkspaceFileByPath(args: { } } - const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path) + const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path, { + userId: args.userId, + }) const uploaded = await uploadWorkspaceFile( args.workspaceId, args.userId, diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts new file mode 100644 index 00000000000..adddcba0c07 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { serializeFileMeta, serializeKBMeta, serializeTableMeta } from './serializers' + +describe('VFS metadata serializers', () => { + it('includes the authoritative file update timestamp', () => { + const metadata = JSON.parse( + serializeFileMeta({ + id: 'file-1', + name: 'notes.md', + contentType: 'text/markdown', + size: 42, + uploadedAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-09T12:34:56.000Z'), + }) + ) + + expect(metadata.updatedAt).toBe('2026-07-09T12:34:56.000Z') + }) + + it('preserves live table and knowledge-base counts', () => { + const table = JSON.parse( + serializeTableMeta({ + id: 'table-1', + name: 'Customers', + schema: { columns: [] }, + rowCount: 137, + maxRows: 10_000, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-09T00:00:00.000Z'), + }) + ) + const knowledgeBase = JSON.parse( + serializeKBMeta({ + id: 'kb-1', + name: 'Handbook', + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + tokenCount: 12_345, + documentCount: 19, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-09T00:00:00.000Z'), + }) + ) + + expect(table.rowCount).toBe(137) + expect(knowledgeBase.documentCount).toBe(19) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 571768bad6a..7bab9f202a7 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,4 +1,3 @@ -import { truncate } from '@sim/utils/string' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/env-flags' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' @@ -291,6 +290,7 @@ export function serializeFileMeta(file: { contentType: string size: number uploadedAt: Date + updatedAt: Date }): string { return JSON.stringify( { @@ -302,6 +302,7 @@ export function serializeFileMeta(file: { contentType: file.contentType, size: file.size, uploadedAt: file.uploadedAt.toISOString(), + updatedAt: file.updatedAt.toISOString(), readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined, note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).', }, @@ -669,7 +670,7 @@ export function serializeCustomTool(tool: { id: tool.id, title: tool.title, schema: tool.schema, - codePreview: truncate(tool.code, 500), + code: tool.code, }, null, 2 @@ -716,7 +717,7 @@ export function serializeSkill(s: { id: s.id, name: s.name, description: s.description, - contentPreview: truncate(s.content, 500), + content: s.content, createdAt: s.createdAt.toISOString(), }, null, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index abfc3ad9508..a76bc739144 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -3,10 +3,12 @@ import { db } from '@sim/db' import { chat as chatTable, copilotChats, + customTools as customToolsTable, document, jobExecutionLogs, knowledgeConnector, mcpServers as mcpServersTable, + skill as skillTable, workflowDeploymentVersion, workflowExecutionLogs, workflowFolder, @@ -16,7 +18,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, desc, eq, isNotNull, isNull, ne, sql } from 'drizzle-orm' +import { and, desc, eq, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { buildWorkspaceContextMd, @@ -108,13 +110,13 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { listCustomTools } from '@/lib/workflows/custom-tools/operations' +import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { loadWorkflowDeploymentSnapshot, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { listSkills } from '@/lib/workflows/skills/operations' +import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' import { assertActiveWorkspaceAccess, @@ -1574,11 +1576,11 @@ export class WorkspaceVFS { rowCount: t.rowCount, })) } catch (err) { - logger.warn('Failed to materialize tables', { + logger.error('Failed to materialize tables; refusing to serve an incomplete VFS', { workspaceId, error: toError(err).message, }) - return [] + throw err } } @@ -1595,6 +1597,7 @@ export class WorkspaceVFS { const files = await listWorkspaceFiles(workspaceId, { folders, includeReservedSystemFiles: true, + throwOnError: true, }) for (const folder of folders) { if ( @@ -1625,6 +1628,7 @@ export class WorkspaceVFS { contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, + updatedAt: file.updatedAt, }) ) } @@ -1710,11 +1714,11 @@ export class WorkspaceVFS { folderPath: f.folderPath ?? null, })) } catch (err) { - logger.warn('Failed to materialize files', { + logger.error('Failed to materialize files; refusing to serve an incomplete VFS', { workspaceId, error: toError(err).message, }) - return [] + throw err } } @@ -1772,6 +1776,11 @@ export class WorkspaceVFS { eq(workflowDeploymentVersion.isActive, true) ) ) + // Match checkNeedsRedeployment/loadDeployedWorkflowState. Historical + // workflows can contain more than one active row, so an unordered + // limit may compare the draft with an older deployment while the UI + // correctly compares against the newest active deployment. + .orderBy(desc(workflowDeploymentVersion.createdAt)) .limit(1) : Promise.resolve([]), db @@ -1827,25 +1836,47 @@ export class WorkspaceVFS { } /** - * Materialize custom tools using the shared listCustomTools function. + * Advertise custom tools in the VFS without eagerly loading their code. + * Paths are registered as lazy so glob/WORKSPACE.md see them, but full + * schema+code is fetched only when read (or a grep whose scope touches them). */ private async materializeCustomTools( workspaceId: string, userId: string ): Promise> { try { - const toolRows = await listCustomTools({ userId, workspaceId }) + // Metadata only — tool code can be large; keep it out of the eager map. + // Visibility matches listCustomTools: workspace tools + legacy user-owned. + const toolRows = await db + .select({ + id: customToolsTable.id, + title: customToolsTable.title, + }) + .from(customToolsTable) + .where( + or( + eq(customToolsTable.workspaceId, workspaceId), + and(isNull(customToolsTable.workspaceId), eq(customToolsTable.userId, userId)) + ) + ) + .orderBy(desc(customToolsTable.createdAt)) for (const tool of toolRows) { const safeName = sanitizeName(tool.title) - const serialized = serializeCustomTool({ - id: tool.id, - title: tool.title, - schema: tool.schema, - code: tool.code, - }) - this.files.set(`custom-tools/${safeName}.json`, serialized) - this.files.set(`agent/custom-tools/${safeName}.json`, serialized) + const toolId = tool.id + const load = async () => { + const full = await getCustomToolById({ toolId, userId, workspaceId }) + if (!full) return null + return serializeCustomTool({ + id: full.id, + title: full.title, + schema: full.schema, + code: full.code, + }) + } + // Legacy alias + canonical agent/ path — each resolves independently on read. + this.registerLazy(`custom-tools/${safeName}.json`, load) + this.registerLazy(`agent/custom-tools/${safeName}.json`, load) } return toolRows.map((t) => ({ id: t.id, name: t.title })) @@ -1944,26 +1975,39 @@ export class WorkspaceVFS { } /** - * Materialize workspace skills using the shared listSkills function. + * 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. */ private async materializeSkills( workspaceId: string ): Promise> { try { - const skillRows = await listSkills({ workspaceId, includeBuiltins: false }) + // Metadata only — skill bodies can be large; keep them out of the eager map. + const skillRows = await db + .select({ + id: skillTable.id, + name: skillTable.name, + description: skillTable.description, + }) + .from(skillTable) + .where(eq(skillTable.workspaceId, workspaceId)) + .orderBy(desc(skillTable.createdAt)) for (const s of skillRows) { const safeName = sanitizeName(s.name) - this.files.set( - `agent/skills/${safeName}.json`, - serializeSkill({ - id: s.id, - name: s.name, - description: s.description, - content: s.content, - createdAt: s.createdAt, + const skillId = s.id + this.registerLazy(`agent/skills/${safeName}.json`, async () => { + const full = await getSkillById({ skillId, workspaceId }) + if (!full) return null + return serializeSkill({ + id: full.id, + name: full.name, + description: full.description, + content: full.content, + createdAt: full.createdAt, }) - ) + }) } return skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })) @@ -2278,6 +2322,7 @@ export class WorkspaceVFS { contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, + updatedAt: file.updatedAt, }) ) } diff --git a/apps/sim/lib/credentials/display-name.test.ts b/apps/sim/lib/credentials/display-name.test.ts new file mode 100644 index 00000000000..36d23dfc8b9 --- /dev/null +++ b/apps/sim/lib/credentials/display-name.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + DISPLAY_NAME_MAX_LENGTH, + defaultCredentialDisplayName, +} from '@/lib/credentials/display-name' + +const NONE: ReadonlySet = new Set() + +describe('defaultCredentialDisplayName', () => { + it("produces {Name}'s {Service} when the user name is known", () => { + expect(defaultCredentialDisplayName('Justin', 'Gmail', NONE)).toBe("Justin's Gmail") + }) + + it('trims surrounding whitespace from the user name', () => { + expect(defaultCredentialDisplayName(' Justin ', 'Gmail', NONE)).toBe("Justin's Gmail") + }) + + it.each([null, undefined, '', ' '])('falls back to My {Service} for user name %j', (name) => { + expect(defaultCredentialDisplayName(name, 'Gmail', NONE)).toBe('My Gmail') + }) + + it('appends " 2" when the base name is taken', () => { + const taken = new Set(["justin's gmail"]) + expect(defaultCredentialDisplayName('Justin', 'Gmail', taken)).toBe("Justin's Gmail 2") + }) + + it('skips taken numbered names and picks the next free slot', () => { + const taken = new Set(["justin's gmail", "justin's gmail 2", "justin's gmail 3"]) + expect(defaultCredentialDisplayName('Justin', 'Gmail', taken)).toBe("Justin's Gmail 4") + }) + + it('compares collisions case-insensitively', () => { + const taken = new Set(["justin's gmail"]) + expect(defaultCredentialDisplayName('JUSTIN', 'Gmail', taken)).toBe("JUSTIN's Gmail 2") + }) + + it('numbers the My {Service} fallback on collision too', () => { + const taken = new Set(['my gmail']) + expect(defaultCredentialDisplayName(null, 'Gmail', taken)).toBe('My Gmail 2') + }) + + it('truncates a long user name so name + suffix + disambiguator fit the max length', () => { + const longName = 'x'.repeat(400) + const result = defaultCredentialDisplayName(longName, 'Gmail', NONE) + + expect(result.endsWith("'s Gmail")).toBe(true) + expect(result.length).toBeLessThanOrEqual(DISPLAY_NAME_MAX_LENGTH) + + const taken = new Set([result.toLowerCase()]) + const numbered = defaultCredentialDisplayName(longName, 'Gmail', taken) + expect(numbered).toBe(`${result} 2`) + expect(numbered.length).toBeLessThanOrEqual(DISPLAY_NAME_MAX_LENGTH) + }) +}) diff --git a/apps/sim/lib/credentials/display-name.ts b/apps/sim/lib/credentials/display-name.ts new file mode 100644 index 00000000000..9b946f31e56 --- /dev/null +++ b/apps/sim/lib/credentials/display-name.ts @@ -0,0 +1,49 @@ +/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */ +export const DISPLAY_NAME_MAX_LENGTH = 255 + +/** + * Reserved tail budget when truncating the username so the auto-numbering + * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}. + */ +const COLLISION_SUFFIX_RESERVATION = 5 + +/** Upper bound for the auto-numbering search — pathological if ever reached. */ +const MAX_COLLISION_INDEX = 10000 + +/** + * Default credential display name. Produces `"{Name}'s {Service}"` when the + * user's name is known, falling back to `"My {Service}"` otherwise. The + * username is truncated so the full string (including any auto-numbering + * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}. + * + * When the base name collides with an existing credential in `takenNames`, + * `" 2"`, `" 3"`, ... are appended until an unused name is found. `takenNames` + * must contain lowercased names; comparison is case-insensitive to match the + * duplicate-detection in the connect modal. + */ +export function defaultCredentialDisplayName( + userName: string | null | undefined, + serviceName: string, + takenNames: ReadonlySet +): string { + const trimmed = userName?.trim() + let base: string + if (trimmed) { + const suffix = `'s ${serviceName}` + const nameBudget = Math.max( + 0, + DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION + ) + const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed + base = `${safeName}${suffix}` + } else { + base = `My ${serviceName}` + } + + if (!takenNames.has(base.toLowerCase())) return base + for (let n = 2; n < MAX_COLLISION_INDEX; n++) { + const candidate = `${base} ${n}` + if (!takenNames.has(candidate.toLowerCase())) return candidate + } + return base +} diff --git a/apps/sim/lib/execution/e2b.test.ts b/apps/sim/lib/execution/e2b.test.ts index 738d1fdfff0..6fae70682da 100644 --- a/apps/sim/lib/execution/e2b.test.ts +++ b/apps/sim/lib/execution/e2b.test.ts @@ -106,3 +106,171 @@ describe('e2b sandbox inputs', () => { expect(mockRunCode).not.toHaveBeenCalled() }) }) + +describe('e2b result marker extraction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockResolvedValue({ + sandboxId: 'sb_1', + files: { write: mockFilesWrite }, + commands: { run: mockCommandsRun }, + runCode: mockRunCode, + kill: mockKill, + }) + mockCommandsRun.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) + }) + + it('parses the result marker from a single stdout entry', async () => { + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: ['before\n', `__SIM_RESULT__=${JSON.stringify({ ok: true })}\n`] }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBeUndefined() + expect(res.result).toEqual({ ok: true }) + expect(res.stdout).toBe('before') + }) + + it('reassembles a marker line split across stream chunks (large single-line result)', async () => { + const payload = 'x'.repeat(50_000) + const markerLine = `__SIM_RESULT__=${JSON.stringify(payload)}\n` + // The kernel splits one long line across several stream messages; each + // chunk is NOT newline-terminated except the last. + const chunks = [ + 'log line\n', + markerLine.slice(0, 20_000), + markerLine.slice(20_000, 40_000), + markerLine.slice(40_000), + ] + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: chunks }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBeUndefined() + expect(res.result).toBe(payload) + expect(res.stdout).toBe('log line') + }) + + it('returns an error instead of a truncated fragment when the marker payload is corrupted', async () => { + // A genuinely broken payload (e.g. the tail chunk was lost entirely). + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: [`__SIM_RESULT__="${'x'.repeat(100)}\n`] }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toMatch(/corrupted in transport/) + expect(res.result).toBeNull() + }) + + it('parses the shell marker and falls back to the raw string for non-JSON payloads', async () => { + mockCommandsRun.mockResolvedValueOnce({ + stdout: `hello\n__SIM_RESULT__=${JSON.stringify([1, 2])}\n`, + stderr: '', + exitCode: 0, + }) + const ok = await executeShellInE2B({ code: 'x', envs: {}, timeoutMs: 1000 }) + expect(ok.error).toBeUndefined() + expect(ok.result).toEqual([1, 2]) + expect(ok.stdout).toBe('hello') + + // Shell markers are user-authored (`echo "__SIM_RESULT__=$STATUS"`), so a + // plain non-JSON value is a string result, never a transport error. + mockCommandsRun.mockResolvedValueOnce({ + stdout: '__SIM_RESULT__=ok\n', + stderr: '', + exitCode: 0, + }) + const plain = await executeShellInE2B({ code: 'x', envs: {}, timeoutMs: 1000 }) + expect(plain.error).toBeUndefined() + expect(plain.result).toBe('ok') + }) + + it('takes the LAST marker line so user-printed marker lines cannot shadow the real result', async () => { + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { + stdout: [ + '__SIM_RESULT__=user-debug-junk\n', + `__SIM_RESULT__=${JSON.stringify({ real: true })}\n`, + ], + }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBeUndefined() + expect(res.result).toEqual({ real: true }) + expect(res.stdout).not.toContain('__SIM_RESULT__') + }) + + it('finds a marker that landed on stderr', async () => { + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { + stdout: ['regular output\n'], + stderr: [`__SIM_RESULT__=${JSON.stringify([1])}\n`], + }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.error).toBeUndefined() + expect(res.result).toEqual([1]) + expect(res.stdout).not.toContain('__SIM_RESULT__') + }) + + it('keeps separate print lines intact (chunks concatenated verbatim, not newline-joined)', async () => { + mockRunCode.mockResolvedValue({ + error: null, + text: '', + logs: { stdout: ['a\n', 'b\n'], stderr: ['warn\n'] }, + results: [], + }) + + const res = await executeInE2B({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + }) + + expect(res.result).toBeNull() + expect(res.stdout).toBe('a\nb\n\nwarn\n') + }) +}) diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index 21c50af09b5..0f9f2e3a553 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -140,6 +140,59 @@ async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise< return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey }) } +/** + * Marker prefix for the serialized code result printed to stdout. Emitters + * (the wrapper builders in the function-execute route) interpolate this + * constant so producer and parser cannot drift. + */ +export const SIM_RESULT_PREFIX = '__SIM_RESULT__=' + +/** + * Extracts the `__SIM_RESULT__=` marker line from stdout and parses its JSON + * payload. Takes the LAST marker line: the wrapper prints its marker after all + * user output, so an earlier user-printed line with the same prefix (debug + * output, a grepped log) never shadows the real result. `parseFailed` means + * the last marker's payload was not valid JSON — `rawPayload` carries it so + * callers whose markers are user-authored (shell) can fall back to the plain + * string, while wrapper-backed callers treat it as transport corruption. + */ +function extractSimResult(stdout: string): { + result: unknown + cleanedStdout: string + parseFailed: boolean + rawPayload?: string +} { + const lines = stdout.split('\n') + let markerIndex = -1 + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].startsWith(SIM_RESULT_PREFIX)) { + markerIndex = i + break + } + } + if (markerIndex === -1) { + return { result: null, cleanedStdout: stdout, parseFailed: false } + } + const rawPayload = lines[markerIndex].slice(SIM_RESULT_PREFIX.length) + let result: unknown = null + let parseFailed = false + try { + result = JSON.parse(rawPayload) + } catch { + parseFailed = true + } + const filteredLines = lines.filter((l) => !l.startsWith(SIM_RESULT_PREFIX)) + if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { + filteredLines.pop() + } + return { result, cleanedStdout: filteredLines.join('\n'), parseFailed, rawPayload } +} + +const SIM_RESULT_CORRUPTED_ERROR = + 'Sandbox result was corrupted in transport (the __SIM_RESULT__ line failed to parse). ' + + "Do not trust or persist this call's output. For large results, write the content to a " + + 'file inside the sandbox and export it via outputs.files[].sandboxPath instead of returning it.' + function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean { const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() const binaryExts = new Set([ @@ -196,8 +249,6 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise l.startsWith(prefix)) - let cleanedStdout = stdout - if (marker) { - const jsonPart = marker.slice(prefix.length) - try { - result = JSON.parse(jsonPart) - } catch { - result = jsonPart - } - const filteredLines = lines.filter((l) => !l.startsWith(prefix)) - if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { - filteredLines.pop() + // Kernel stream entries are chunks, not lines — each already carries its own + // newlines, and one long line can arrive split across several entries. + // Concatenate each stream verbatim: joining chunks with '\n' injected a + // newline at every chunk boundary, which corrupted large single-line + // __SIM_RESULT__ payloads and silently truncated the persisted result. + // Distinct sources (final-expression text, stdout, stderr) still join with + // '\n' so the marker is found no matter which stream carried it. + const streamStdout = (execution.logs?.stdout ?? []).join('') + const streamStderr = (execution.logs?.stderr ?? []).join('') + const combinedOutput = [execution.text, streamStdout, streamStderr].filter(Boolean).join('\n') + + const extraction = extractSimResult(combinedOutput) + const cleanedStdout = extraction.cleanedStdout + + // The wrapper always emits valid single-line JSON, so a marker that fails + // to parse means the payload was mangled in transport — never persist it. + if (extraction.parseFailed) { + logger.error('E2B result marker failed to parse', { + sandboxId, + stdoutEntryCount: execution.logs?.stdout?.length ?? 0, + stdoutLength: streamStdout.length, + }) + return { + result: null, + stdout: cleanedStdout, + error: SIM_RESULT_CORRUPTED_ERROR, + sandboxId, } - cleanedStdout = filteredLines.join('\n') } + const result = extraction.result const images: string[] = [] if (execution.results?.length) { @@ -348,24 +399,13 @@ export async function executeShellInE2B( } } - let parsed: unknown = null - const prefix = '__SIM_RESULT__=' - const lines = stdout.split('\n') - const marker = lines.find((l) => l.startsWith(prefix)) - let cleanedStdout = stdout - if (marker) { - const jsonPart = marker.slice(prefix.length) - try { - parsed = JSON.parse(jsonPart) - } catch { - parsed = jsonPart - } - const filteredLines = lines.filter((l) => !l.startsWith(prefix)) - if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { - filteredLines.pop() - } - cleanedStdout = filteredLines.join('\n') - } + // Shell scripts have no wrapper: any __SIM_RESULT__ line is user-authored + // (e.g. `echo "__SIM_RESULT__=$STATUS"`), so a non-JSON payload is a plain + // string result, not transport corruption. commands.run also accumulates + // output into one string, so the chunk-split corruption cannot occur here. + const extraction = extractSimResult(stdout) + const parsed = extraction.parseFailed ? extraction.rawPayload : extraction.result + const cleanedStdout = extraction.cleanedStdout const exportedFiles: Record = {} for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 86ac2312b2d..c6da814dc83 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -21,7 +21,6 @@ import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' -import { buildUserSkillTool } from '@/lib/mothership/skills' 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' @@ -208,12 +207,11 @@ export async function executeInboxTask(taskId: string): Promise { return { attachments, ...downloaded } } - const [attachmentResult, workspaceContext, integrationTools, userSkillTool, userPermission] = + const [attachmentResult, workspaceContext, integrationTools, userPermission] = await Promise.all([ fetchAttachments(), generateWorkspaceContext(ws.id, userId), buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - buildUserSkillTool(ws.id), getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult @@ -235,7 +233,6 @@ export async function executeInboxTask(taskId: string): Promise { workspaceContext, ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), ...(fileAttachments.length > 0 ? { fileAttachments } : {}), } diff --git a/apps/sim/lib/mothership/skills.test.ts b/apps/sim/lib/mothership/skills.test.ts deleted file mode 100644 index 67f66e146d2..00000000000 --- a/apps/sim/lib/mothership/skills.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { whereMock } = vi.hoisted(() => ({ whereMock: vi.fn() })) - -vi.mock('@sim/db', () => ({ - db: { select: () => ({ from: () => ({ where: whereMock }) }) }, - skill: { workspaceId: 'workspaceId', name: 'name', description: 'description' }, -})) -vi.mock('@sim/logger', () => ({ - createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), -})) -vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) })) - -import { buildUserSkillTool, LOAD_USER_SKILL_TOOL_NAME } from './skills' - -describe('buildUserSkillTool', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns null without a workspace id', async () => { - expect(await buildUserSkillTool('')).toBeNull() - expect(whereMock).not.toHaveBeenCalled() - }) - - it('returns null when the workspace has no user skills', async () => { - whereMock.mockResolvedValue([]) - expect(await buildUserSkillTool('ws-1')).toBeNull() - }) - - it('builds one load_user_skill tool with an enum of workspace skill names', async () => { - whereMock.mockResolvedValue([ - { name: 'posthog-playbook', description: 'PostHog steps' }, - { name: 'brand-voice', description: 'Tone rules' }, - ]) - - const tool = await buildUserSkillTool('ws-1') - - expect(tool?.name).toBe(LOAD_USER_SKILL_TOOL_NAME) - // Must NOT be executeLocally — it dispatches with executor "sim", not the browser client. - expect(tool?.executeLocally).toBeUndefined() - expect(tool?.params).toMatchObject({ mothershipToolKind: 'skill' }) - expect(tool?.input_schema).toMatchObject({ - type: 'object', - properties: { skill_name: { enum: ['posthog-playbook', 'brand-voice'] } }, - required: ['skill_name'], - }) - expect(tool?.description).toContain('posthog-playbook') - expect(tool?.description).toContain('brand-voice') - }) - - it('returns null when the skill query fails', async () => { - whereMock.mockRejectedValue(new Error('db down')) - expect(await buildUserSkillTool('ws-1')).toBeNull() - }) -}) diff --git a/apps/sim/lib/mothership/skills.ts b/apps/sim/lib/mothership/skills.ts deleted file mode 100644 index cdb2b42ef95..00000000000 --- a/apps/sim/lib/mothership/skills.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { db, skill } from '@sim/db' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import type { ToolSchema } from '@/lib/copilot/chat/payload' - -const logger = createLogger('MothershipUserSkills') - -export const LOAD_USER_SKILL_TOOL_NAME = 'load_user_skill' - -/** - * Build the single load_user_skill tool that exposes all workspace - * user-created skills to the mothership and its subagents. - * - * User skills live in the `skill` table (builtins are code-only and are treated - * as defaults, so they are excluded here). The tool is a non-deferred, - * sim-executed request-local tool: when the model calls it, Go forwards the - * call and sim resolves the content via resolveSkillContent (see tools/index.ts). - * It is available to all users — there is no super-admin gate, unlike the curated - * mothership_settings tools. - * - * Embedded copilot/internal skills are not handled here: those are autoloaded - * into each agent's system prompt on the Go side and never sent as loadable. - */ -export async function buildUserSkillTool(workspaceId: string): Promise { - if (!workspaceId) return null - - let rows: { name: string; description: string }[] - try { - rows = await db - .select({ name: skill.name, description: skill.description }) - .from(skill) - .where(eq(skill.workspaceId, workspaceId)) - } catch (error) { - logger.error('Failed to load workspace skills for load_user_skill tool', { error, workspaceId }) - return null - } - - if (rows.length === 0) return null - - const skillNames = rows.map((r) => r.name) - const catalog = rows.map((r) => `- ${r.name}: ${r.description}`).join('\n') - - return { - name: LOAD_USER_SKILL_TOOL_NAME, - description: `Load a user-created skill's full instructions. You MUST call this before following a skill: the list below only tells you which skills exist and when each applies — it is NOT the instructions. To use a skill, call load_user_skill with its exact name and follow the content it returns; never act on a skill's name or description alone. Available skills:\n${catalog}`, - input_schema: { - type: 'object', - properties: { - skill_name: { - type: 'string', - description: 'Exact name of the user skill to load.', - enum: skillNames, - }, - }, - required: ['skill_name'], - additionalProperties: false, - }, - // Do NOT set executeLocally: skill content is resolved on the sim backend - // (DB), so Go must dispatch this with executor "sim". executeLocally maps to - // ClientExecutable, which routes the call to the browser client (no handler) - // and the load hangs. mothershipToolKind 'skill' is enough for sim routing. - params: { - mothershipToolKind: 'skill', - mothershipToolName: LOAD_USER_SKILL_TOOL_NAME, - }, - } -} diff --git a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts index 7c6d066e0dc..a143c5e6dd2 100644 --- a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts @@ -96,6 +96,51 @@ describe('trackChatUpload', () => { ) }) + it('stamps message_id on the UPDATE arm when the birth message is known', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'wf_existing' }]) + + await trackChatUpload( + WORKSPACE_ID, + USER_ID, + CHAT_ID, + S3_KEY, + 'image.png', + 'image/png', + 1024, + 'msg_abc' + ) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ chatId: CHAT_ID, messageId: 'msg_abc' }) + ) + }) + + it('stamps message_id on the fallback INSERT arm and nulls it when omitted', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await trackChatUpload( + WORKSPACE_ID, + USER_ID, + CHAT_ID, + S3_KEY, + 'image.png', + 'image/png', + 1024, + 'msg_abc' + ) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ chatId: CHAT_ID, messageId: 'msg_abc' }) + ) + + // Legacy callers without a message id write an explicit NULL ("birth unknown"). + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'wf_existing' }]) + await trackChatUpload(WORKSPACE_ID, USER_ID, CHAT_ID, S3_KEY, 'image.png', 'image/png', 1024) + expect(dbChainMockFns.set).toHaveBeenLastCalledWith( + expect.objectContaining({ messageId: null }) + ) + }) + it('retries with a suffixed displayName on collision against the chat displayName index', async () => { // 23505 from the partial unique index on (chat_id, display_name) — the case we retry. const displayNameCollision = Object.assign(new Error('duplicate key'), { diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 5a8e47f72b9..6d31fc10487 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -452,6 +452,14 @@ export async function ensureWorkspaceFileFolderPath(params: { }): Promise { if (params.pathSegments.length === 0) return null + // Fast path: the whole chain already exists (the common case for repeated + // writes into known folders) — per-segment indexed lookups instead of + // loading the workspace's entire folder table. + const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments, { + includeReservedSystemFolders: true, + }) + if (existing) return existing + // Load all active folders once and build a lookup keyed by "name|parentId" // so we can resolve existing segments without a per-segment SELECT. const existingFolders = await db diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts new file mode 100644 index 00000000000..6b032412bd9 --- /dev/null +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const chain = { + from: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + } + chain.from.mockReturnValue(chain) + chain.where.mockReturnValue(chain) + return { + chain, + select: vi.fn(() => chain), + } +}) + +vi.mock('@sim/db', () => ({ db: { select: mocks.select } })) + +import { listWorkspaceFiles } from './workspace-file-manager' + +describe('listWorkspaceFiles error handling', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.chain.from.mockReturnValue(mocks.chain) + mocks.chain.where.mockReturnValue(mocks.chain) + mocks.chain.orderBy.mockRejectedValue(new Error('database unavailable')) + mocks.select.mockReturnValue(mocks.chain) + }) + + it('keeps the established best-effort behavior by default', async () => { + await expect(listWorkspaceFiles('workspace-1')).resolves.toEqual([]) + }) + + it('propagates failures when a caller requires an authoritative list', async () => { + await expect(listWorkspaceFiles('workspace-1', { throwOnError: true })).rejects.toThrow( + 'database unavailable' + ) + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 8e091d8d8f2..4f4cf23bca8 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -82,6 +82,8 @@ interface ListWorkspaceFilesOptions { folders?: WorkspaceFileFolderRecord[] hydrateFolderPaths?: boolean includeReservedSystemFiles?: boolean + /** Propagate storage errors when an incomplete list would be unsafe. */ + throwOnError?: boolean } /** @@ -487,14 +489,20 @@ export async function trackChatUpload( s3Key: string, fileName: string, contentType: string, - size: number + size: number, + messageId?: string ): Promise<{ displayName: string }> { for (let n = 1; n <= MAX_CHAT_DISPLAY_NAME_RETRIES; n++) { const candidate = suffixedName(fileName, n) try { const updated = await db .update(workspaceFiles) - .set({ chatId, context: 'mothership', displayName: candidate }) + .set({ + chatId, + messageId: messageId ?? null, + context: 'mothership', + displayName: candidate, + }) .where( and( eq(workspaceFiles.key, s3Key), @@ -520,6 +528,7 @@ export async function trackChatUpload( workspaceId, context: 'mothership', chatId, + messageId: messageId ?? null, originalName: fileName, displayName: candidate, contentType, @@ -690,6 +699,7 @@ export async function listWorkspaceFiles( }) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) + if (options?.throwOnError) throw error return [] } } @@ -921,16 +931,26 @@ export async function updateWorkspaceFileContent( metadata, }) - await db + const updatedRows = await db .update(workspaceFiles) .set({ size: content.length, contentType: nextContentType, updatedAt: new Date() }) .where( and( eq(workspaceFiles.id, fileId), eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace') + eq(workspaceFiles.context, 'workspace'), + // Deletes are soft (deletedAt); without this filter a concurrently + // deleted row still matches and the update reports success against a + // file the user can no longer see. + isNull(workspaceFiles.deletedAt) ) ) + .returning({ id: workspaceFiles.id }) + if (updatedRows.length !== 1) { + throw new Error( + `File record update matched ${updatedRows.length} rows for ${fileId} — the file may have been deleted concurrently` + ) + } if (sizeDiff !== 0) { try { @@ -1016,6 +1036,77 @@ export async function renameWorkspaceFile( } } +/** + * Move and/or rename a workspace file in one atomic row update. Either side + * may be a no-op (same folder = pure rename, same name = pure move); when + * both are unchanged the record is returned untouched. Conflicts at the + * destination throw {@link FileConflictError}. The `renamed`/`moved` flags + * report what actually changed, computed from the same read the update uses. + */ +export async function moveRenameWorkspaceFile(params: { + workspaceId: string + fileId: string + targetFolderId: string | null + newName: string +}): Promise<{ file: WorkspaceFileRecord; renamed: boolean; moved: boolean }> { + const normalizedName = normalizeWorkspaceFileItemName(params.newName.trim(), 'File') + + const fileRecord = await getWorkspaceFile(params.workspaceId, params.fileId) + if (!fileRecord) { + throw new Error('File not found') + } + + const targetFolderId = await assertWorkspaceFileFolderTarget( + params.workspaceId, + params.targetFolderId + ) + const currentFolderId = fileRecord.folderId ?? null + const renamed = fileRecord.name !== normalizedName + const moved = currentFolderId !== targetFolderId + if (!renamed && !moved) { + return { file: fileRecord, renamed, moved } + } + + const exists = await fileExistsInWorkspace(params.workspaceId, normalizedName, targetFolderId) + if (exists) { + throw new FileConflictError(normalizedName) + } + + let updated: { id: string }[] + try { + updated = await db + .update(workspaceFiles) + .set({ originalName: normalizedName, folderId: targetFolderId, updatedAt: new Date() }) + .where( + and( + eq(workspaceFiles.id, params.fileId), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace') + ) + ) + .returning({ id: workspaceFiles.id }) + } catch (error: unknown) { + if (getPostgresErrorCode(error) === '23505') { + throw new FileConflictError(normalizedName) + } + throw error + } + + if (updated.length === 0) { + throw new Error('File not found or could not be moved') + } + + return { + file: { + ...fileRecord, + name: normalizedName, + folderId: targetFolderId, + }, + renamed, + moved, + } +} + /** * Soft delete a workspace file. */ diff --git a/apps/sim/lib/workflows/skills/operations.test.ts b/apps/sim/lib/workflows/skills/operations.test.ts index cd61dad4abf..93d0c552267 100644 --- a/apps/sim/lib/workflows/skills/operations.test.ts +++ b/apps/sim/lib/workflows/skills/operations.test.ts @@ -37,8 +37,8 @@ describe('listSkills includeBuiltins', () => { expect(result.every((s) => s.id.startsWith('builtin-'))).toBe(true) }) - // The mothership skill registry passes includeBuiltins: false so it never sees - // the code-only template skills (it loads workspace skills via load_user_skill). + // The mothership skill inventory passes includeBuiltins: false so it never sees + // the code-only template skills. it('excludes builtin template skills when includeBuiltins is false', async () => { orderByMock.mockResolvedValue([ { id: 'sk-1', name: 'mine', description: 'd', content: 'c', workspaceId: 'ws-1' }, diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 361ef667178..7e0fcfb4c2f 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -36,8 +36,8 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski * real skills do. A workspace skill that shares a built-in's name overrides it. * * Pass `includeBuiltins: false` to return only user-created skills. The - * mothership uses this for the skill registry it sees, since it loads workspace - * skills via load_user_skill and never the code-only templates. + * mothership uses this for the workspace skill inventory it sees, which lists + * only user-created skills and never the code-only templates. */ export async function listSkills(params: { workspaceId: string; includeBuiltins?: boolean }) { const dbRows = await db diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 30379de8031..ce11157ccb7 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -5,6 +5,7 @@ import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, FileConflictError, + moveRenameWorkspaceFile, moveWorkspaceFileItems, renameWorkspaceFile, restoreWorkspaceFile, @@ -307,6 +308,72 @@ export async function performRenameWorkspaceFile( } } +export interface PerformMoveRenameWorkspaceFileParams { + workspaceId: string + userId: string + fileId: string + targetFolderId: string | null + newName: string +} + +export interface PerformMoveRenameWorkspaceFileResult { + success: boolean + error?: string + errorCode?: WorkspaceFilesOrchestrationErrorCode + file?: WorkspaceFileRecord +} + +export async function performMoveRenameWorkspaceFile( + params: PerformMoveRenameWorkspaceFileParams +): Promise { + const { workspaceId, userId, fileId, targetFolderId, newName } = params + + try { + const { file, renamed, moved } = await moveRenameWorkspaceFile({ + workspaceId, + fileId, + targetFolderId, + newName, + }) + logger.info('Moved/renamed workspace file', { workspaceId, fileId, renamed, moved }) + + if (moved) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Moved file "${file.name}"${targetFolderId ? ' to folder' : ' to root'}`, + metadata: { targetFolderId }, + }) + } + if (renamed) { + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Renamed file to "${file.name}"`, + }) + } + + return { success: true, file } + } catch (error) { + logger.error('Failed to move/rename workspace file', { error }) + if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { + return { success: false, error: toError(error).message, errorCode: 'conflict' } + } + if (toError(error).message.includes('not found')) { + return { success: false, error: toError(error).message, errorCode: 'not_found' } + } + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + export async function performRestoreWorkspaceFile( params: PerformRestoreWorkspaceFileParams ): Promise { diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 5e5268bda8c..81c7af23352 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -3,6 +3,8 @@ export { type PerformCreateWorkspaceFileFolderResult, type PerformDeleteWorkspaceFileItemsParams, type PerformDeleteWorkspaceFileItemsResult, + type PerformMoveRenameWorkspaceFileParams, + type PerformMoveRenameWorkspaceFileResult, type PerformMoveWorkspaceFileItemsParams, type PerformMoveWorkspaceFileItemsResult, type PerformRenameWorkspaceFileParams, @@ -15,6 +17,7 @@ export { type PerformUpdateWorkspaceFileFolderResult, performCreateWorkspaceFileFolder, performDeleteWorkspaceFileItems, + performMoveRenameWorkspaceFile, performMoveWorkspaceFileItems, performRenameWorkspaceFile, performRestoreWorkspaceFile, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index dbd766914e1..76d3ba0233e 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -928,7 +928,7 @@ export async function executeTool( const scope = resolveToolScope(params, executionContext) const toolKind: 'skill' | 'custom' | 'mcp' | undefined = - normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill' + normalizedToolId === 'load_skill' ? 'skill' : isCustomTool(normalizedToolId) ? 'custom' @@ -948,7 +948,7 @@ export async function executeTool( }) } - if (normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill') { + if (normalizedToolId === 'load_skill') { const skillName = params.skill_name if (!skillName || !scope.workspaceId) { return { diff --git a/apps/sim/tools/registry.minimal.ts b/apps/sim/tools/registry.minimal.ts index d1b34868ec8..9b9721c94a2 100644 --- a/apps/sim/tools/registry.minimal.ts +++ b/apps/sim/tools/registry.minimal.ts @@ -1,5 +1,75 @@ import { functionExecuteTool } from '@/tools/function' +import { + gmailAddLabelV2Tool, + gmailArchiveV2Tool, + gmailCreateLabelV2Tool, + gmailDeleteDraftV2Tool, + gmailDeleteLabelV2Tool, + gmailDeleteV2Tool, + gmailDraftV2Tool, + gmailEditDraftV2Tool, + gmailGetDraftV2Tool, + gmailGetThreadV2Tool, + gmailListDraftsV2Tool, + gmailListLabelsV2Tool, + gmailListThreadsV2Tool, + gmailMarkReadV2Tool, + gmailMarkUnreadV2Tool, + gmailMoveV2Tool, + gmailReadV2Tool, + gmailRemoveLabelV2Tool, + gmailSearchV2Tool, + gmailSendV2Tool, + gmailTrashThreadV2Tool, + gmailUnarchiveV2Tool, + gmailUntrashThreadV2Tool, + gmailUpdateLabelV2Tool, +} from '@/tools/gmail' import { httpRequestTool } from '@/tools/http' +import { + slackAddReactionTool, + slackArchiveConversationTool, + slackCanvasTool, + slackCreateChannelCanvasTool, + slackCreateConversationTool, + slackDeleteCanvasTool, + slackDeleteMessageTool, + slackDeleteScheduledMessageTool, + slackDownloadTool, + slackEditCanvasTool, + slackEphemeralMessageTool, + slackGetCanvasTool, + slackGetChannelHistoryTool, + slackGetChannelInfoTool, + slackGetMessageTool, + slackGetPermalinkTool, + slackGetThreadRepliesTool, + slackGetThreadTool, + slackGetUserPresenceTool, + slackGetUserTool, + slackInviteToConversationTool, + slackListCanvasesTool, + slackListChannelsTool, + slackListMembersTool, + slackListScheduledMessagesTool, + slackListUsersTool, + slackLookupCanvasSectionsTool, + slackMessageReaderTool, + slackMessageTool, + slackOpenViewTool, + slackPublishViewTool, + slackPushViewTool, + slackRemoveReactionTool, + slackRenameConversationTool, + slackScheduleMessageTool, + slackSetConversationPurposeTool, + slackSetConversationTopicTool, + slackSetStatusTool, + slackSetSuggestedPromptsTool, + slackSetTitleTool, + slackUpdateMessageTool, + slackUpdateViewTool, +} from '@/tools/slack' import type { ToolConfig } from '@/tools/types' /** @@ -8,9 +78,76 @@ import type { ToolConfig } from '@/tools/types' * next.config.ts) so the local dev server never compiles the full ~247-tool * graph (~2,074 modules) that the shared workspace layout otherwise drags into * every route. Only these tools execute in minimal mode; unset the flag for the - * full set. NEVER aliased in production. + * full set. The slack and gmail v2 sets back the `slack` and `gmail_v2` blocks + * kept in `blocks/registry-maps.minimal.ts`. NEVER aliased in production. */ export const tools: Record = { http_request: httpRequestTool, function_execute: functionExecuteTool, + gmail_send_v2: gmailSendV2Tool, + gmail_read_v2: gmailReadV2Tool, + gmail_search_v2: gmailSearchV2Tool, + gmail_draft_v2: gmailDraftV2Tool, + gmail_move_v2: gmailMoveV2Tool, + gmail_mark_read_v2: gmailMarkReadV2Tool, + gmail_mark_unread_v2: gmailMarkUnreadV2Tool, + gmail_archive_v2: gmailArchiveV2Tool, + gmail_unarchive_v2: gmailUnarchiveV2Tool, + gmail_delete_v2: gmailDeleteV2Tool, + gmail_add_label_v2: gmailAddLabelV2Tool, + gmail_remove_label_v2: gmailRemoveLabelV2Tool, + gmail_create_label_v2: gmailCreateLabelV2Tool, + gmail_delete_draft_v2: gmailDeleteDraftV2Tool, + gmail_delete_label_v2: gmailDeleteLabelV2Tool, + gmail_edit_draft_v2: gmailEditDraftV2Tool, + gmail_get_draft_v2: gmailGetDraftV2Tool, + gmail_get_thread_v2: gmailGetThreadV2Tool, + gmail_list_drafts_v2: gmailListDraftsV2Tool, + gmail_list_labels_v2: gmailListLabelsV2Tool, + gmail_list_threads_v2: gmailListThreadsV2Tool, + gmail_trash_thread_v2: gmailTrashThreadV2Tool, + gmail_untrash_thread_v2: gmailUntrashThreadV2Tool, + gmail_update_label_v2: gmailUpdateLabelV2Tool, + slack_message: slackMessageTool, + slack_message_reader: slackMessageReaderTool, + slack_list_channels: slackListChannelsTool, + slack_list_members: slackListMembersTool, + slack_list_users: slackListUsersTool, + slack_get_user: slackGetUserTool, + slack_get_message: slackGetMessageTool, + slack_get_thread: slackGetThreadTool, + slack_get_thread_replies: slackGetThreadRepliesTool, + slack_get_channel_history: slackGetChannelHistoryTool, + slack_get_permalink: slackGetPermalinkTool, + slack_set_status: slackSetStatusTool, + slack_set_title: slackSetTitleTool, + slack_set_suggested_prompts: slackSetSuggestedPromptsTool, + slack_canvas: slackCanvasTool, + slack_download: slackDownloadTool, + slack_ephemeral_message: slackEphemeralMessageTool, + slack_update_message: slackUpdateMessageTool, + slack_delete_message: slackDeleteMessageTool, + slack_add_reaction: slackAddReactionTool, + slack_remove_reaction: slackRemoveReactionTool, + slack_get_channel_info: slackGetChannelInfoTool, + slack_get_user_presence: slackGetUserPresenceTool, + slack_open_view: slackOpenViewTool, + slack_update_view: slackUpdateViewTool, + slack_push_view: slackPushViewTool, + slack_publish_view: slackPublishViewTool, + slack_edit_canvas: slackEditCanvasTool, + slack_create_channel_canvas: slackCreateChannelCanvasTool, + slack_get_canvas: slackGetCanvasTool, + slack_list_canvases: slackListCanvasesTool, + slack_lookup_canvas_sections: slackLookupCanvasSectionsTool, + slack_delete_canvas: slackDeleteCanvasTool, + slack_create_conversation: slackCreateConversationTool, + slack_invite_to_conversation: slackInviteToConversationTool, + slack_schedule_message: slackScheduleMessageTool, + slack_list_scheduled_messages: slackListScheduledMessagesTool, + slack_delete_scheduled_message: slackDeleteScheduledMessageTool, + slack_archive_conversation: slackArchiveConversationTool, + slack_rename_conversation: slackRenameConversationTool, + slack_set_conversation_topic: slackSetConversationTopicTool, + slack_set_conversation_purpose: slackSetConversationPurposeTool, } diff --git a/bun.lock b/bun.lock index 0f9ba9efcf1..5d4c96f206d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/packages/db/migrations/0258_swift_red_wolf.sql b/packages/db/migrations/0258_swift_red_wolf.sql new file mode 100644 index 00000000000..180d5266afa --- /dev/null +++ b/packages/db/migrations/0258_swift_red_wolf.sql @@ -0,0 +1 @@ +ALTER TABLE "workspace_files" ADD COLUMN "message_id" text; \ No newline at end of file diff --git a/packages/db/migrations/0258_tranquil_madame_web.sql b/packages/db/migrations/0258_tranquil_madame_web.sql new file mode 100644 index 00000000000..180d5266afa --- /dev/null +++ b/packages/db/migrations/0258_tranquil_madame_web.sql @@ -0,0 +1 @@ +ALTER TABLE "workspace_files" ADD COLUMN "message_id" text; \ No newline at end of file diff --git a/packages/db/migrations/meta/0258_snapshot.json b/packages/db/migrations/meta/0258_snapshot.json new file mode 100644 index 00000000000..f86e187c733 --- /dev/null +++ b/packages/db/migrations/meta/0258_snapshot.json @@ -0,0 +1,16724 @@ +{ + "id": "80b3d0e9-156b-4db8-aa07-fd1e8548aec1", + "prevId": "c405fc48-c6bb-4786-863f-3d3e686095b5", + "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": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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 b457b88600c..81b802dc07b 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1800,6 +1800,13 @@ "when": 1783531236677, "tag": "0257_majestic_chat", "breakpoints": true + }, + { + "idx": 258, + "version": "7", + "when": 1783551403285, + "tag": "0258_tranquil_madame_web", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 62bf0b5d806..77392d61ac5 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1666,6 +1666,16 @@ export const workspaceFiles = pgTable( }), context: text('context').notNull(), // 'workspace', 'mothership', 'copilot', 'chat', 'knowledge-base', 'profile-pictures', 'general', 'execution' chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'cascade' }), + /** + * Logical id of the copilot message this file was born in (the user message the + * upload was attached to). Plain text with no FK: message ids are only unique per + * chat — the same id legitimately exists in the source chat and every fork of it, + * which is what lets a fork's "copy files at-or-before this message" cut match rows + * in both. NULL means "birth unknown / not tracked": rows predating this column and + * contexts that don't stamp it. Nulled together with chatId when a file is + * materialized to the workspace. + */ + messageId: text('message_id'), originalName: text('original_name').notNull(), /** * Collision-disambiguated name exposed to the copilot VFS as `uploads/`. diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index fedc7d65fec..b9b3d37165c 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -7,7 +7,7 @@ export { CalendarDayCell, type CalendarDayCellProps, } from './calendar/calendar-day-cell' -export { Checkbox } from './checkbox/checkbox' +export { Checkbox, checkboxIconVariants, checkboxVariants } from './checkbox/checkbox' export { Chip, ChipLink, diff --git a/packages/emcn/src/icons/chevron-left.tsx b/packages/emcn/src/icons/chevron-left.tsx new file mode 100644 index 00000000000..f79b8280e7f --- /dev/null +++ b/packages/emcn/src/icons/chevron-left.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * ChevronLeft icon component + * @param props - SVG properties including className, fill, etc. + */ +export function ChevronLeft(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/chevron-right.tsx b/packages/emcn/src/icons/chevron-right.tsx new file mode 100644 index 00000000000..2f2a591ae87 --- /dev/null +++ b/packages/emcn/src/icons/chevron-right.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * ChevronRight icon component + * @param props - SVG properties including className, fill, etc. + */ +export function ChevronRight(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index 1af0459200b..81fba9fa370 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -15,6 +15,8 @@ export { Calendar } from './calendar' export { Card } from './card' export { Check } from './check' export { ChevronDown } from './chevron-down' +export { ChevronLeft } from './chevron-left' +export { ChevronRight } from './chevron-right' export { CircleAlert } from './circle-alert' export { CircleCheck } from './circle-check' export { CircleInfo } from './circle-info' @@ -87,6 +89,7 @@ export { ShieldCheck } from './shield-check' export { Shuffle } from './shuffle' export { Sim } from './sim' export { Slash } from './slash' +export { Split } from './split' export { Square } from './square' export { SquareArrowUpRight } from './square-arrow-up-right' export { Table } from './table' diff --git a/packages/emcn/src/icons/split.tsx b/packages/emcn/src/icons/split.tsx new file mode 100644 index 00000000000..aac39588a5c --- /dev/null +++ b/packages/emcn/src/icons/split.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * Split icon component - one path branching into two + * @param props - SVG properties including className, fill, etc. + */ +export function Split(props: SVGProps) { + return ( + + ) +} diff --git a/packages/security/src/hash.ts b/packages/security/src/hash.ts index 9a9a18b498d..2d9cd96442d 100644 --- a/packages/security/src/hash.ts +++ b/packages/security/src/hash.ts @@ -1,10 +1,17 @@ import { createHash } from 'node:crypto' /** - * Deterministic SHA-256 digest of a UTF-8 string, hex-encoded. Use for - * indexed lookup of sensitive values (e.g. API key hash columns) where the + * Deterministic SHA-256 digest, hex-encoded. Strings hash as UTF-8; binary + * content passes as a Uint8Array/Buffer. Use for indexed lookup of sensitive + * values (e.g. API key hash columns) and content-integrity receipts where the * caller only needs to verify equality without ever reversing the hash. */ -export function sha256Hex(input: string): string { - return createHash('sha256').update(input, 'utf8').digest('hex') +export function sha256Hex(input: string | Uint8Array): string { + const hash = createHash('sha256') + if (typeof input === 'string') { + hash.update(input, 'utf8') + } else { + hash.update(input) + } + return hash.digest('hex') } diff --git a/scripts/sync-tool-catalog.ts b/scripts/sync-tool-catalog.ts index 285743741a6..67f7727f889 100644 --- a/scripts/sync-tool-catalog.ts +++ b/scripts/sync-tool-catalog.ts @@ -91,7 +91,7 @@ function renderRuntimeSchemaModule(catalog: { tools: Record[] } 'parameters' in tool ? JSON.stringify(tool.parameters ?? null, null, 2) : 'undefined' const resultSchema = 'resultSchema' in tool ? JSON.stringify(tool.resultSchema ?? null, null, 2) : 'undefined' - lines.push(` [${id}]: {`) + lines.push(` ${id}: {`) lines.push( ` parameters: ${parameters === 'null' ? 'undefined' : parameters.replace(/\n/g, '\n ')},` )