From b2a485e164a8e404966736f03d5b5a0fab8f0923 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 9 Jun 2026 21:54:27 -0700 Subject: [PATCH 01/10] fix(db): serialize concurrent migrations with a Postgres advisory lock (#4939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(db): serialize concurrent migrations with a Postgres advisory lock Deployments start N app replicas at once, each with a migration sidecar. drizzle migrate() has no cross-process lock, so all N read __drizzle_migrations, all see the same migration pending, and all apply it concurrently — one wins, the losers run the same DDL against already-mutated state and exit 1 (e.g. DROP TABLE "form" -> table does not exist / TaskFailedToStart). Wrap migrate() in a session-level pg_advisory_lock so runners serialize: the winner migrates, the losers block, then re-read and find nothing pending. Session locks auto-release on disconnect, so a crashed runner never wedges the lock. * fix(db): guard pg_advisory_unlock so it cannot mask a successful migration If the explicit unlock throws (e.g. connection drops in the window after migrate() commits), the exception bubbled to the outer catch and exited 1 — falsely reporting a failed migration to the deploy orchestrator. The session lock auto-releases on disconnect anyway, so swallow and log instead. * refactor(db): move unlock-guard rationale to TSDoc helper --- packages/db/scripts/migrate.ts | 44 ++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/db/scripts/migrate.ts b/packages/db/scripts/migrate.ts index 9d967a9db7..ed0af3b1c4 100644 --- a/packages/db/scripts/migrate.ts +++ b/packages/db/scripts/migrate.ts @@ -38,12 +38,34 @@ if (!url) { const client = postgres(url, { max: 1, connect_timeout: 10 }) +/** + * Cross-process migration lock key (a stable, app-wide 64-bit constant). + * + * drizzle's `migrate()` has no built-in lock, so when a deployment starts N app + * replicas at once — each with a migration sidecar — all N read + * `__drizzle_migrations`, all see the same migration pending, and all try to apply + * it concurrently. One wins; the losers run the same DDL against already-mutated + * state and die (e.g. `DROP TABLE "form"` → `table "form" does not exist`, + * exit 1 / TaskFailedToStart). + * + * A session-level `pg_advisory_lock` serializes runners: the first to acquire it + * migrates while the rest block, then each loser acquires the lock, re-reads + * `__drizzle_migrations`, finds nothing pending, and exits cleanly. Session locks + * auto-release if the connection drops, so a crashed runner never wedges the lock. + */ +const MIGRATION_LOCK_KEY = 4_961_002_270n + try { // statement_timeout=0: index builds (esp. CONCURRENTLY on large tables) can run // far longer than the app default; a migration must never be killed mid-build. await client`SET statement_timeout = 0` - await migrate(drizzle(client), { migrationsFolder: './migrations' }) - console.log('Migrations applied successfully.') + await client`SELECT pg_advisory_lock(${MIGRATION_LOCK_KEY})` + try { + await migrate(drizzle(client), { migrationsFolder: './migrations' }) + console.log('Migrations applied successfully.') + } finally { + await releaseMigrationLock() + } } catch (error) { console.error('ERROR: Migration failed.') printMigrationError(error) @@ -52,6 +74,24 @@ try { await client.end() } +/** + * Release the advisory lock without ever failing the process. The session-level + * lock auto-releases when the connection closes, so a thrown unlock — e.g. the + * connection dropped right after `migrate()` committed — must be swallowed. + * Letting it reach the outer `catch` would exit 1 and falsely report a + * successful migration as failed to the deploy orchestrator. + */ +async function releaseMigrationLock(): Promise { + try { + await client`SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})` + } catch (unlockError) { + console.error( + 'WARN: pg_advisory_unlock failed; the session lock will auto-release on disconnect.', + unlockError + ) + } +} + /** * Print every diagnostic field a Postgres driver puts on a thrown error. The default * `error.message` loses the constraint name, affected table/column, PG code, and hint — From 272bad9718c5fed47539e65ec9cc2f8a971316c8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 9 Jun 2026 22:03:03 -0700 Subject: [PATCH 02/10] feat(realtime): preflight schema-compatibility check on startup (#4940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(realtime): preflight schema-compatibility check on startup The socket service authorizes every connection with a full-row query against the workflow table. When a deploy ships a realtime image whose compiled schema is ahead of/behind the live DB (e.g. a column dropped by a migration the image predates), that query fails on every request and silently breaks persistence — yet the process stays up and the shallow /health probe keeps returning 200, so the deploy looks healthy while serving nothing. Run one representative workflow query before listen(): a schema mismatch throws, propagates to the entrypoint, and the task exits non-zero and never goes healthy, so CodeDeploy auto-rolls-back instead of shifting traffic onto broken tasks. Schema-class errors (undefined column/table/function) fail fast; connection-class errors retry with backoff so a cold DB at boot does not flap. Runs once at startup, never on the per-probe LB health check, to avoid a DB blip mass- terminating the fleet (cascading failure). * fix(realtime): unwrap cause for schema codes, drop sleep after final attempt - isSchemaMismatch now walks the error.cause chain — drizzle wraps the driver error, so the SQLSTATE often lives on the inner cause, not the outer throw. Without this a wrapped 42703/42P01 was retried 5x and mis-reported as "database unreachable" instead of failing fast. - No longer sleeps after the final failed attempt (~6-10s of dead wait that undermined the fail-fast contract); sleep now only happens between attempts. - Tests: assert sleep is called exactly 4 times on exhaustion, and add a wrapped-cause fail-fast case. --- apps/realtime/src/database/preflight.test.ts | 106 +++++++++++++++++++ apps/realtime/src/database/preflight.ts | 98 +++++++++++++++++ apps/realtime/src/index.ts | 3 + 3 files changed, 207 insertions(+) create mode 100644 apps/realtime/src/database/preflight.test.ts create mode 100644 apps/realtime/src/database/preflight.ts diff --git a/apps/realtime/src/database/preflight.test.ts b/apps/realtime/src/database/preflight.test.ts new file mode 100644 index 0000000000..f290c2e35b --- /dev/null +++ b/apps/realtime/src/database/preflight.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLimit } = vi.hoisted(() => ({ + mockLimit: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + limit: mockLimit, + }), + }), + }, +})) + +vi.mock('@sim/db/schema', () => ({ + workflow: {}, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@sim/utils/helpers', () => ({ + sleep: vi.fn().mockResolvedValue(undefined), +})) + +import { sleep } from '@sim/utils/helpers' +import { assertSchemaCompatibility } from '@/database/preflight' + +/** Builds a Postgres-shaped error carrying a SQLSTATE `code`, as postgres.js throws. */ +function pgError(code: string): Error & { code: string } { + return Object.assign(new Error(`pg error ${code}`), { code }) +} + +/** Mirrors how drizzle wraps the driver error: the SQLSTATE lives on `cause`, not the outer error. */ +function wrappedPgError(code: string): Error { + return new Error('Failed query', { cause: pgError(code) }) +} + +describe('assertSchemaCompatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves when the representative schema query succeeds', async () => { + mockLimit.mockResolvedValueOnce([]) + + await expect(assertSchemaCompatibility()).resolves.toBeUndefined() + + expect(mockLimit).toHaveBeenCalledTimes(1) + }) + + it('throws immediately on an undefined-column mismatch without retrying', async () => { + mockLimit.mockRejectedValue(pgError('42703')) + + await expect(assertSchemaCompatibility()).rejects.toThrow(/incompatible with the live database/) + + expect(mockLimit).toHaveBeenCalledTimes(1) + expect(sleep).not.toHaveBeenCalled() + }) + + it('throws immediately on an undefined-table mismatch', async () => { + mockLimit.mockRejectedValue(pgError('42P01')) + + await expect(assertSchemaCompatibility()).rejects.toThrow(/incompatible with the live database/) + + expect(mockLimit).toHaveBeenCalledTimes(1) + }) + + it('detects a schema mismatch wrapped in error.cause and fails fast', async () => { + mockLimit.mockRejectedValue(wrappedPgError('42703')) + + await expect(assertSchemaCompatibility()).rejects.toThrow(/incompatible with the live database/) + + expect(mockLimit).toHaveBeenCalledTimes(1) + expect(sleep).not.toHaveBeenCalled() + }) + + it('retries transient connection errors and resolves once reachable', async () => { + mockLimit + .mockRejectedValueOnce(pgError('ECONNREFUSED')) + .mockRejectedValueOnce(pgError('ECONNREFUSED')) + .mockResolvedValueOnce([]) + + await expect(assertSchemaCompatibility()).resolves.toBeUndefined() + + expect(mockLimit).toHaveBeenCalledTimes(3) + expect(sleep).toHaveBeenCalledTimes(2) + }) + + it('throws after exhausting retries when the database stays unreachable', async () => { + mockLimit.mockRejectedValue(pgError('ECONNREFUSED')) + + await expect(assertSchemaCompatibility()).rejects.toThrow(/database unreachable/) + + expect(mockLimit).toHaveBeenCalledTimes(5) + expect(sleep).toHaveBeenCalledTimes(4) + }) +}) diff --git a/apps/realtime/src/database/preflight.ts b/apps/realtime/src/database/preflight.ts new file mode 100644 index 0000000000..44eb2ab154 --- /dev/null +++ b/apps/realtime/src/database/preflight.ts @@ -0,0 +1,98 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' + +const logger = createLogger('SocketPreflight') + +/** + * Maximum attempts for the schema canary when the database is merely unreachable. + * Connection-class failures are retried; schema-class failures fail immediately. + */ +const MAX_CONNECT_ATTEMPTS = 5 + +/** + * Postgres SQLSTATE codes meaning the deployed image's compiled schema disagrees + * with the live database (undefined column, table, or function). These never + * self-heal, so retrying only delays an inevitable startup failure. + */ +const SCHEMA_MISMATCH_CODES = new Set(['42703', '42P01', '42883']) + +/** + * Walks the `cause` chain so a SQLSTATE code is found even when drizzle wraps the + * driver error (the code commonly lives on the inner `cause`, not the outer throw). + */ +function isSchemaMismatch(error: unknown): boolean { + const seen = new Set() + let current: unknown = error + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current) + const code = (current as { code?: unknown }).code + if (typeof code === 'string' && SCHEMA_MISMATCH_CODES.has(code)) { + return true + } + current = (current as { cause?: unknown }).cause + } + return false +} + +/** + * Verifies, before the server accepts traffic, that the deployed image's schema + * is compatible with the live database — throwing if it is not. + * + * Every socket is authorized against the `workflow` table through a full-row + * drizzle projection. If the image's compiled schema is ahead of (or behind) the + * database — e.g. a column dropped by a migration the image predates — that query + * fails on every request and silently breaks persistence, yet the process stays + * up and the shallow `/health` probe keeps returning 200. The fleet looks healthy + * while serving nothing. + * + * Running one representative query at startup turns that latent, per-request + * failure into an immediate startup failure: the throw propagates to the server + * entrypoint, the task exits non-zero and never becomes healthy, and the deploy's + * health gate never flips — so CodeDeploy auto-rolls-back instead of shifting + * traffic onto broken tasks. + * + * Deliberately invoked once at startup and never from the per-probe load-balancer + * health check: a deep dependency check on every probe would let a transient + * database blip mass-terminate the whole fleet (cascading failure). + * + * @throws when the schema is incompatible, or the database stays unreachable + * across {@link MAX_CONNECT_ATTEMPTS} attempts. + */ +export async function assertSchemaCompatibility(): Promise { + let lastError: unknown + + for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { + try { + await db.select().from(workflow).limit(1) + logger.info('Schema-compatibility check passed') + return + } catch (error) { + lastError = error + + if (isSchemaMismatch(error)) { + throw new Error( + `Deployed image is incompatible with the live database schema: ${getErrorMessage(error)}` + ) + } + + if (attempt === MAX_CONNECT_ATTEMPTS) { + break + } + + const delay = backoffWithJitter(attempt, null) + logger.warn( + `Schema-compatibility check could not reach the database (attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}), retrying in ${Math.round(delay)}ms`, + getErrorMessage(error) + ) + await sleep(delay) + } + } + + throw new Error( + `Schema-compatibility check failed after ${MAX_CONNECT_ATTEMPTS} attempts — database unreachable: ${getErrorMessage(lastError)}` + ) +} diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 7232eb36be..e43184c1f0 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -2,6 +2,7 @@ import { createServer } from 'http' import { createLogger } from '@sim/logger' import type { Server as SocketIOServer } from 'socket.io' import { createSocketIOServer, shutdownSocketIOAdapter } from '@/config/socket' +import { assertSchemaCompatibility } from '@/database/preflight' import { env } from '@/env' import { setupAllHandlers } from '@/handlers' import { type AuthenticatedSocket, authenticateSocket } from '@/middleware/auth' @@ -93,6 +94,8 @@ async function main() { setupAllHandlers(socket, roomManager) }) + await assertSchemaCompatibility() + httpServer.listen(PORT, '0.0.0.0', () => { logger.info(`Socket.IO server running on port ${PORT}`) logger.info(`Health check available at: http://localhost:${PORT}/health`) From 29ee6a76623687abc8bfb9b9c148672023522d37 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 9 Jun 2026 22:50:28 -0700 Subject: [PATCH 03/10] fix(secrets): keep readonly secret names legible instead of dimming them (#4942) * fix(secrets): keep readonly secret names legible instead of dimming them Readonly viewers saw the workspace secret name at opacity-50 while the masked value rendered at full opacity, an inconsistent and hostile treatment of content they need to read. Drop the opacity dim on the non-renameable key; non-editability is already conveyed by the read-only field and absent edit affordances. * fix(secrets): use cursor-text on readonly key to hint selectability --- .../secrets/components/secrets-manager/secrets-manager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index b36fe42ba9..e2581174df 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -181,7 +181,7 @@ function WorkspaceVariableRow({ return (
{ if (renamingKey !== envKey) onRenameStart(envKey) From 540e608e0002b1fb88c5c26db99370c1da95c9f5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 10 Jun 2026 09:01:52 -0700 Subject: [PATCH 04/10] improvement(chat-voice): modernize ElevenLabs TTS to Flash v2.5 (#4943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(chat-voice): modernize ElevenLabs TTS to Flash v2.5 - Switch default TTS model from eleven_turbo_v2_5 to eleven_flash_v2_5 (ElevenLabs recommends Flash over Turbo in all cases; ~75ms latency) - Drop deprecated optimize_streaming_latency knob plus legacy use_pvc_as_ivc / enable_ssml_parsing flags - Move output_format to the query string and raise it from mp3_22050_32 to mp3_44100_128 for higher audio quality - Switch apply_text_normalization from off to auto for correct number/date pronunciation * improvement(chat-voice): default to Jessica voice (Flash v2.5-optimized) Replace the legacy Sarah default (EXAVITQu4vr4xnSDxMaL), which has no high-quality eleven_flash_v2_5 base, with Jessica (cgSgspJ2msm6clMCkdW9) — a current premade conversational voice verified against the live account and optimized for Flash v2.5. --- apps/sim/app/api/proxy/tts/stream/route.ts | 9 +++------ apps/sim/app/chat/[identifier]/chat.tsx | 2 +- apps/sim/app/chat/hooks/use-audio-streaming.ts | 2 +- apps/sim/lib/api/contracts/media/tts-stream.ts | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index d8ea97d39c..39d561522a 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -92,7 +92,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return new Response('ElevenLabs service not configured', { status: 503 }) } - const endpoint = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream` + const query = new URLSearchParams({ output_format: 'mp3_44100_128' }) + const endpoint = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream?${query.toString()}` const response = await fetch(endpoint, { method: 'POST', @@ -104,17 +105,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { body: JSON.stringify({ text, model_id: modelId, - optimize_streaming_latency: 4, - output_format: 'mp3_22050_32', // Fastest format voice_settings: { stability: 0.5, similarity_boost: 0.8, style: 0.0, use_speaker_boost: false, }, - enable_ssml_parsing: false, - apply_text_normalization: 'off', - use_pvc_as_ivc: false, + apply_text_normalization: 'auto', }), }) diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index f5678291c8..891727d578 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -44,7 +44,7 @@ interface ChatRequestPayload { } const DEFAULT_VOICE_SETTINGS = { - voiceId: 'EXAVITQu4vr4xnSDxMaL', // Default ElevenLabs voice (Bella) + voiceId: 'cgSgspJ2msm6clMCkdW9', // Default ElevenLabs voice (Jessica) — Flash v2.5-optimized } /** diff --git a/apps/sim/app/chat/hooks/use-audio-streaming.ts b/apps/sim/app/chat/hooks/use-audio-streaming.ts index 6ba5b5d9aa..51db697440 100644 --- a/apps/sim/app/chat/hooks/use-audio-streaming.ts +++ b/apps/sim/app/chat/hooks/use-audio-streaming.ts @@ -79,7 +79,7 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject Date: Wed, 10 Jun 2026 11:04:12 -0700 Subject: [PATCH 05/10] feat(workflows): sim trigger, logs v2 block, toolbar renaming (#4941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workflows): sim trigger, logs v2 block, toolbar renaming * fix(review): bound rule queries, canonical logs params, watched-workflow SQL scoping Code-review fixes: read the canonical workflowIds param in logs_v2 (the serializer deletes the source pair ids), aggregate failure-rate in the DB and switch rule windows to the indexed startedAt column, clamp rule config to the legacy contract bounds, push no_activity watch scoping into SQL before the LIMIT, fix the generated sim icon-map key, normalize docs wording, and drop dead exports. Co-authored-by: Cursor * address comments * fix(review): integer rule rounding, success-gated workflow labels, display module hygiene Second-pass review fixes: round integer rule fields so fractional input never reaches SQL LIMIT, gate workflow-name readiness on a successful non-placeholder load in both editor and preview (errored loads mislabeled valid workflows as deleted), lazily read the variables store in preview rows, move the filter-field JSON preview into the shared display module and unexport its single-consumer helpers, and align >= boundary copy (failure rate, error count, cooldown window) with implementation. Co-authored-by: Cursor * chore: sync lockfile after staging merge Co-authored-by: Cursor * fix(workspace-events): keyset-paginate the no_activity subscription scan A fixed LIMIT 500 with no ORDER BY silently starved subscriptions beyond the cap once the global count exceeded it. The poll now pages by webhook id so every subscription is visited each cycle; pagination bounds memory, not total work. Co-authored-by: Cursor * fix(workspace-events): keyset-paginate the watched-workflow scan The 500-row LIMIT silently and deterministically excluded high-id workflows from no_activity coverage in watch-everything subscriptions on large workspaces. The scan now pages by workflow id, mirroring the subscription scan; per-workflow checks move into a helper so the pagination loop stays flat. Co-authored-by: Cursor * fix(workspace-events): skip no_activity subscriptions on the execution-completion path no_activity is poller-owned and can never fire from a completed execution, but it passed into the rule branch and cost a pointless cooldown point-read per subscription on the hottest path. Early-continue alongside the workflow_deployed guard. Co-authored-by: Cursor * docs(sim-trigger): note failure-based alert conditions evaluate on failed runs Co-authored-by: Cursor * fix(blocks): recategorize Data Enrichment as a core block It's a Sim-native capability (registry enrichments over a managed provider cascade, like Search), not a third-party integration. Moves it to Core Blocks in the toolbar, out of the integrations catalog, and relocates its docs page to blocks/ with the icon-map allowlist keeping the docs card icon. Co-authored-by: Cursor * fix(blocks): recategorize MySQL, PostgreSQL, SFTP, SMTP, SSH as integrations External-system connectors with host/credential auth belong under Integrations, not Core Blocks — consistent with MongoDB, Redis, ClickHouse, and the other datastore integrations. They already carried integrationType and /tools docsLinks; the regenerated docs pages turn those previously-dangling links into real pages, and the blocks join the integrations catalog and icon maps. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- apps/docs/components/icons.tsx | 23 + apps/docs/components/ui/icon-mapping.ts | 12 + .../docs/en/{tools => blocks}/enrichment.mdx | 0 apps/docs/content/docs/en/blocks/logs.mdx | 57 + apps/docs/content/docs/en/blocks/meta.json | 2 + apps/docs/content/docs/en/execution/api.mdx | 281 +- .../content/docs/en/execution/logging.mdx | 4 +- apps/docs/content/docs/en/tools/meta.json | 6 +- apps/docs/content/docs/en/tools/mysql.mdx | 168 + .../docs/content/docs/en/tools/postgresql.mdx | 190 + .../docs/content/docs/en/tools/servicenow.mdx | 1 - apps/docs/content/docs/en/tools/sftp.mdx | 156 + apps/docs/content/docs/en/tools/smtp.mdx | 55 + apps/docs/content/docs/en/tools/ssh.mdx | 382 + apps/docs/content/docs/en/triggers/meta.json | 1 + apps/docs/content/docs/en/triggers/sim.mdx | 85 + apps/sim/app/api/emails/preview/route.ts | 52 - .../logs/by-execution/[executionId]/route.ts | 13 +- .../api/webhooks/trigger/[path]/route.test.ts | 43 + .../app/api/webhooks/trigger/[path]/route.ts | 15 +- .../poll/route.test.ts | 37 +- .../poll/route.ts | 20 +- .../notifications/[notificationId]/route.ts | 287 - .../[notificationId]/test/route.ts | 348 - .../[id]/notifications/constants.ts | 8 - .../workspaces/[id]/notifications/route.ts | 227 - .../[workspaceId]/logs/components/index.ts | 1 - .../slack-channel-selector/index.ts | 1 - .../slack-channel-selector.tsx | 114 - .../components/workflow-selector/index.ts | 1 - .../workflow-selector/workflow-selector.tsx | 126 - .../components/notifications/index.ts | 1 - .../notifications/notifications.tsx | 1250 -- .../logs/components/logs-toolbar/index.ts | 1 - .../components/logs-toolbar/logs-toolbar.tsx | 9 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 23 +- .../components/dropdown/dropdown.tsx | 31 +- .../panel/components/toolbar/toolbar.tsx | 4 +- .../components/workflow-block/index.ts | 2 +- .../workflow-block/workflow-block.tsx | 563 +- .../components/block/block.tsx | 180 +- .../preview-workflow/preview-workflow.tsx | 6 +- .../workspace-notification-delivery.ts | 789 - apps/sim/blocks/blocks/enrichment.ts | 4 +- apps/sim/blocks/blocks/logs.ts | 349 + apps/sim/blocks/blocks/mysql.ts | 2 +- apps/sim/blocks/blocks/postgresql.ts | 2 +- apps/sim/blocks/blocks/sftp.ts | 2 +- apps/sim/blocks/blocks/sim_workspace_event.ts | 40 + apps/sim/blocks/blocks/smtp.ts | 2 +- apps/sim/blocks/blocks/ssh.ts | 2 +- apps/sim/blocks/registry.ts | 5 +- apps/sim/components/emails/index.ts | 2 - .../components/emails/notifications/index.ts | 6 - .../workflow-notification-email.tsx | 163 - apps/sim/components/emails/render.ts | 10 - apps/sim/components/icons.tsx | 23 + apps/sim/hooks/queries/notifications.ts | 158 - apps/sim/lib/api/contracts/index.ts | 1 - apps/sim/lib/api/contracts/notifications.ts | 300 - apps/sim/lib/integrations/icon-mapping.ts | 12 + apps/sim/lib/integrations/integrations.json | 231 +- apps/sim/lib/logs/events.ts | 165 - apps/sim/lib/logs/execution/logger.test.ts | 6 +- apps/sim/lib/logs/execution/logger.ts | 6 +- apps/sim/lib/notifications/alert-rules.ts | 335 - .../lib/notifications/inactivity-polling.ts | 294 - apps/sim/lib/webhooks/processor.ts | 5 +- .../sim/lib/workflows/blocks/block-outputs.ts | 11 +- .../workflows/orchestration/deploy.test.ts | 159 +- .../sim/lib/workflows/orchestration/deploy.ts | 21 + .../lib/workflows/subblocks/display.test.ts | 183 + apps/sim/lib/workflows/subblocks/display.ts | 513 + apps/sim/lib/workflows/subblocks/options.ts | 32 + apps/sim/lib/workflows/triggers/triggers.ts | 2 + apps/sim/lib/workspace-events/constants.ts | 169 + apps/sim/lib/workspace-events/emitter.test.ts | 386 + apps/sim/lib/workspace-events/emitter.ts | 198 + .../lib/workspace-events/no-activity.test.ts | 286 + apps/sim/lib/workspace-events/no-activity.ts | 274 + apps/sim/lib/workspace-events/payload.test.ts | 144 + apps/sim/lib/workspace-events/payload.ts | 112 + apps/sim/lib/workspace-events/rules.test.ts | 209 + apps/sim/lib/workspace-events/rules.ts | 179 + apps/sim/lib/workspace-events/state.ts | 72 + .../workspace-events/subscriptions.test.ts | 75 + .../sim/lib/workspace-events/subscriptions.ts | 158 + apps/sim/lib/workspace-events/types.ts | 64 + apps/sim/lib/workspaces/lifecycle.test.ts | 2 +- apps/sim/lib/workspaces/lifecycle.ts | 9 - apps/sim/tools/logs/get_run_details.ts | 75 + apps/sim/tools/logs/index.ts | 2 + apps/sim/tools/logs/query_runs.ts | 163 + apps/sim/tools/logs/types.ts | 46 + apps/sim/tools/registry.ts | 10 +- apps/sim/triggers/constants.ts | 12 + apps/sim/triggers/registry.ts | 2 + apps/sim/triggers/sim/index.ts | 1 + apps/sim/triggers/sim/workspace-event.test.ts | 255 + apps/sim/triggers/sim/workspace-event.ts | 172 + apps/sim/triggers/types.ts | 6 +- bun.lock | 1 - helm/sim/README.md | 2 +- helm/sim/values.yaml | 6 +- packages/audit/src/types.ts | 6 - .../0231_sim_trigger_workspace_events.sql | 14 + .../db/migrations/meta/0231_snapshot.json | 16266 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 92 +- packages/testing/src/mocks/audit.mock.ts | 4 - packages/testing/src/mocks/database.mock.ts | 4 + packages/testing/src/mocks/schema.mock.ts | 40 +- scripts/generate-docs.ts | 4 +- 113 files changed, 22312 insertions(+), 5836 deletions(-) rename apps/docs/content/docs/en/{tools => blocks}/enrichment.mdx (100%) create mode 100644 apps/docs/content/docs/en/blocks/logs.mdx create mode 100644 apps/docs/content/docs/en/tools/mysql.mdx create mode 100644 apps/docs/content/docs/en/tools/postgresql.mdx create mode 100644 apps/docs/content/docs/en/tools/sftp.mdx create mode 100644 apps/docs/content/docs/en/tools/smtp.mdx create mode 100644 apps/docs/content/docs/en/tools/ssh.mdx create mode 100644 apps/docs/content/docs/en/triggers/sim.mdx rename apps/sim/app/api/{notifications => workspace-events}/poll/route.test.ts (69%) rename apps/sim/app/api/{notifications => workspace-events}/poll/route.ts (75%) delete mode 100644 apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/notifications/constants.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/notifications/route.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/slack-channel-selector/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/slack-channel-selector/slack-channel-selector.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx delete mode 100644 apps/sim/background/workspace-notification-delivery.ts create mode 100644 apps/sim/blocks/blocks/sim_workspace_event.ts delete mode 100644 apps/sim/components/emails/notifications/index.ts delete mode 100644 apps/sim/components/emails/notifications/workflow-notification-email.tsx delete mode 100644 apps/sim/hooks/queries/notifications.ts delete mode 100644 apps/sim/lib/api/contracts/notifications.ts delete mode 100644 apps/sim/lib/logs/events.ts delete mode 100644 apps/sim/lib/notifications/alert-rules.ts delete mode 100644 apps/sim/lib/notifications/inactivity-polling.ts create mode 100644 apps/sim/lib/workflows/subblocks/display.test.ts create mode 100644 apps/sim/lib/workflows/subblocks/display.ts create mode 100644 apps/sim/lib/workflows/subblocks/options.ts create mode 100644 apps/sim/lib/workspace-events/constants.ts create mode 100644 apps/sim/lib/workspace-events/emitter.test.ts create mode 100644 apps/sim/lib/workspace-events/emitter.ts create mode 100644 apps/sim/lib/workspace-events/no-activity.test.ts create mode 100644 apps/sim/lib/workspace-events/no-activity.ts create mode 100644 apps/sim/lib/workspace-events/payload.test.ts create mode 100644 apps/sim/lib/workspace-events/payload.ts create mode 100644 apps/sim/lib/workspace-events/rules.test.ts create mode 100644 apps/sim/lib/workspace-events/rules.ts create mode 100644 apps/sim/lib/workspace-events/state.ts create mode 100644 apps/sim/lib/workspace-events/subscriptions.test.ts create mode 100644 apps/sim/lib/workspace-events/subscriptions.ts create mode 100644 apps/sim/lib/workspace-events/types.ts create mode 100644 apps/sim/tools/logs/get_run_details.ts create mode 100644 apps/sim/tools/logs/query_runs.ts create mode 100644 apps/sim/triggers/sim/index.ts create mode 100644 apps/sim/triggers/sim/workspace-event.test.ts create mode 100644 apps/sim/triggers/sim/workspace-event.ts create mode 100644 packages/db/migrations/0231_sim_trigger_workspace_events.sql create mode 100644 packages/db/migrations/meta/0231_snapshot.json diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 41732af3b2..cdd2ab9401 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -6823,6 +6823,29 @@ export function SixtyfourIcon(props: SVGProps) { ) } +export function SimTriggerIcon(props: SVGProps) { + return ( + + ) +} + export function SimilarwebIcon(props: SVGProps) { return ( = { mistral_parse_v3: MistralIcon, monday: MondayIcon, mongodb: MongoDBIcon, + mysql: MySQLIcon, neo4j: Neo4jIcon, neverbounce: NeverBounceIcon, new_relic: NewRelicIcon, @@ -373,6 +380,7 @@ export const blockTypeToIconMap: Record = { pinecone: PineconeIcon, pipedrive: PipedriveIcon, polymarket: PolymarketIcon, + postgresql: PostgresIcon, posthog: PosthogIcon, profound: ProfoundIcon, prospeo: ProspeoIcon, @@ -402,13 +410,17 @@ export const blockTypeToIconMap: Record = { serper: SerperIcon, servicenow: ServiceNowIcon, ses: SESIcon, + sftp: SftpIcon, sharepoint: MicrosoftSharepointIcon, sharepoint_v2: MicrosoftSharepointIcon, shopify: ShopifyIcon, + sim_workspace_event: SimTriggerIcon, similarweb: SimilarwebIcon, sixtyfour: SixtyfourIcon, slack: SlackIcon, + smtp: SmtpIcon, sqs: SQSIcon, + ssh: SshIcon, stagehand: StagehandIcon, stripe: StripeIcon, sts: STSIcon, diff --git a/apps/docs/content/docs/en/tools/enrichment.mdx b/apps/docs/content/docs/en/blocks/enrichment.mdx similarity index 100% rename from apps/docs/content/docs/en/tools/enrichment.mdx rename to apps/docs/content/docs/en/blocks/enrichment.mdx diff --git a/apps/docs/content/docs/en/blocks/logs.mdx b/apps/docs/content/docs/en/blocks/logs.mdx new file mode 100644 index 0000000000..b0a9a44b71 --- /dev/null +++ b/apps/docs/content/docs/en/blocks/logs.mdx @@ -0,0 +1,57 @@ +--- +title: Logs +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { FAQ } from '@/components/ui/faq' + +The Logs block queries workflow run logs in the current workspace and fetches full details for individual runs — the same data you see on the Logs page, available to your workflows. + +## Operations + + + +

Find runs matching a set of filters. Returns only the matching run IDs, ordered newest-first by default.

+
    +
  • Workflows: select specific workflows, or leave empty for all (comma-separated IDs in advanced mode)
  • +
  • Status: info, error, running, pending, cancelled (empty for all)
  • +
  • Time Range: presets from the past 30 minutes to the past 30 days, or explicit ISO start/end dates in advanced mode
  • +
  • Cost: compare run cost against a credit threshold (e.g. ≥ 10 credits)
  • +
  • Duration: compare run duration against a millisecond threshold
  • +
  • Limit: maximum run IDs to return (default 100, max 200)
  • +
+ + +

Fetch everything about a single run by its run ID:

+
    +
  • runId, workflowId, workflowName
  • +
  • status, trigger, startedAt, durationMs
  • +
  • cost: run cost in credits
  • +
  • traceSpans: the full trace — per-block inputs, outputs, timings, and tool calls
  • +
  • finalOutput: the run's final output
  • +
+
+ + +## Typical Pattern + +Query for the runs you care about, then loop over the returned IDs and fetch details for each: + +1. **Query Logs** with `Status: error` and `Time Range: Past 24 hours` → `` +2. Loop over the IDs and call **Get Run Details** → inspect `` to find the failing block +3. Act on it — post a summary to Slack, file a ticket, or feed the trace to an Agent block for diagnosis + +This pairs naturally with the [Sim trigger](/triggers/sim): the trigger hands you the `runId` that fired the event, and Get Run Details gives you the full trace. + + + The block always operates on the current workspace. Costs are denominated in credits, both for + the cost filter and the cost output. + + + diff --git a/apps/docs/content/docs/en/blocks/meta.json b/apps/docs/content/docs/en/blocks/meta.json index 2e1ad2ccad..5409369e30 100644 --- a/apps/docs/content/docs/en/blocks/meta.json +++ b/apps/docs/content/docs/en/blocks/meta.json @@ -5,11 +5,13 @@ "api", "condition", "credential", + "enrichment", "evaluator", "function", "guardrails", "human-in-the-loop", "knowledge", + "logs", "loop", "parallel", "response", diff --git a/apps/docs/content/docs/en/execution/api.mdx b/apps/docs/content/docs/en/execution/api.mdx index 5e8e2ea07c..e41d975a61 100644 --- a/apps/docs/content/docs/en/execution/api.mdx +++ b/apps/docs/content/docs/en/execution/api.mdx @@ -6,7 +6,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Video } from '@/components/ui/video' -Sim provides a comprehensive external API for querying workflow run logs and setting up webhooks for real-time notifications when workflows complete. +Sim provides a comprehensive external API for querying workflow run logs. To react to workflow runs in real time, use the Sim trigger block to run a workflow on workspace events like execution errors, successes, and deployments. ## Authentication @@ -246,230 +246,13 @@ Retrieve run details including the workflow state snapshot. -## Notifications - -Get real-time notifications when workflow runs complete via webhook, email, or Slack. Notifications are configured at the workspace level from the Logs page. - -### Configuration - -Configure notifications from the Logs page by clicking the menu button and selecting "Configure Notifications". - -**Notification Channels:** -- **Webhook**: Send HTTP POST requests to your endpoint -- **Email**: Receive email notifications with run details -- **Slack**: Post messages to a Slack channel - -**Workflow Selection:** -- Select specific workflows to monitor -- Or choose "All Workflows" to include current and future workflows - -**Filtering Options:** -- `levelFilter`: Log levels to receive (`info`, `error`) -- `triggerFilter`: Trigger types to receive (`api`, `webhook`, `schedule`, `manual`, `chat`) - -**Optional Data:** -- `includeFinalOutput`: Include the workflow's final output -- `includeTraceSpans`: Include detailed trace spans -- `includeRateLimits`: Include rate limit information (sync/async limits and remaining) -- `includeUsageData`: Include billing period usage and limits - -### Alert Rules - -Instead of receiving notifications for every run, configure alert rules to be notified only when issues are detected: - -**Consecutive Failures** -- Alert after X consecutive failed runs (e.g., 3 failures in a row) -- Resets when a run succeeds - -**Failure Rate** -- Alert when failure rate exceeds X% over the last Y hours -- Requires minimum 5 runs in the window -- Only triggers after the full time window has elapsed - -**Latency Threshold** -- Alert when any run takes longer than X seconds -- Useful for catching slow or hanging workflows - -**Latency Spike** -- Alert when a run is X% slower than the average -- Compares against the average duration over the configured time window -- Requires minimum 5 runs to establish baseline - -**Cost Threshold** -- Alert when a single run costs more than $X -- Useful for catching expensive LLM calls - -**No Activity** -- Alert when no runs occur within X hours -- Useful for monitoring scheduled workflows that should run regularly - -**Error Count** -- Alert when error count exceeds X within a time window -- Tracks total errors, not consecutive - -All alert types include a 1-hour cooldown to prevent notification spam. - -### Webhook Configuration - -For webhooks, additional options are available: -- `url`: Your webhook endpoint URL -- `secret`: Optional secret for HMAC signature verification - -### Payload Structure - -When a workflow run completes, Sim sends the following payload (via webhook POST, email, or Slack): - -```json -{ - "id": "evt_123", - "type": "workflow.execution.completed", - "timestamp": 1735925767890, - "data": { - "workflowId": "wf_xyz789", - "executionId": "exec_def456", - "status": "success", - "level": "info", - "trigger": "api", - "startedAt": "2025-01-01T12:34:56.789Z", - "endedAt": "2025-01-01T12:34:57.123Z", - "totalDurationMs": 334, - "cost": { - "total": 0.00234, - "tokens": { - "prompt": 123, - "completion": 456, - "total": 579 - }, - "models": { - "gpt-4o": { - "input": 0.001, - "output": 0.00134, - "total": 0.00234, - "tokens": { - "prompt": 123, - "completion": 456, - "total": 579 - } - } - } - }, - "files": null, - "finalOutput": {...}, // Only if includeFinalOutput=true - "traceSpans": [...], // Only if includeTraceSpans=true - "rateLimits": {...}, // Only if includeRateLimits=true - "usage": {...} // Only if includeUsageData=true - }, - "links": { - "log": "/v1/logs/log_abc123", - "execution": "/v1/logs/executions/exec_def456" - } -} -``` - -### Webhook Headers - -Each webhook request includes these headers (webhook channel only): - -- `sim-event`: Event type (always `workflow.execution.completed`) -- `sim-timestamp`: Unix timestamp in milliseconds -- `sim-delivery-id`: Unique delivery ID for idempotency -- `sim-signature`: HMAC-SHA256 signature for verification (if secret configured) -- `Idempotency-Key`: Same as delivery ID for duplicate detection - -### Signature Verification - -If you configure a webhook secret, verify the signature to ensure the webhook is from Sim: - - - - ```javascript - import crypto from 'crypto'; - - function verifyWebhookSignature(body, signature, secret) { - const [timestampPart, signaturePart] = signature.split(','); - const timestamp = timestampPart.replace('t=', ''); - const expectedSignature = signaturePart.replace('v1=', ''); - - const signatureBase = `${timestamp}.${body}`; - const hmac = crypto.createHmac('sha256', secret); - hmac.update(signatureBase); - const computedSignature = hmac.digest('hex'); - - return computedSignature === expectedSignature; - } - - // In your webhook handler - app.post('/webhook', (req, res) => { - const signature = req.headers['sim-signature']; - const body = JSON.stringify(req.body); - - if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET)) { - return res.status(401).send('Invalid signature'); - } - - // Process the webhook... - }); - ``` - - - ```python - import hmac - import hashlib - import json - - def verify_webhook_signature(body: str, signature: str, secret: str) -> bool: - timestamp_part, signature_part = signature.split(',') - timestamp = timestamp_part.replace('t=', '') - expected_signature = signature_part.replace('v1=', '') - - signature_base = f"{timestamp}.{body}" - computed_signature = hmac.new( - secret.encode(), - signature_base.encode(), - hashlib.sha256 - ).hexdigest() - - return hmac.compare_digest(computed_signature, expected_signature) - - # In your webhook handler - @app.route('/webhook', methods=['POST']) - def webhook(): - signature = request.headers.get('sim-signature') - body = json.dumps(request.json) - - if not verify_webhook_signature(body, signature, os.environ['WEBHOOK_SECRET']): - return 'Invalid signature', 401 - - # Process the webhook... - ``` - - - -### Retry Policy - -Failed webhook deliveries are retried with exponential backoff and jitter: - -- Maximum attempts: 5 -- Retry delays: 5 seconds, 15 seconds, 1 minute, 3 minutes, 10 minutes -- Jitter: Up to 10% additional delay to prevent thundering herd -- Only HTTP 5xx and 429 responses trigger retries -- Deliveries timeout after 30 seconds - - - Webhook deliveries are processed asynchronously and don't affect workflow run performance. - - ## Best Practices 1. **Polling Strategy**: When polling for logs, use cursor-based pagination with `order=asc` and `startDate` to fetch new logs efficiently. -2. **Webhook Security**: Always configure a webhook secret and verify signatures to ensure requests are from Sim. +2. **Privacy**: By default, `finalOutput` and `traceSpans` are excluded from responses. Only enable these if you need the data and understand the privacy implications. -3. **Idempotency**: Use the `Idempotency-Key` header to detect and handle duplicate webhook deliveries. - -4. **Privacy**: By default, `finalOutput` and `traceSpans` are excluded from responses. Only enable these if you need the data and understand the privacy implications. - -5. **Rate Limiting**: Implement exponential backoff when you receive 429 responses. Check the `Retry-After` header for the recommended wait time. +3. **Rate Limiting**: Implement exponential backoff when you receive 429 responses. Check the `Retry-After` header for the recommended wait time. ## Rate Limiting @@ -540,67 +323,11 @@ async function pollLogs() { setInterval(pollLogs, 30000); ``` -## Example: Processing Webhooks - -```javascript -import express from 'express'; -import crypto from 'crypto'; - -const app = express(); -app.use(express.json()); - -app.post('/sim-webhook', (req, res) => { - // Verify signature - const signature = req.headers['sim-signature']; - const body = JSON.stringify(req.body); - - if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET)) { - return res.status(401).send('Invalid signature'); - } - - // Check timestamp to prevent replay attacks - const timestamp = parseInt(req.headers['sim-timestamp']); - const fiveMinutesAgo = Date.now() - (5 * 60 * 1000); - - if (timestamp < fiveMinutesAgo) { - return res.status(401).send('Timestamp too old'); - } - - // Process the webhook - const event = req.body; - - switch (event.type) { - case 'workflow.execution.completed': - const { workflowId, executionId, status, cost } = event.data; - - if (status === 'error') { - console.error(`Workflow ${workflowId} failed: ${executionId}`); - // Handle error... - } else { - console.log(`Workflow ${workflowId} completed: ${executionId}`); - console.log(`Cost: $${cost.total}`); - // Process successful execution... - } - break; - } - - // Return 200 to acknowledge receipt - res.status(200).send('OK'); -}); - -app.listen(3000, () => { - console.log('Webhook server listening on port 3000'); -}); -``` - import { FAQ } from '@/components/ui/faq' Sim Keys in the platform. Workflows with public API access enabled can also be called without authentication." }, - { question: "How does the webhook retry policy work?", answer: "Failed webhook deliveries are retried up to 5 times with exponential backoff: 5 seconds, 15 seconds, 1 minute, 3 minutes, and 10 minutes, plus up to 10% jitter. Only HTTP 5xx and 429 responses trigger retries. Each delivery times out after 30 seconds." }, { question: "What rate limits apply to the Logs API?", answer: "Rate limits use a token bucket algorithm. Free plans get 30 requests/minute with 60 burst capacity, Pro gets 100/200, Team gets 200/400, and Enterprise gets 500/1000. These are separate from workflow run rate limits, which are shown in the response body." }, - { question: "How do I verify that a webhook is from Sim?", answer: "Configure a webhook secret when setting up notifications. Sim signs each delivery with HMAC-SHA256 using the format 't={timestamp},v1={signature}' in the sim-signature header. Compute the HMAC of '{timestamp}.{body}' with your secret and compare it to the signature value." }, - { question: "What alert rules are available for notifications?", answer: "You can configure alerts for consecutive failures, failure rate thresholds, latency thresholds, latency spikes (percentage above average), cost thresholds, no-activity periods, and error counts within a time window. All alert types include a 1-hour cooldown to prevent notification spam." }, - { question: "Can I filter which runs trigger notifications?", answer: "Yes. You can filter notifications by specific workflows (or select all), log level (info or error), and trigger type (api, webhook, schedule, manual, chat). You can also choose whether to include final output, trace spans, rate limits, and usage data in the notification payload." }, + { question: "How do I get notified when a workflow run completes or fails?", answer: "Add the Sim trigger block to a workflow and deploy it. It runs on workspace events like execution successes, errors, deployments, and alert conditions such as latency spikes or cost thresholds — and you can compose any blocks downstream (Slack, email, webhooks, custom logic) to deliver the alert." }, ]} /> diff --git a/apps/docs/content/docs/en/execution/logging.mdx b/apps/docs/content/docs/en/execution/logging.mdx index dbbf50a383..68a0ef17b6 100644 --- a/apps/docs/content/docs/en/execution/logging.mdx +++ b/apps/docs/content/docs/en/execution/logging.mdx @@ -145,7 +145,7 @@ The snapshot provides: - Learn about [Cost Calculation](/execution/costs) to understand workflow pricing - Explore the [External API](/execution/api) for programmatic log access -- Set up [Notifications](/execution/api#notifications) for real-time alerts via webhook, email, or Slack +- Add the Sim trigger block to a workflow to react to execution errors, successes, deployments, and alert conditions in real time import { FAQ } from '@/components/ui/faq' @@ -154,6 +154,6 @@ import { FAQ } from '@/components/ui/faq' { question: "What data is captured in each run log?", answer: "Each log entry includes the run ID, workflow ID, trigger type, start and end timestamps, total duration in milliseconds, cost breakdown (total cost, token counts, and per-model breakdowns), run data with trace spans, final output, and any associated files. The log details sidebar lets you inspect block-level inputs and outputs." }, { question: "Are API keys visible in the logs?", answer: "No. API keys and credentials are automatically redacted in the log input tab for security. You can safely inspect block inputs without exposing sensitive values." }, { question: "What is a workflow snapshot?", answer: "A workflow snapshot is a frozen copy of the workflow's structure (blocks, connections, and configuration) captured at the time of a run. It lets you see the exact state of the workflow when a particular run happened, which is useful for debugging workflows that have been modified since." }, - { question: "Can I access logs programmatically?", answer: "Yes. The External API provides endpoints to query logs with filtering by workflow, time range, trigger type, duration, cost, and model. You can also set up webhook, email, or Slack notifications for real-time alerts when runs complete." }, + { question: "Can I access logs programmatically?", answer: "Yes. The External API provides endpoints to query logs with filtering by workflow, time range, trigger type, duration, cost, and model. To react to runs in real time, use the Sim trigger block to run a workflow on execution events." }, { question: "What does Live mode do on the Logs page?", answer: "Live mode automatically refreshes the Logs page in real-time so new log entries appear as they are recorded, without requiring manual page refreshes. This is useful during deployments or when monitoring active workflows." }, ]} /> \ No newline at end of file diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index c2edcceb16..e0b089a1c5 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -48,7 +48,6 @@ "elevenlabs", "emailbison", "enrich", - "enrichment", "evernote", "exa", "extend", @@ -120,6 +119,7 @@ "mistral_parse", "monday", "mongodb", + "mysql", "neo4j", "neverbounce", "new_relic", @@ -137,6 +137,7 @@ "pinecone", "pipedrive", "polymarket", + "postgresql", "posthog", "profound", "prospeo", @@ -164,12 +165,15 @@ "serper", "servicenow", "ses", + "sftp", "sharepoint", "shopify", "similarweb", "sixtyfour", "slack", + "smtp", "sqs", + "ssh", "stagehand", "stripe", "sts", diff --git a/apps/docs/content/docs/en/tools/mysql.mdx b/apps/docs/content/docs/en/tools/mysql.mdx new file mode 100644 index 0000000000..b5bb354b37 --- /dev/null +++ b/apps/docs/content/docs/en/tools/mysql.mdx @@ -0,0 +1,168 @@ +--- +title: MySQL +description: Connect to MySQL database +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate MySQL into the workflow. Can query, insert, update, delete, and execute raw SQL. + + + +## Tools + +### `mysql_query` + +Execute SELECT query on MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `query` | string | Yes | SQL SELECT query to execute \(e.g., SELECT * FROM users WHERE active = 1\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows returned | + +### `mysql_insert` + +Insert new record into MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to insert into \(e.g., users, orders\) | +| `data` | object | Yes | Data to insert as key-value pairs | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of inserted rows | +| `rowCount` | number | Number of rows inserted | + +### `mysql_update` + +Update existing records in MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to update \(e.g., users, orders\) | +| `data` | object | Yes | Data to update as key-value pairs | +| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of updated rows | +| `rowCount` | number | Number of rows updated | + +### `mysql_delete` + +Delete records from MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to delete from \(e.g., users, orders\) | +| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of deleted rows | +| `rowCount` | number | Number of rows deleted | + +### `mysql_execute` + +Execute raw SQL query on MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `query` | string | Yes | Raw SQL query to execute \(e.g., CREATE TABLE users \(id INT PRIMARY KEY, name VARCHAR\(255\)\)\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows affected | + +### `mysql_introspect` + +Introspect MySQL database schema to retrieve table structures, columns, and relationships + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to \(e.g., my_database\) | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `tables` | array | Array of table schemas with columns, keys, and indexes | +| `databases` | array | List of available databases on the server | + + diff --git a/apps/docs/content/docs/en/tools/postgresql.mdx b/apps/docs/content/docs/en/tools/postgresql.mdx new file mode 100644 index 0000000000..b2099d51ec --- /dev/null +++ b/apps/docs/content/docs/en/tools/postgresql.mdx @@ -0,0 +1,190 @@ +--- +title: PostgreSQL +description: Connect to PostgreSQL database +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate PostgreSQL into the workflow. Can query, insert, update, delete, and execute raw SQL. + + + +## Tools + +### `postgresql_query` + +Execute a SELECT query on PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `query` | string | Yes | SQL SELECT query to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows returned | + +### `postgresql_insert` + +Insert data into PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to insert data into | +| `data` | object | Yes | Data object to insert \(key-value pairs\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Inserted data \(if RETURNING clause used\) | +| `rowCount` | number | Number of rows inserted | + +### `postgresql_update` + +Update data in PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to update data in | +| `data` | object | Yes | Data object with fields to update \(key-value pairs\) | +| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Updated data \(if RETURNING clause used\) | +| `rowCount` | number | Number of rows updated | + +### `postgresql_delete` + +Delete data from PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to delete data from | +| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Deleted data \(if RETURNING clause used\) | +| `rowCount` | number | Number of rows deleted | + +### `postgresql_execute` + +Execute raw SQL query on PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `query` | string | Yes | Raw SQL query to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows affected | + +### `postgresql_introspect` + +Introspect PostgreSQL database schema to retrieve table structures, columns, and relationships + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `schema` | string | No | Schema to introspect \(default: public\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `tables` | array | Array of table schemas with columns, keys, and indexes | +| ↳ `name` | string | Table name | +| ↳ `schema` | string | Schema name \(e.g., public\) | +| ↳ `columns` | array | Table columns | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Data type \(e.g., integer, varchar, timestamp\) | +| ↳ `nullable` | boolean | Whether the column allows NULL values | +| ↳ `default` | string | Default value expression | +| ↳ `isPrimaryKey` | boolean | Whether the column is part of the primary key | +| ↳ `isForeignKey` | boolean | Whether the column is a foreign key | +| ↳ `references` | object | Foreign key reference information | +| ↳ `table` | string | Referenced table name | +| ↳ `column` | string | Referenced column name | +| ↳ `primaryKey` | array | Primary key column names | +| ↳ `foreignKeys` | array | Foreign key constraints | +| ↳ `column` | string | Local column name | +| ↳ `referencesTable` | string | Referenced table name | +| ↳ `referencesColumn` | string | Referenced column name | +| ↳ `indexes` | array | Table indexes | +| ↳ `name` | string | Index name | +| ↳ `columns` | array | Columns included in the index | +| ↳ `unique` | boolean | Whether the index enforces uniqueness | +| `schemas` | array | List of available schemas in the database | + + diff --git a/apps/docs/content/docs/en/tools/servicenow.mdx b/apps/docs/content/docs/en/tools/servicenow.mdx index 398e3fd6db..7337cbf636 100644 --- a/apps/docs/content/docs/en/tools/servicenow.mdx +++ b/apps/docs/content/docs/en/tools/servicenow.mdx @@ -215,7 +215,6 @@ Attach a file to a ServiceNow record | `recordSysId` | string | Yes | sys_id of the record to attach the file to | | `fileName` | string | Yes | Name to give the uploaded file \(e.g., logs.txt\) | | `file` | file | No | File to upload \(UserFile object\) | -| `fileContent` | string | No | Base64-encoded file content \(legacy\) | #### Output diff --git a/apps/docs/content/docs/en/tools/sftp.mdx b/apps/docs/content/docs/en/tools/sftp.mdx new file mode 100644 index 0000000000..b350597fcc --- /dev/null +++ b/apps/docs/content/docs/en/tools/sftp.mdx @@ -0,0 +1,156 @@ +--- +title: SFTP +description: Transfer files via SFTP (SSH File Transfer Protocol) +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers. + + + +## Tools + +### `sftp_upload` + +Upload files to a remote SFTP server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SFTP server hostname or IP address | +| `port` | number | Yes | SFTP server port \(default: 22\) | +| `username` | string | Yes | SFTP username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `remotePath` | string | Yes | Destination directory on the remote server | +| `files` | file[] | No | Files to upload | +| `fileContent` | string | No | Direct file content to upload \(for text files\) | +| `fileName` | string | No | File name when using direct content | +| `overwrite` | boolean | No | Whether to overwrite existing files \(default: true\) | +| `permissions` | string | No | File permissions \(e.g., 0644\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the upload was successful | +| `uploadedFiles` | json | Array of uploaded file details \(name, remotePath, size\) | +| `message` | string | Operation status message | + +### `sftp_download` + +Download a file from a remote SFTP server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SFTP server hostname or IP address | +| `port` | number | Yes | SFTP server port \(default: 22\) | +| `username` | string | Yes | SFTP username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `remotePath` | string | Yes | Path to the file on the remote server | +| `encoding` | string | No | Output encoding: utf-8 for text, base64 for binary \(default: utf-8\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the download was successful | +| `file` | file | Downloaded file stored in execution files | +| `fileName` | string | Name of the downloaded file | +| `content` | string | File content \(text or base64 encoded\) | +| `size` | number | File size in bytes | +| `encoding` | string | Content encoding \(utf-8 or base64\) | +| `message` | string | Operation status message | + +### `sftp_list` + +List files and directories on a remote SFTP server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SFTP server hostname or IP address | +| `port` | number | Yes | SFTP server port \(default: 22\) | +| `username` | string | Yes | SFTP username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `remotePath` | string | Yes | Directory path on the remote server | +| `detailed` | boolean | No | Include detailed file information \(size, permissions, modified date\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the operation was successful | +| `path` | string | Directory path that was listed | +| `entries` | json | Array of directory entries with name, type, size, permissions, modifiedAt | +| `count` | number | Number of entries in the directory | +| `message` | string | Operation status message | + +### `sftp_delete` + +Delete a file or directory on a remote SFTP server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SFTP server hostname or IP address | +| `port` | number | Yes | SFTP server port \(default: 22\) | +| `username` | string | Yes | SFTP username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `remotePath` | string | Yes | Path to the file or directory to delete | +| `recursive` | boolean | No | Delete directories recursively | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the deletion was successful | +| `deletedPath` | string | Path that was deleted | +| `message` | string | Operation status message | + +### `sftp_mkdir` + +Create a directory on a remote SFTP server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SFTP server hostname or IP address | +| `port` | number | Yes | SFTP server port \(default: 22\) | +| `username` | string | Yes | SFTP username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `remotePath` | string | Yes | Path for the new directory | +| `recursive` | boolean | No | Create parent directories if they do not exist | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the directory was created successfully | +| `createdPath` | string | Path of the created directory | +| `message` | string | Operation status message | + + diff --git a/apps/docs/content/docs/en/tools/smtp.mdx b/apps/docs/content/docs/en/tools/smtp.mdx new file mode 100644 index 0000000000..ab57c300c5 --- /dev/null +++ b/apps/docs/content/docs/en/tools/smtp.mdx @@ -0,0 +1,55 @@ +--- +title: SMTP +description: Send emails via any SMTP mail server +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Send emails using any SMTP server (Gmail, Outlook, custom servers, etc.). Configure SMTP connection settings and send emails with full control over content, recipients, and attachments. + + + +## Tools + +### `smtp_send_mail` + +Send emails via SMTP server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `smtpHost` | string | Yes | SMTP server hostname \(e.g., smtp.gmail.com\) | +| `smtpPort` | number | Yes | SMTP server port \(587 for TLS, 465 for SSL\) | +| `smtpUsername` | string | Yes | SMTP authentication username | +| `smtpPassword` | string | Yes | SMTP authentication password | +| `smtpSecure` | string | Yes | Security protocol \(TLS, SSL, or None\) | +| `from` | string | Yes | Sender email address | +| `to` | string | Yes | Recipient email address | +| `subject` | string | Yes | Email subject | +| `body` | string | Yes | Email body content | +| `contentType` | string | No | Content type \(text or html\) | +| `fromName` | string | No | Display name for sender | +| `cc` | string | No | CC recipients \(comma-separated\) | +| `bcc` | string | No | BCC recipients \(comma-separated\) | +| `replyTo` | string | No | Reply-to email address | +| `attachments` | file[] | No | Files to attach to the email | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the email was sent successfully | +| `messageId` | string | Message ID from SMTP server | +| `to` | string | Recipient email address | +| `subject` | string | Email subject | +| `error` | string | Error message if sending failed | + + diff --git a/apps/docs/content/docs/en/tools/ssh.mdx b/apps/docs/content/docs/en/tools/ssh.mdx new file mode 100644 index 0000000000..6fc4a66bb5 --- /dev/null +++ b/apps/docs/content/docs/en/tools/ssh.mdx @@ -0,0 +1,382 @@ +--- +title: SSH +description: Connect to remote servers via SSH +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Execute commands, transfer files, and manage remote servers via SSH. Supports password and private key authentication for secure server access. + + + +## Tools + +### `ssh_execute_command` + +Execute a shell command on a remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `command` | string | Yes | Shell command to execute on the remote server | +| `workingDirectory` | string | No | Working directory for command execution | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stdout` | string | Standard output from command | +| `stderr` | string | Standard error output | +| `exitCode` | number | Command exit code | +| `success` | boolean | Whether command succeeded \(exit code 0\) | +| `message` | string | Operation status message | + +### `ssh_execute_script` + +Upload and execute a multi-line script on a remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `script` | string | Yes | Script content to execute \(bash, python, etc.\) | +| `interpreter` | string | No | Script interpreter \(default: /bin/bash\) | +| `workingDirectory` | string | No | Working directory for script execution | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stdout` | string | Standard output from script | +| `stderr` | string | Standard error output | +| `exitCode` | number | Script exit code | +| `success` | boolean | Whether script succeeded \(exit code 0\) | +| `scriptPath` | string | Temporary path where script was uploaded | +| `message` | string | Operation status message | + +### `ssh_check_command_exists` + +Check if a command/program exists on the remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `commandName` | string | Yes | Command name to check \(e.g., docker, git, python3\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commandExists` | boolean | Whether the command exists | +| `commandPath` | string | Full path to the command \(if found\) | +| `version` | string | Command version output \(if applicable\) | +| `message` | string | Operation status message | + +### `ssh_upload_file` + +Upload a file to a remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `fileContent` | string | Yes | File content to upload \(base64 encoded for binary files\) | +| `fileName` | string | Yes | Name of the file being uploaded | +| `remotePath` | string | Yes | Destination path on the remote server | +| `permissions` | string | No | File permissions \(e.g., 0644\) | +| `overwrite` | boolean | No | Whether to overwrite existing files \(default: true\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `uploaded` | boolean | Whether the file was uploaded successfully | +| `remotePath` | string | Final path on the remote server | +| `size` | number | File size in bytes | +| `message` | string | Operation status message | + +### `ssh_download_file` + +Download a file from a remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `remotePath` | string | Yes | Path of the file on the remote server | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `downloaded` | boolean | Whether the file was downloaded successfully | +| `file` | file | Downloaded file stored in execution files | +| `fileContent` | string | File content \(base64 encoded for binary files\) | +| `fileName` | string | Name of the downloaded file | +| `remotePath` | string | Source path on the remote server | +| `size` | number | File size in bytes | +| `message` | string | Operation status message | + +### `ssh_list_directory` + +List files and directories in a remote directory + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `path` | string | Yes | Remote directory path to list | +| `detailed` | boolean | No | Include file details \(size, permissions, modified date\) | +| `recursive` | boolean | No | List subdirectories recursively \(default: false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `entries` | array | Array of file and directory entries | +| ↳ `name` | string | File or directory name | +| ↳ `type` | string | Entry type \(file, directory, symlink\) | +| ↳ `size` | number | File size in bytes | +| ↳ `permissions` | string | File permissions | +| ↳ `modified` | string | Last modified timestamp | +| `totalFiles` | number | Total number of files | +| `totalDirectories` | number | Total number of directories | +| `message` | string | Operation status message | + +### `ssh_check_file_exists` + +Check if a file or directory exists on the remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `path` | string | Yes | Remote file or directory path to check | +| `type` | string | No | Expected type: file, directory, or any \(default: any\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `exists` | boolean | Whether the path exists | +| `type` | string | Type of path \(file, directory, symlink, not_found\) | +| `size` | number | File size if it is a file | +| `permissions` | string | File permissions \(e.g., 0755\) | +| `modified` | string | Last modified timestamp | +| `message` | string | Operation status message | + +### `ssh_create_directory` + +Create a directory on the remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `path` | string | Yes | Directory path to create | +| `recursive` | boolean | No | Create parent directories if they do not exist \(default: true\) | +| `permissions` | string | No | Directory permissions \(default: 0755\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `created` | boolean | Whether the directory was created successfully | +| `remotePath` | string | Created directory path | +| `alreadyExists` | boolean | Whether the directory already existed | +| `message` | string | Operation status message | + +### `ssh_delete_file` + +Delete a file or directory from the remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `path` | string | Yes | Path to delete | +| `recursive` | boolean | No | Recursively delete directories \(default: false\) | +| `force` | boolean | No | Force deletion without confirmation \(default: false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the path was deleted successfully | +| `remotePath` | string | Deleted path | +| `message` | string | Operation status message | + +### `ssh_move_rename` + +Move or rename a file or directory on the remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `sourcePath` | string | Yes | Current path of the file or directory | +| `destinationPath` | string | Yes | New path for the file or directory | +| `overwrite` | boolean | No | Overwrite destination if it exists \(default: false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `moved` | boolean | Whether the operation was successful | +| `sourcePath` | string | Original path | +| `destinationPath` | string | New path | +| `message` | string | Operation status message | + +### `ssh_get_system_info` + +Retrieve system information from the remote SSH server + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `hostname` | string | Server hostname | +| `os` | string | Operating system \(e.g., Linux, Darwin\) | +| `architecture` | string | CPU architecture \(e.g., x64, arm64\) | +| `uptime` | number | System uptime in seconds | +| `memory` | json | Memory information \(total, free, used\) | +| `diskSpace` | json | Disk space information \(total, free, used\) | +| `message` | string | Operation status message | + +### `ssh_read_file_content` + +Read the contents of a remote file + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `path` | string | Yes | Remote file path to read | +| `encoding` | string | No | File encoding \(default: utf-8\) | +| `maxSize` | number | No | Maximum file size to read in MB \(default: 10\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | File content as string | +| `size` | number | File size in bytes | +| `lines` | number | Number of lines in file | +| `remotePath` | string | Remote file path | +| `message` | string | Operation status message | + +### `ssh_write_file_content` + +Write or append content to a remote file + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | SSH server hostname or IP address | +| `port` | number | Yes | SSH server port \(default: 22\) | +| `username` | string | Yes | SSH username | +| `password` | string | No | Password for authentication \(if not using private key\) | +| `privateKey` | string | No | Private key for authentication \(OpenSSH format\) | +| `passphrase` | string | No | Passphrase for encrypted private key | +| `path` | string | Yes | Remote file path to write to | +| `content` | string | Yes | Content to write to the file | +| `mode` | string | No | Write mode: overwrite, append, or create \(default: overwrite\) | +| `permissions` | string | No | File permissions \(e.g., 0644\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `written` | boolean | Whether the file was written successfully | +| `remotePath` | string | File path | +| `size` | number | Final file size in bytes | +| `message` | string | Operation status message | + + diff --git a/apps/docs/content/docs/en/triggers/meta.json b/apps/docs/content/docs/en/triggers/meta.json index 10a41e72ef..115514f497 100644 --- a/apps/docs/content/docs/en/triggers/meta.json +++ b/apps/docs/content/docs/en/triggers/meta.json @@ -5,6 +5,7 @@ "schedule", "webhook", "rss", + "sim", "airtable", "ashby", "attio", diff --git a/apps/docs/content/docs/en/triggers/sim.mdx b/apps/docs/content/docs/en/triggers/sim.mdx new file mode 100644 index 0000000000..c46b54ed5a --- /dev/null +++ b/apps/docs/content/docs/en/triggers/sim.mdx @@ -0,0 +1,85 @@ +--- +title: Sim +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { FAQ } from '@/components/ui/faq' + +The Sim trigger runs a workflow when events happen in your workspace: another workflow's run fails or succeeds, a workflow is deployed, or an alert condition like a latency spike or cost threshold is met. Use it to build side-effect workflows — alerting, escalation, auto-remediation — composed from any blocks (Slack, email, webhooks, custom logic). + +## Events + +Pick one event per Sim trigger block: + +**Plain events** — fire on every occurrence: + +
    +
  • Execution Error: a watched workflow's run failed
  • +
  • Execution Success: a watched workflow's run completed successfully
  • +
  • Workflow Deployed: a watched workflow was deployed (including redeploys and version rollbacks)
  • +
+ +**Alert conditions** — evaluated as runs complete (failure-based conditions evaluate on failed runs), with a cooldown so they fire at most once per cooldown window: + +
    +
  • Consecutive Failures: the last N runs all failed
  • +
  • Failure Rate: failure rate meets or exceeds a percentage over a time window (minimum 5 runs)
  • +
  • Latency Threshold: a run took longer than a fixed duration
  • +
  • Latency Spike: a run was a configurable percentage slower than the recent average (minimum 5 runs)
  • +
  • Cost Threshold: a run cost more than a credit threshold
  • +
  • Error Count: N or more errors occurred within a time window
  • +
  • No Activity: a watched workflow had no runs for a configurable number of hours
  • +
+ +## Workflow Scope + +By default the trigger watches every workflow in the workspace. Select specific workflows to narrow it. The workflow containing the trigger is always excluded — it never receives events about itself. + +## Outputs + +All events include `event`, `timestamp`, `workflowId`, and `workflowName` (the source workflow). Additional fields depend on the event type: + + + +
    +
  • runId: the run that completed
  • +
  • durationMs: run duration in milliseconds
  • +
  • cost: run cost in credits
  • +
  • finalOutput: the run's final output (truncated when large)
  • +
+
+ +

Run-backed conditions nest the run that tripped them under triggeringRun:

+
    +
  • triggeringRun.runId, triggeringRun.durationMs, triggeringRun.cost, triggeringRun.finalOutput
  • +
+

No Activity has no triggering run, so it carries only the base fields.

+
+ +
    +
  • version: the deployment version number that was activated
  • +
+
+
+ +## Behavior + +
    +
  • The workflow containing the Sim trigger must be deployed for events to fire.
  • +
  • Runs started by a Sim trigger never emit workspace events, so side-effect workflows cannot chain or loop.
  • +
  • Alert conditions fire at most once per cooldown window (one hour, or the inactivity window for No Activity).
  • +
  • Event delivery is fire-and-forget: side-effect runs are billed like any other run and are subject to workspace rate limits.
  • +
+ + + Trigger configuration is snapshotted at deploy time. After changing the event type, workflow + scope, or thresholds, redeploy the workflow for the changes to take effect. + + + diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 7459448fb9..32af972407 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -13,7 +13,6 @@ import { renderPlanWelcomeEmail, renderUsageThresholdEmail, renderWelcomeEmail, - renderWorkflowNotificationEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' import { emailPreviewQuerySchema } from '@/lib/api/contracts/common' @@ -94,51 +93,6 @@ const emailTemplates = { billingPortalUrl: 'https://sim.ai/settings/billing', failureReason: 'Card declined', }), - - // Notification emails - 'workflow-notification-success': () => - renderWorkflowNotificationEmail({ - workflowName: 'Customer Onboarding Flow', - status: 'success', - trigger: 'api', - duration: '2.3s', - cost: '$0.0042', - logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123', - }), - 'workflow-notification-error': () => - renderWorkflowNotificationEmail({ - workflowName: 'Customer Onboarding Flow', - status: 'error', - trigger: 'webhook', - duration: '1.1s', - cost: '$0.0021', - logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123', - }), - 'workflow-notification-alert': () => - renderWorkflowNotificationEmail({ - workflowName: 'Customer Onboarding Flow', - status: 'error', - trigger: 'schedule', - duration: '45.2s', - cost: '$0.0156', - logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123', - alertReason: '3 consecutive failures detected', - }), - 'workflow-notification-full': () => - renderWorkflowNotificationEmail({ - workflowName: 'Data Processing Pipeline', - status: 'success', - trigger: 'api', - duration: '12.5s', - cost: '$0.0234', - logUrl: 'https://sim.ai/workspace/ws_123/logs?executionId=exec_abc123', - finalOutput: { processed: 150, skipped: 3, status: 'completed' }, - rateLimits: { - sync: { requestsPerMinute: 60, remaining: 45 }, - async: { requestsPerMinute: 120, remaining: 98 }, - }, - usageData: { currentPeriodCost: 12.45, limit: 50, percentUsed: 24.9 }, - }), } as const type EmailTemplate = keyof typeof emailTemplates @@ -169,12 +123,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 'credit-purchase', 'payment-failed', ], - Notifications: [ - 'workflow-notification-success', - 'workflow-notification-error', - 'workflow-notification-alert', - 'workflow-notification-full', - ], } const categoryHtml = Object.entries(categories) diff --git a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts index 172a77506c..bab5309245 100644 --- a/apps/sim/app/api/logs/by-execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/by-execution/[executionId]/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getLogByExecutionIdContract } from '@/lib/api/contracts/logs' import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' @@ -10,9 +10,12 @@ const logger = createLogger('LogDetailsByExecutionAPI') export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { error: authResult.error || 'Authentication required' }, + { status: 401 } + ) } const parsed = await parseRequest(getLogByExecutionIdContract, request, context) @@ -22,7 +25,7 @@ export const GET = withRouteHandler( const { workspaceId } = parsed.data.query const data = await fetchLogDetail({ - userId: session.user.id, + userId: authResult.userId, workspaceId, lookupColumn: 'executionId', lookupValue: executionId, diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 83d3b81b14..29850b5fe2 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -487,6 +487,49 @@ describe('Webhook Trigger API Route', () => { expect(text).toMatch(/not found/i) }) + describe('Internal trigger providers', () => { + it.each(['sim', 'table'])( + 'rejects HTTP deliveries to %s trigger paths with 404', + async (provider) => { + testData.webhooks.push({ + id: `${provider}-webhook-id`, + provider, + path: 'internal-path', + isActive: true, + providerConfig: { eventType: 'execution_error' }, + workflowId: 'test-workflow-id', + }) + + const req = createMockRequest('POST', { event: 'execution_error', forged: true }) + const params = Promise.resolve({ path: 'internal-path' }) + + const response = await POST(req as any, { params }) + + expect(response.status).toBe(404) + expect(queueWebhookExecutionMock).not.toHaveBeenCalled() + } + ) + + it('does not affect normal provider paths', async () => { + testData.webhooks.push({ + id: 'generic-webhook-id', + provider: 'generic', + path: 'normal-path', + isActive: true, + providerConfig: { requireAuth: false }, + workflowId: 'test-workflow-id', + }) + + const req = createMockRequest('POST', { event: 'test' }) + const params = Promise.resolve({ path: 'normal-path' }) + + const response = await POST(req as any, { params }) + + expect(response.status).toBe(200) + expect(queueWebhookExecutionMock).toHaveBeenCalledOnce() + }) + }) + describe('Generic Webhook Authentication', () => { it('passes correlation-bearing request context into webhook queueing', async () => { testData.webhooks.push({ diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 73e71b1f3d..166fddffcf 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -18,6 +18,7 @@ import { verifyProviderAuth, } from '@/lib/webhooks/processor' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' +import { isInternalTriggerProvider } from '@/triggers/constants' const logger = createLogger('WebhookTriggerAPI') @@ -89,7 +90,19 @@ async function handleWebhookPost( } // Find all webhooks for this path (supports credential set fan-out where multiple webhooks share a path) - const webhooksForPath = await findAllWebhooksForPath({ requestId, path }) + const allWebhooksForPath = await findAllWebhooksForPath({ requestId, path }) + + // Internal trigger providers (sim, table) are fired in-process, never over + // HTTP. Their rows still register a path, so reject deliveries here to keep + // forged events out. + const webhooksForPath = allWebhooksForPath.filter( + ({ webhook: foundWebhook }) => !isInternalTriggerProvider(foundWebhook.provider) + ) + + if (allWebhooksForPath.length > 0 && webhooksForPath.length === 0) { + logger.warn(`[${requestId}] Rejected HTTP delivery to internal trigger path: ${path}`) + return new NextResponse('Not Found', { status: 404 }) + } if (webhooksForPath.length === 0) { const verificationResponse = await handlePreLookupWebhookVerification( diff --git a/apps/sim/app/api/notifications/poll/route.test.ts b/apps/sim/app/api/workspace-events/poll/route.test.ts similarity index 69% rename from apps/sim/app/api/notifications/poll/route.test.ts rename to apps/sim/app/api/workspace-events/poll/route.test.ts index fd41807c47..a577d54b4f 100644 --- a/apps/sim/app/api/notifications/poll/route.test.ts +++ b/apps/sim/app/api/workspace-events/poll/route.test.ts @@ -1,14 +1,16 @@ /** - * Tests for the inactivity-alert polling cron route. + * Tests for the workspace-events no-activity polling cron route. * * @vitest-environment node */ import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockVerifyCronAuth, mockPollInactivityAlerts } = vi.hoisted(() => ({ +const { mockVerifyCronAuth, mockPollNoActivityEvents } = vi.hoisted(() => ({ mockVerifyCronAuth: vi.fn().mockReturnValue(null), - mockPollInactivityAlerts: vi.fn().mockResolvedValue({ checked: 0, delivered: 0 }), + mockPollNoActivityEvents: vi + .fn() + .mockResolvedValue({ subscriptions: 0, checked: 0, fired: 0, skipped: 0 }), })) vi.mock('@/lib/auth/internal', () => ({ @@ -17,25 +19,30 @@ vi.mock('@/lib/auth/internal', () => ({ vi.mock('@/lib/core/config/redis', () => redisConfigMock) -vi.mock('@/lib/notifications/inactivity-polling', () => ({ - pollInactivityAlerts: mockPollInactivityAlerts, +vi.mock('@/lib/workspace-events/no-activity', () => ({ + pollNoActivityEvents: mockPollNoActivityEvents, })) import { GET } from './route' function createRequest() { - return createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/notifications/poll') + return createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/workspace-events/poll') } const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) -describe('inactivity alert polling route (fire-and-forget)', () => { +describe('workspace events polling route (fire-and-forget)', () => { beforeEach(() => { vi.clearAllMocks() redisConfigMockFns.mockAcquireLock.mockResolvedValue(true) redisConfigMockFns.mockReleaseLock.mockResolvedValue(true) mockVerifyCronAuth.mockReturnValue(null) - mockPollInactivityAlerts.mockResolvedValue({ checked: 0, delivered: 0 }) + mockPollNoActivityEvents.mockResolvedValue({ + subscriptions: 0, + checked: 0, + fired: 0, + skipped: 0, + }) }) it('returns the auth error when cron auth fails', async () => { @@ -44,7 +51,7 @@ describe('inactivity alert polling route (fire-and-forget)', () => { const response = await GET(createRequest()) expect(response.status).toBe(401) - expect(mockPollInactivityAlerts).not.toHaveBeenCalled() + expect(mockPollNoActivityEvents).not.toHaveBeenCalled() }) it('acknowledges with 202 and polls in the background after acquiring the lock', async () => { @@ -54,15 +61,15 @@ describe('inactivity alert polling route (fire-and-forget)', () => { const data = await response.json() expect(data).toMatchObject({ status: 'started' }) expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledWith( - 'inactivity-alert-polling-lock', + 'workspace-events-no-activity-poll-lock', expect.any(String), expect.any(Number) ) await flushMicrotasks() - expect(mockPollInactivityAlerts).toHaveBeenCalledTimes(1) + expect(mockPollNoActivityEvents).toHaveBeenCalledTimes(1) expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith( - 'inactivity-alert-polling-lock', + 'workspace-events-no-activity-poll-lock', expect.any(String) ) }) @@ -75,18 +82,18 @@ describe('inactivity alert polling route (fire-and-forget)', () => { expect(response.status).toBe(202) const data = await response.json() expect(data).toMatchObject({ status: 'skip' }) - expect(mockPollInactivityAlerts).not.toHaveBeenCalled() + expect(mockPollNoActivityEvents).not.toHaveBeenCalled() }) it('releases the lock even when polling throws', async () => { - mockPollInactivityAlerts.mockRejectedValueOnce(new Error('poll failed')) + mockPollNoActivityEvents.mockRejectedValueOnce(new Error('poll failed')) const response = await GET(createRequest()) expect(response.status).toBe(202) await flushMicrotasks() expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith( - 'inactivity-alert-polling-lock', + 'workspace-events-no-activity-poll-lock', expect.any(String) ) }) diff --git a/apps/sim/app/api/notifications/poll/route.ts b/apps/sim/app/api/workspace-events/poll/route.ts similarity index 75% rename from apps/sim/app/api/notifications/poll/route.ts rename to apps/sim/app/api/workspace-events/poll/route.ts index d81707cb19..6408eb0bd6 100644 --- a/apps/sim/app/api/notifications/poll/route.ts +++ b/apps/sim/app/api/workspace-events/poll/route.ts @@ -8,25 +8,25 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { runDetached } from '@/lib/core/utils/background' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { pollInactivityAlerts } from '@/lib/notifications/inactivity-polling' +import { pollNoActivityEvents } from '@/lib/workspace-events/no-activity' -const logger = createLogger('InactivityAlertPoll') +const logger = createLogger('WorkspaceEventsPoll') export const maxDuration = 120 -const LOCK_KEY = 'inactivity-alert-polling-lock' +const LOCK_KEY = 'workspace-events-no-activity-poll-lock' const LOCK_TTL_SECONDS = 120 export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateShortId() - logger.info(`Inactivity alert polling triggered (${requestId})`) + logger.info(`Workspace events no-activity polling triggered (${requestId})`) const queryValidation = noInputSchema.safeParse( Object.fromEntries(request.nextUrl.searchParams.entries()) ) if (!queryValidation.success) return validationErrorResponse(queryValidation.error) try { - const authError = verifyCronAuth(request, 'Inactivity alert polling') + const authError = verifyCronAuth(request, 'Workspace events polling') if (authError) { return authError } @@ -45,9 +45,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - runDetached('inactivity-alert-polling', async () => { + runDetached('workspace-events-no-activity-polling', async () => { try { - await pollInactivityAlerts() + await pollNoActivityEvents() } finally { await releaseLock(LOCK_KEY, requestId).catch(() => {}) } @@ -56,18 +56,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json( { success: true, - message: 'Inactivity alert polling started', + message: 'Workspace events polling started', requestId, status: 'started', }, { status: 202 } ) } catch (error) { - logger.error(`Error during inactivity alert polling (${requestId}):`, error) + logger.error(`Error during workspace events polling (${requestId}):`, error) return NextResponse.json( { success: false, - message: 'Inactivity alert polling failed', + message: 'Workspace events polling failed', error: getErrorMessage(error, 'Unknown error'), requestId, }, diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts deleted file mode 100644 index 3de7e2f26c..0000000000 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { workflow, workspaceNotificationSubscription } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { updateNotificationServerContract } from '@/lib/api/contracts/notifications' -import { parseRequest, validationErrorResponse } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { encryptSecret } from '@/lib/core/security/encryption' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceNotificationAPI') - -type RouteParams = { params: Promise<{ id: string; notificationId: string }> } - -async function checkWorkspaceWriteAccess( - userId: string, - workspaceId: string -): Promise<{ hasAccess: boolean; permission: string | null }> { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - const hasAccess = permission === 'write' || permission === 'admin' - return { hasAccess, permission } -} - -async function getSubscription(notificationId: string, workspaceId: string) { - const [subscription] = await db - .select() - .from(workspaceNotificationSubscription) - .where( - and( - eq(workspaceNotificationSubscription.id, notificationId), - eq(workspaceNotificationSubscription.workspaceId, workspaceId) - ) - ) - .limit(1) - return subscription -} - -export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workspaceId, notificationId } = await params - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - - if (!permission) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const subscription = await getSubscription(notificationId, workspaceId) - - if (!subscription) { - return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) - } - - return NextResponse.json({ - data: { - id: subscription.id, - notificationType: subscription.notificationType, - workflowIds: subscription.workflowIds, - allWorkflows: subscription.allWorkflows, - levelFilter: subscription.levelFilter, - triggerFilter: subscription.triggerFilter, - includeFinalOutput: subscription.includeFinalOutput, - includeTraceSpans: subscription.includeTraceSpans, - includeRateLimits: subscription.includeRateLimits, - includeUsageData: subscription.includeUsageData, - webhookConfig: subscription.webhookConfig, - emailRecipients: subscription.emailRecipients, - slackConfig: subscription.slackConfig, - alertConfig: subscription.alertConfig, - active: subscription.active, - createdAt: subscription.createdAt, - updatedAt: subscription.updatedAt, - }, - }) - } catch (error) { - logger.error('Error fetching notification', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) - -export const PUT = withRouteHandler(async (request: NextRequest, context: RouteParams) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workspaceId, notificationId } = await context.params - const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) - - if (!hasAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const existingSubscription = await getSubscription(notificationId, workspaceId) - - if (!existingSubscription) { - return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) - } - - const parsed = await parseRequest(updateNotificationServerContract, request, context, { - validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request'), - }) - if (!parsed.success) return parsed.response - const data = parsed.data.body - - if (data.workflowIds && data.workflowIds.length > 0) { - const workflowsInWorkspace = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), inArray(workflow.id, data.workflowIds))) - - const validIds = new Set(workflowsInWorkspace.map((w) => w.id)) - const invalidIds = data.workflowIds.filter((id) => !validIds.has(id)) - - if (invalidIds.length > 0) { - return NextResponse.json( - { error: 'Some workflow IDs do not belong to this workspace', invalidIds }, - { status: 400 } - ) - } - } - - const updateData: Record = { updatedAt: new Date() } - - if (data.workflowIds !== undefined) updateData.workflowIds = data.workflowIds - if (data.allWorkflows !== undefined) updateData.allWorkflows = data.allWorkflows - if (data.levelFilter !== undefined) updateData.levelFilter = data.levelFilter - if (data.triggerFilter !== undefined) updateData.triggerFilter = data.triggerFilter - if (data.includeFinalOutput !== undefined) - updateData.includeFinalOutput = data.includeFinalOutput - if (data.includeTraceSpans !== undefined) updateData.includeTraceSpans = data.includeTraceSpans - if (data.includeRateLimits !== undefined) updateData.includeRateLimits = data.includeRateLimits - if (data.includeUsageData !== undefined) updateData.includeUsageData = data.includeUsageData - if (data.alertConfig !== undefined) updateData.alertConfig = data.alertConfig - if (data.emailRecipients !== undefined) updateData.emailRecipients = data.emailRecipients - if (data.slackConfig !== undefined) updateData.slackConfig = data.slackConfig - if (data.active !== undefined) updateData.active = data.active - - // Handle webhookConfig with secret encryption - if (data.webhookConfig !== undefined) { - let webhookConfig = data.webhookConfig - if (webhookConfig?.secret) { - const { encrypted } = await encryptSecret(webhookConfig.secret) - webhookConfig = { ...webhookConfig, secret: encrypted } - } - updateData.webhookConfig = webhookConfig - } - - const [subscription] = await db - .update(workspaceNotificationSubscription) - .set(updateData) - .where(eq(workspaceNotificationSubscription.id, notificationId)) - .returning() - - logger.info('Updated notification subscription', { - workspaceId, - subscriptionId: subscription.id, - }) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.NOTIFICATION_UPDATED, - resourceType: AuditResourceType.NOTIFICATION, - resourceId: notificationId, - resourceName: subscription.notificationType, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Updated ${subscription.notificationType} notification subscription`, - metadata: { - notificationType: subscription.notificationType, - updatedFields: Object.keys(data).filter( - (k) => (data as Record)[k] !== undefined - ), - ...(data.active !== undefined && { active: data.active }), - ...(data.alertConfig !== undefined && { alertRule: data.alertConfig?.rule ?? null }), - }, - request, - }) - - return NextResponse.json({ - data: { - id: subscription.id, - notificationType: subscription.notificationType, - workflowIds: subscription.workflowIds, - allWorkflows: subscription.allWorkflows, - levelFilter: subscription.levelFilter, - triggerFilter: subscription.triggerFilter, - includeFinalOutput: subscription.includeFinalOutput, - includeTraceSpans: subscription.includeTraceSpans, - includeRateLimits: subscription.includeRateLimits, - includeUsageData: subscription.includeUsageData, - webhookConfig: subscription.webhookConfig, - emailRecipients: subscription.emailRecipients, - slackConfig: subscription.slackConfig, - alertConfig: subscription.alertConfig, - active: subscription.active, - createdAt: subscription.createdAt, - updatedAt: subscription.updatedAt, - }, - }) - } catch (error) { - logger.error('Error updating notification', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) - -export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workspaceId, notificationId } = await params - const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) - - if (!hasAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const deleted = await db - .delete(workspaceNotificationSubscription) - .where( - and( - eq(workspaceNotificationSubscription.id, notificationId), - eq(workspaceNotificationSubscription.workspaceId, workspaceId) - ) - ) - .returning({ - id: workspaceNotificationSubscription.id, - notificationType: workspaceNotificationSubscription.notificationType, - }) - - if (deleted.length === 0) { - return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) - } - - const deletedSubscription = deleted[0] - - logger.info('Deleted notification subscription', { - workspaceId, - subscriptionId: notificationId, - }) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.NOTIFICATION_DELETED, - resourceType: AuditResourceType.NOTIFICATION, - resourceId: notificationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: deletedSubscription.notificationType, - description: `Deleted ${deletedSubscription.notificationType} notification subscription`, - metadata: { - notificationType: deletedSubscription.notificationType, - }, - request, - }) - - captureServerEvent( - session.user.id, - 'notification_channel_deleted', - { - notification_id: notificationId, - notification_type: deletedSubscription.notificationType, - workspace_id: workspaceId, - }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting notification', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts deleted file mode 100644 index 97e6942808..0000000000 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { db } from '@sim/db' -import { account, workspaceNotificationSubscription } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { hmacSha256Hex } from '@sim/security/hmac' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { - type EmailRateLimitsData, - type EmailUsageData, - renderWorkflowNotificationEmail, -} from '@/components/emails' -import { notificationParamsSchema } from '@/lib/api/contracts/notifications' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { decryptSecret } from '@/lib/core/security/encryption' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendEmail } from '@/lib/messaging/email/mailer' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceNotificationTestAPI') - -type RouteParams = { params: Promise<{ id: string; notificationId: string }> } - -interface WebhookConfig { - url: string - secret?: string -} - -interface SlackConfig { - channelId: string - channelName: string - accountId: string -} - -function generateSignature(secret: string, timestamp: number, body: string): string { - const signatureBase = `${timestamp}.${body}` - return hmacSha256Hex(signatureBase, secret) -} - -function buildTestPayload(subscription: typeof workspaceNotificationSubscription.$inferSelect) { - const timestamp = Date.now() - const eventId = `evt_test_${generateId()}` - const executionId = `exec_test_${generateId()}` - - const payload: Record = { - id: eventId, - type: 'workflow.execution.completed', - timestamp, - data: { - workflowId: 'test-workflow-id', - workflowName: 'Test Workflow', - executionId, - status: 'success', - level: 'info', - trigger: 'manual', - startedAt: new Date(timestamp - 5000).toISOString(), - endedAt: new Date(timestamp).toISOString(), - totalDurationMs: 5000, - cost: { - total: 0.00123, - tokens: { input: 100, output: 50, total: 150 }, - }, - }, - links: { - log: `/workspace/logs`, - }, - } - - const data = payload.data as Record - - if (subscription.includeFinalOutput) { - data.finalOutput = { message: 'This is a test notification', test: true } - } - - if (subscription.includeRateLimits) { - data.rateLimits = { - sync: { - requestsPerMinute: 150, - remaining: 45, - resetAt: new Date(timestamp + 60000).toISOString(), - }, - async: { - requestsPerMinute: 1000, - remaining: 50, - resetAt: new Date(timestamp + 60000).toISOString(), - }, - } - } - - if (subscription.includeUsageData) { - data.usage = { currentPeriodCost: 2.45, limit: 20, percentUsed: 12.25, isExceeded: false } - } - - if (subscription.includeTraceSpans && subscription.notificationType === 'webhook') { - data.traceSpans = [ - { - name: 'test-block', - startTime: timestamp, - endTime: timestamp + 150, - duration: 150, - status: 'success', - blockId: 'block_test_1', - blockType: 'agent', - blockName: 'Test Agent', - children: [], - }, - ] - } - - return { payload, timestamp } -} - -async function testWebhook(subscription: typeof workspaceNotificationSubscription.$inferSelect) { - const webhookConfig = subscription.webhookConfig as WebhookConfig | null - if (!webhookConfig?.url) { - return { success: false, error: 'No webhook URL configured' } - } - - const { payload, timestamp } = buildTestPayload(subscription) - const body = JSON.stringify(payload) - const deliveryId = `delivery_test_${generateId()}` - - const headers: Record = { - 'Content-Type': 'application/json', - 'sim-event': 'workflow.execution.completed', - 'sim-timestamp': timestamp.toString(), - 'sim-delivery-id': deliveryId, - 'Idempotency-Key': deliveryId, - } - - if (webhookConfig.secret) { - const { decrypted } = await decryptSecret(webhookConfig.secret) - const signature = generateSignature(decrypted, timestamp, body) - headers['sim-signature'] = `t=${timestamp},v1=${signature}` - } - - try { - const response = await secureFetchWithValidation( - webhookConfig.url, - { - method: 'POST', - headers, - body, - timeout: 10000, - allowHttp: true, - }, - 'webhookUrl' - ) - const responseBody = await response.text().catch(() => '') - - return { - success: response.ok, - status: response.status, - statusText: response.statusText, - body: responseBody.slice(0, 500), - timestamp: new Date().toISOString(), - } - } catch (error: unknown) { - logger.warn('Webhook test failed', { - error: toError(error).message, - }) - return { success: false, error: 'Failed to deliver webhook' } - } -} - -async function testEmail(subscription: typeof workspaceNotificationSubscription.$inferSelect) { - if (!subscription.emailRecipients || subscription.emailRecipients.length === 0) { - return { success: false, error: 'No email recipients configured' } - } - - const { payload } = buildTestPayload(subscription) - const data = (payload as Record).data as Record - const baseUrl = getBaseUrl() - const logUrl = `${baseUrl}/workspace/${subscription.workspaceId}/logs` - - const html = await renderWorkflowNotificationEmail({ - workflowName: data.workflowName as string, - status: data.status as 'success' | 'error', - trigger: data.trigger as string, - duration: `${data.totalDurationMs}ms`, - cost: `$${(((data.cost as Record)?.total as number) || 0).toFixed(4)}`, - logUrl, - finalOutput: data.finalOutput, - rateLimits: data.rateLimits as EmailRateLimitsData | undefined, - usageData: data.usage as EmailUsageData | undefined, - }) - - const result = await sendEmail({ - to: subscription.emailRecipients, - subject: `[Test] Workflow Execution: ${data.workflowName}`, - html, - text: `This is a test notification from Sim.\n\nWorkflow: ${data.workflowName}\nStatus: ${data.status}\nDuration: ${data.totalDurationMs}ms\n\nView Log: ${logUrl}\n\nThis notification is configured for workspace notifications.`, - emailType: 'notifications', - }) - - return { - success: result.success, - message: result.message, - timestamp: new Date().toISOString(), - } -} - -async function testSlack( - subscription: typeof workspaceNotificationSubscription.$inferSelect, - userId: string -) { - const slackConfig = subscription.slackConfig as SlackConfig | null - if (!slackConfig?.channelId || !slackConfig?.accountId) { - return { success: false, error: 'No Slack channel or account configured' } - } - - const [slackAccount] = await db - .select({ accessToken: account.accessToken }) - .from(account) - .where(and(eq(account.id, slackConfig.accountId), eq(account.userId, userId))) - .limit(1) - - if (!slackAccount?.accessToken) { - return { success: false, error: 'Slack account not found or not connected' } - } - - const { payload } = buildTestPayload(subscription) - const data = (payload as Record).data as Record - - const slackPayload = { - channel: slackConfig.channelId, - blocks: [ - { - type: 'header', - text: { type: 'plain_text', text: '🧪 Test Notification', emoji: true }, - }, - { - type: 'section', - fields: [ - { type: 'mrkdwn', text: `*Workflow:*\n${data.workflowName}` }, - { type: 'mrkdwn', text: `*Status:*\n✅ ${data.status}` }, - { type: 'mrkdwn', text: `*Duration:*\n${data.totalDurationMs}ms` }, - { type: 'mrkdwn', text: `*Trigger:*\n${data.trigger}` }, - ], - }, - { - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: 'This is a test notification from Sim workspace notifications.', - }, - ], - }, - ], - text: `Test notification: ${data.workflowName} - ${data.status}`, - } - - try { - const response = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${slackAccount.accessToken}`, - }, - body: JSON.stringify(slackPayload), - }) - - const result = await response.json() - - return { - success: result.ok, - error: result.ok ? undefined : `Slack error: ${result.error || 'unknown'}`, - channel: result.channel, - timestamp: new Date().toISOString(), - } - } catch (error: unknown) { - logger.warn('Slack test notification failed', { - error: toError(error).message, - }) - return { success: false, error: 'Failed to send Slack notification' } - } -} - -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = notificationParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, notificationId } = paramsResult.data - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const [subscription] = await db - .select() - .from(workspaceNotificationSubscription) - .where( - and( - eq(workspaceNotificationSubscription.id, notificationId), - eq(workspaceNotificationSubscription.workspaceId, workspaceId) - ) - ) - .limit(1) - - if (!subscription) { - return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) - } - - let result: Record - - switch (subscription.notificationType) { - case 'webhook': - result = await testWebhook(subscription) - break - case 'email': - result = await testEmail(subscription) - break - case 'slack': - result = await testSlack(subscription, session.user.id) - break - default: - return NextResponse.json({ error: 'Unknown notification type' }, { status: 400 }) - } - - logger.info('Test notification sent', { - workspaceId, - subscriptionId: notificationId, - type: subscription.notificationType, - success: result.success, - }) - - return NextResponse.json({ data: result }) - } catch (error) { - logger.error('Error testing notification', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/constants.ts b/apps/sim/app/api/workspaces/[id]/notifications/constants.ts deleted file mode 100644 index 036f32a534..0000000000 --- a/apps/sim/app/api/workspaces/[id]/notifications/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Maximum email recipients per notification */ -export const MAX_EMAIL_RECIPIENTS = 10 - -/** Maximum notifications per type per workspace */ -export const MAX_NOTIFICATIONS_PER_TYPE = 10 - -/** Maximum workflow IDs per notification */ -export const MAX_WORKFLOW_IDS = 1000 diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts deleted file mode 100644 index 9ddb0ed3fa..0000000000 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { workflow, workspaceNotificationSubscription } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { createNotificationServerContract } from '@/lib/api/contracts/notifications' -import { parseRequest, validationErrorResponse } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { encryptSecret } from '@/lib/core/security/encryption' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { MAX_NOTIFICATIONS_PER_TYPE } from './constants' - -const logger = createLogger('WorkspaceNotificationsAPI') - -async function checkWorkspaceWriteAccess( - userId: string, - workspaceId: string -): Promise<{ hasAccess: boolean; permission: string | null }> { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - const hasAccess = permission === 'write' || permission === 'admin' - return { hasAccess, permission } -} - -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workspaceId } = await params - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - - if (!permission) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const subscriptions = await db - .select({ - id: workspaceNotificationSubscription.id, - notificationType: workspaceNotificationSubscription.notificationType, - workflowIds: workspaceNotificationSubscription.workflowIds, - allWorkflows: workspaceNotificationSubscription.allWorkflows, - levelFilter: workspaceNotificationSubscription.levelFilter, - triggerFilter: workspaceNotificationSubscription.triggerFilter, - includeFinalOutput: workspaceNotificationSubscription.includeFinalOutput, - includeTraceSpans: workspaceNotificationSubscription.includeTraceSpans, - includeRateLimits: workspaceNotificationSubscription.includeRateLimits, - includeUsageData: workspaceNotificationSubscription.includeUsageData, - webhookConfig: workspaceNotificationSubscription.webhookConfig, - emailRecipients: workspaceNotificationSubscription.emailRecipients, - slackConfig: workspaceNotificationSubscription.slackConfig, - alertConfig: workspaceNotificationSubscription.alertConfig, - active: workspaceNotificationSubscription.active, - createdAt: workspaceNotificationSubscription.createdAt, - updatedAt: workspaceNotificationSubscription.updatedAt, - }) - .from(workspaceNotificationSubscription) - .where(eq(workspaceNotificationSubscription.workspaceId, workspaceId)) - .orderBy(workspaceNotificationSubscription.createdAt) - - return NextResponse.json({ data: subscriptions }) - } catch (error) { - logger.error('Error fetching notifications', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workspaceId } = await context.params - const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) - - if (!hasAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const parsed = await parseRequest(createNotificationServerContract, request, context, { - validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request'), - }) - if (!parsed.success) return parsed.response - const data = parsed.data.body - - const existingCount = await db - .select({ id: workspaceNotificationSubscription.id }) - .from(workspaceNotificationSubscription) - .where( - and( - eq(workspaceNotificationSubscription.workspaceId, workspaceId), - eq(workspaceNotificationSubscription.notificationType, data.notificationType) - ) - ) - - if (existingCount.length >= MAX_NOTIFICATIONS_PER_TYPE) { - return NextResponse.json( - { - error: `Maximum ${MAX_NOTIFICATIONS_PER_TYPE} ${data.notificationType} notifications per workspace`, - }, - { status: 400 } - ) - } - - if (!data.allWorkflows && data.workflowIds.length > 0) { - const workflowsInWorkspace = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), inArray(workflow.id, data.workflowIds))) - - const validIds = new Set(workflowsInWorkspace.map((w) => w.id)) - const invalidIds = data.workflowIds.filter((id) => !validIds.has(id)) - - if (invalidIds.length > 0) { - return NextResponse.json( - { error: 'Some workflow IDs do not belong to this workspace', invalidIds }, - { status: 400 } - ) - } - } - - let webhookConfig = data.webhookConfig || null - if (webhookConfig?.secret) { - const { encrypted } = await encryptSecret(webhookConfig.secret) - webhookConfig = { ...webhookConfig, secret: encrypted } - } - - const [subscription] = await db - .insert(workspaceNotificationSubscription) - .values({ - id: generateId(), - workspaceId, - notificationType: data.notificationType, - workflowIds: data.workflowIds, - allWorkflows: data.allWorkflows, - levelFilter: data.levelFilter, - triggerFilter: data.triggerFilter, - includeFinalOutput: data.includeFinalOutput, - includeTraceSpans: data.includeTraceSpans, - includeRateLimits: data.includeRateLimits, - includeUsageData: data.includeUsageData, - alertConfig: data.alertConfig || null, - webhookConfig, - emailRecipients: data.emailRecipients || null, - slackConfig: data.slackConfig || null, - createdBy: session.user.id, - }) - .returning() - - logger.info('Created notification subscription', { - workspaceId, - subscriptionId: subscription.id, - type: data.notificationType, - }) - - captureServerEvent( - session.user.id, - 'notification_channel_created', - { - workspace_id: workspaceId, - notification_type: data.notificationType, - alert_rule: data.alertConfig?.rule ?? null, - }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.NOTIFICATION_CREATED, - resourceType: AuditResourceType.NOTIFICATION, - resourceId: subscription.id, - resourceName: data.notificationType, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Created ${data.notificationType} notification subscription`, - metadata: { - notificationType: data.notificationType, - allWorkflows: data.allWorkflows, - workflowCount: data.workflowIds.length, - levelFilter: data.levelFilter, - alertRule: data.alertConfig?.rule ?? null, - ...(data.notificationType === 'email' && { - recipientCount: data.emailRecipients?.length ?? 0, - }), - ...(data.notificationType === 'slack' && { channelName: data.slackConfig?.channelName }), - }, - request, - }) - - return NextResponse.json({ - data: { - id: subscription.id, - notificationType: subscription.notificationType, - workflowIds: subscription.workflowIds, - allWorkflows: subscription.allWorkflows, - levelFilter: subscription.levelFilter, - triggerFilter: subscription.triggerFilter, - includeFinalOutput: subscription.includeFinalOutput, - includeTraceSpans: subscription.includeTraceSpans, - includeRateLimits: subscription.includeRateLimits, - includeUsageData: subscription.includeUsageData, - webhookConfig: subscription.webhookConfig, - emailRecipients: subscription.emailRecipients, - slackConfig: subscription.slackConfig, - alertConfig: subscription.alertConfig, - active: subscription.active, - createdAt: subscription.createdAt, - updatedAt: subscription.updatedAt, - }, - }) - } catch (error) { - logger.error('Error creating notification', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts index 4bb7d5cbd0..c8b8e357e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts @@ -4,4 +4,3 @@ export { ExecutionSnapshot } from './log-details/components/execution-snapshot' export { FileCards } from './log-details/components/file-download' export { TraceView } from './log-details/components/trace-view' export { LogRowContextMenu } from './log-row-context-menu' -export { NotificationSettings } from './logs-toolbar' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/slack-channel-selector/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/slack-channel-selector/index.ts deleted file mode 100644 index 502a98f5e6..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/slack-channel-selector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SlackChannelSelector } from './slack-channel-selector' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/slack-channel-selector/slack-channel-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/slack-channel-selector/slack-channel-selector.tsx deleted file mode 100644 index 7010954417..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/slack-channel-selector/slack-channel-selector.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client' - -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { Hash, Lock } from 'lucide-react' -import { ChipCombobox, type ComboboxOption } from '@/components/emcn' -import { requestJson } from '@/lib/api/client/request' -import { slackChannelsSelectorContract } from '@/lib/api/contracts' - -const logger = createLogger('SlackChannelSelector') - -interface SlackChannel { - id: string - name: string - isPrivate: boolean -} - -interface SlackChannelSelectorProps { - accountId: string - value: string - onChange: (channelId: string, channelName: string) => void - disabled?: boolean - error?: string -} - -/** - * Standalone Slack channel selector that fetches channels for a given account. - */ -export function SlackChannelSelector({ - accountId, - value, - onChange, - disabled = false, - error, -}: SlackChannelSelectorProps) { - const [channels, setChannels] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [fetchError, setFetchError] = useState(null) - - const fetchChannels = useCallback(async () => { - if (!accountId) { - setChannels([]) - return - } - - setIsLoading(true) - setFetchError(null) - - try { - const data = await requestJson(slackChannelsSelectorContract, { - body: { credential: accountId }, - }) - setChannels( - (data.channels ?? []).map((channel) => ({ - id: channel.id, - name: channel.name, - isPrivate: channel.isPrivate, - })) - ) - } catch (err) { - logger.error('Failed to fetch Slack channels', { error: err }) - setFetchError(getErrorMessage(err, 'Failed to fetch channels')) - setChannels([]) - } finally { - setIsLoading(false) - } - }, [accountId]) - - useEffect(() => { - fetchChannels() - }, [fetchChannels]) - - const options: ComboboxOption[] = channels.map((channel) => ({ - label: channel.name, - value: channel.id, - icon: channel.isPrivate ? Lock : Hash, - })) - - const selectedChannel = channels.find((c) => c.id === value) - - const handleChange = (channelId: string) => { - const channel = channels.find((c) => c.id === channelId) - onChange(channelId, channel?.name || '') - } - - return ( -
- - {selectedChannel && !fetchError && ( -

- {selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name} -

- )} - {error &&

{error}

} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/index.ts deleted file mode 100644 index 2055c3e6bd..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkflowSelector } from './workflow-selector' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx deleted file mode 100644 index 239f80572d..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { X } from 'lucide-react' -import { Badge, ChipCombobox, type ComboboxOption, Label, Skeleton } from '@/components/emcn' -import { useWorkflows } from '@/hooks/queries/workflows' - -interface WorkflowSelectorProps { - workspaceId: string - selectedIds: string[] - allWorkflows: boolean - onChange: (ids: string[], allWorkflows: boolean) => void - error?: string -} - -/** - * Multi-select workflow selector with "All Workflows" option. - * Uses Combobox's built-in showAllOption for the "All Workflows" selection. - * When allWorkflows is true, the array is empty and "All Workflows" is selected. - */ -export function WorkflowSelector({ - workspaceId, - selectedIds, - allWorkflows, - onChange, - error, -}: WorkflowSelectorProps) { - const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId) - - const options: ComboboxOption[] = useMemo(() => { - return workflows.map((w) => ({ - label: w.name, - value: w.id, - })) - }, [workflows]) - - /** - * When allWorkflows is true, pass empty array so the "All" option is selected. - * Otherwise, pass the selected workflow IDs. - */ - const currentValues = allWorkflows ? [] : selectedIds - - /** - * Handle multi-select changes from Combobox. - * Empty array from showAllOption = all workflows selected. - */ - const handleMultiSelectChange = (values: string[]) => { - if (values.length === 0) { - onChange([], true) - } else { - onChange(values, false) - } - } - - const handleRemove = (e: React.MouseEvent, id: string) => { - e.preventDefault() - e.stopPropagation() - onChange( - selectedIds.filter((i) => i !== id), - false - ) - } - - const selectedWorkflows = useMemo(() => { - return workflows.filter((w) => selectedIds.includes(w.id)) - }, [workflows, selectedIds]) - - const overlayContent = useMemo(() => { - if (allWorkflows) { - return All Workflows - } - - if (selectedWorkflows.length === 0) { - return null - } - - return ( -
- {selectedWorkflows.slice(0, 2).map((w) => ( - handleRemove(e, w.id)} - > - {w.name} - - - ))} - {selectedWorkflows.length > 2 && ( - - +{selectedWorkflows.length - 2} - - )} -
- ) - }, [allWorkflows, selectedWorkflows, selectedIds]) - - if (isLoading) { - return ( -
- - -
- ) - } - - return ( -
- - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/index.ts deleted file mode 100644 index 40536038c2..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { NotificationSettings } from './notifications' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx deleted file mode 100644 index 4e65092dd6..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx +++ /dev/null @@ -1,1250 +0,0 @@ -'use client' - -import type { ReactNode } from 'react' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { X } from 'lucide-react' -import { - Badge, - Button, - ChipCombobox, - ChipConfirmModal, - ChipInput, - ChipModal, - ChipModalBody, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - ChipModalTabs, - Skeleton, -} from '@/components/emcn' -import { SlackIcon } from '@/components/icons' -import type { - NotificationAlertRule, - NotificationLogLevel, - NotificationType, -} from '@/lib/api/contracts/notifications' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { getTriggerOptions } from '@/lib/logs/get-trigger-options' -import { - type NotificationSubscription, - useCreateNotification, - useDeleteNotification, - useNotifications, - useTestNotification, - useUpdateNotification, -} from '@/hooks/queries/notifications' -import { - useConnectedAccounts, - useConnectOAuthService, -} from '@/hooks/queries/oauth/oauth-connections' -import type { CoreTriggerType } from '@/stores/logs/filters/types' -import { SlackChannelSelector } from './components/slack-channel-selector' -import { WorkflowSelector } from './components/workflow-selector' - -const logger = createLogger('NotificationSettings') - -interface TabContentProps { - displayForm: boolean - renderForm: () => ReactNode - isLoading: boolean - filteredSubscriptions: NotificationSubscription[] - renderSubscriptionItem: (subscription: NotificationSubscription) => ReactNode -} - -function TabContent({ - displayForm, - renderForm, - isLoading, - filteredSubscriptions, - renderSubscriptionItem, -}: TabContentProps) { - if (displayForm) { - return renderForm() - } - - return ( -
-
- {isLoading ? ( -
- {[120, 80, 100, 90].map((labelWidth, i) => ( -
- - -
- ))} -
- ) : ( -
- {filteredSubscriptions.map(renderSubscriptionItem)} -
- )} -
-
- ) -} - -const TRIGGER_OPTIONS = getTriggerOptions() -const ALL_TRIGGER_VALUES = TRIGGER_OPTIONS.map((t) => t.value) - -type LogLevel = NotificationLogLevel -/** Contract alert rule plus a UI-only `'none'` sentinel meaning "no alert config". */ -type AlertRule = NotificationAlertRule | 'none' - -const ALERT_RULES: { value: AlertRule; label: string; description: string }[] = [ - { value: 'none', label: 'None', description: 'Notify on every matching execution' }, - { - value: 'consecutive_failures', - label: 'Consecutive Failures', - description: 'After X failures in a row', - }, - { value: 'failure_rate', label: 'Failure Rate', description: 'When failure % exceeds threshold' }, - { - value: 'latency_threshold', - label: 'Latency Threshold', - description: 'When execution exceeds duration', - }, - { value: 'latency_spike', label: 'Latency Spike', description: 'When slower than average by %' }, - { - value: 'cost_threshold', - label: 'Cost Threshold', - description: 'When execution cost exceeds credits', - }, - { value: 'no_activity', label: 'No Activity', description: 'When no executions in time window' }, - { value: 'error_count', label: 'Error Count', description: 'When errors exceed count in window' }, -] - -interface NotificationSettingsProps { - workspaceId: string - open: boolean - onOpenChange: (open: boolean) => void -} - -const LOG_LEVELS: LogLevel[] = ['info', 'error'] - -function formatAlertConfigLabel(config: { - rule: AlertRule - consecutiveFailures?: number - failureRatePercent?: number - windowHours?: number - durationThresholdMs?: number - latencySpikePercent?: number - costThresholdDollars?: number - inactivityHours?: number - errorCountThreshold?: number -}): string { - switch (config.rule) { - case 'consecutive_failures': - return `${config.consecutiveFailures} consecutive failures` - case 'failure_rate': - return `${config.failureRatePercent}% failure rate in ${config.windowHours}h` - case 'latency_threshold': - return `>${Math.round((config.durationThresholdMs || 0) / 1000)}s duration` - case 'latency_spike': - return `${config.latencySpikePercent}% above avg in ${config.windowHours}h` - case 'cost_threshold': - return `>${dollarsToCredits(config.costThresholdDollars ?? 0).toLocaleString()} credits per execution` - case 'no_activity': - return `No activity in ${config.inactivityHours}h` - case 'error_count': - return `${config.errorCountThreshold} errors in ${config.windowHours}h` - default: - return 'Alert rule' - } -} - -export const NotificationSettings = memo(function NotificationSettings({ - workspaceId, - open, - onOpenChange, -}: NotificationSettingsProps) { - const [activeTab, setActiveTab] = useState('webhook') - const [showForm, setShowForm] = useState(false) - const [editingId, setEditingId] = useState(null) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [deletingId, setDeletingId] = useState(null) - const [testStatus, setTestStatus] = useState<{ - id: string - success: boolean - message: string - } | null>(null) - - const [formData, setFormData] = useState({ - workflowIds: [] as string[], - allWorkflows: true, - levelFilter: ['info', 'error'] as LogLevel[], - triggerFilter: ALL_TRIGGER_VALUES, - includeFinalOutput: false, - includeTraceSpans: false, - includeRateLimits: false, - includeUsageData: false, - webhookUrl: '', - webhookSecret: '', - emailRecipients: [] as string[], - slackChannelId: '', - slackChannelName: '', - slackAccountId: '', - - alertRule: 'none' as AlertRule, - consecutiveFailures: 3, - failureRatePercent: 50, - windowHours: 24, - durationThresholdMs: 30000, - latencySpikePercent: 100, - costThresholdDollars: 1, - inactivityHours: 24, - errorCountThreshold: 10, - }) - - const [formErrors, setFormErrors] = useState>({}) - - const { data: subscriptions = [], isLoading } = useNotifications(open ? workspaceId : undefined) - const createNotification = useCreateNotification() - const updateNotification = useUpdateNotification() - const deleteNotification = useDeleteNotification() - const testNotification = useTestNotification() - - const { data: slackAccounts = [], isLoading: isLoadingSlackAccounts } = - useConnectedAccounts('slack') - const connectSlack = useConnectOAuthService() - - useEffect(() => { - if (testStatus) { - const timer = setTimeout(() => { - setTestStatus(null) - }, 2000) - return () => clearTimeout(timer) - } - }, [testStatus]) - - const filteredSubscriptions = useMemo(() => { - return subscriptions.filter((s) => s.notificationType === activeTab) - }, [subscriptions, activeTab]) - - const hasSubscriptions = filteredSubscriptions.length > 0 - - // Compute form visibility synchronously to avoid empty state flash - // Show form if user explicitly opened it OR if loading is complete with no subscriptions - const displayForm = showForm || (!isLoading && !hasSubscriptions && !editingId) - - const getSubscriptionsForTab = (tab: NotificationType) => { - return subscriptions.filter((s) => s.notificationType === tab) - } - - const resetForm = useCallback(() => { - setFormData({ - workflowIds: [], - allWorkflows: true, - levelFilter: ['info', 'error'], - triggerFilter: ALL_TRIGGER_VALUES, - includeFinalOutput: false, - includeTraceSpans: false, - includeRateLimits: false, - includeUsageData: false, - webhookUrl: '', - webhookSecret: '', - emailRecipients: [], - slackChannelId: '', - slackChannelName: '', - slackAccountId: '', - - alertRule: 'none', - consecutiveFailures: 3, - failureRatePercent: 50, - windowHours: 24, - durationThresholdMs: 30000, - latencySpikePercent: 100, - costThresholdDollars: 1, - inactivityHours: 24, - errorCountThreshold: 10, - }) - setFormErrors({}) - setEditingId(null) - }, []) - - const handleClose = useCallback(() => { - resetForm() - setShowForm(false) - setTestStatus(null) - onOpenChange(false) - }, [onOpenChange, resetForm]) - - const handleEmailRecipientsChange = useCallback((next: string[]) => { - setFormData((prev) => ({ ...prev, emailRecipients: next })) - setFormErrors((prev) => ({ ...prev, emailRecipients: '' })) - }, []) - - const validateForm = (): boolean => { - const errors: Record = {} - - if (!formData.allWorkflows && formData.workflowIds.length === 0) { - errors.workflows = 'Select at least one workflow or enable "All Workflows"' - } - - if (formData.levelFilter.length === 0) { - errors.levelFilter = 'Select at least one log level' - } - - if (formData.triggerFilter.length === 0) { - errors.triggerFilter = 'Select at least one trigger type' - } - - if (activeTab === 'webhook') { - if (!formData.webhookUrl) { - errors.webhookUrl = 'Webhook URL is required' - } else { - try { - const url = new URL(formData.webhookUrl) - if (!['http:', 'https:'].includes(url.protocol)) { - errors.webhookUrl = 'URL must start with http:// or https://' - } - } catch { - errors.webhookUrl = 'Invalid URL format' - } - } - } - - if (activeTab === 'email') { - if (formData.emailRecipients.length === 0) { - errors.emailRecipients = 'At least one email address is required' - } else if (formData.emailRecipients.length > 10) { - errors.emailRecipients = 'Maximum 10 email recipients allowed' - } - } - - if (activeTab === 'slack') { - if (!formData.slackAccountId) { - errors.slackAccountId = 'Select a Slack account' - } - if (!formData.slackChannelId) { - errors.slackChannelId = 'Select a Slack channel' - } - } - - if (formData.alertRule !== 'none') { - switch (formData.alertRule) { - case 'consecutive_failures': - if (formData.consecutiveFailures < 1 || formData.consecutiveFailures > 100) { - errors.consecutiveFailures = 'Must be between 1 and 100' - } - break - case 'failure_rate': - if (formData.failureRatePercent < 1 || formData.failureRatePercent > 100) { - errors.failureRatePercent = 'Must be between 1 and 100' - } - if (formData.windowHours < 1 || formData.windowHours > 168) { - errors.windowHours = 'Must be between 1 and 168 hours' - } - break - case 'latency_threshold': - if (formData.durationThresholdMs < 1000 || formData.durationThresholdMs > 3600000) { - errors.durationThresholdMs = 'Must be between 1s and 1 hour' - } - break - case 'latency_spike': - if (formData.latencySpikePercent < 10 || formData.latencySpikePercent > 1000) { - errors.latencySpikePercent = 'Must be between 10% and 1000%' - } - if (formData.windowHours < 1 || formData.windowHours > 168) { - errors.windowHours = 'Must be between 1 and 168 hours' - } - break - case 'cost_threshold': - if (formData.costThresholdDollars < 0.01 || formData.costThresholdDollars > 1000) { - errors.costThresholdDollars = 'Must be between $0.01 and $1000' - } - break - case 'no_activity': - if (formData.inactivityHours < 1 || formData.inactivityHours > 168) { - errors.inactivityHours = 'Must be between 1 and 168 hours' - } - break - case 'error_count': - if (formData.errorCountThreshold < 1 || formData.errorCountThreshold > 1000) { - errors.errorCountThreshold = 'Must be between 1 and 1000' - } - if (formData.windowHours < 1 || formData.windowHours > 168) { - errors.windowHours = 'Must be between 1 and 168 hours' - } - break - } - } - - setFormErrors(errors) - return Object.keys(errors).length === 0 - } - - const handleSave = async () => { - if (!validateForm()) return - - const alertConfig = - formData.alertRule !== 'none' - ? { - rule: formData.alertRule, - ...(formData.alertRule === 'consecutive_failures' && { - consecutiveFailures: formData.consecutiveFailures, - }), - ...(formData.alertRule === 'failure_rate' && { - failureRatePercent: formData.failureRatePercent, - windowHours: formData.windowHours, - }), - ...(formData.alertRule === 'latency_threshold' && { - durationThresholdMs: formData.durationThresholdMs, - }), - ...(formData.alertRule === 'latency_spike' && { - latencySpikePercent: formData.latencySpikePercent, - windowHours: formData.windowHours, - }), - ...(formData.alertRule === 'cost_threshold' && { - costThresholdDollars: formData.costThresholdDollars, - }), - ...(formData.alertRule === 'no_activity' && { - inactivityHours: formData.inactivityHours, - }), - ...(formData.alertRule === 'error_count' && { - errorCountThreshold: formData.errorCountThreshold, - windowHours: formData.windowHours, - }), - } - : null - - const payload = { - notificationType: activeTab, - workflowIds: formData.workflowIds, - allWorkflows: formData.allWorkflows, - levelFilter: formData.levelFilter, - triggerFilter: formData.triggerFilter as CoreTriggerType[], - includeFinalOutput: formData.includeFinalOutput, - // Trace spans only available for webhooks (too large for email/Slack) - includeTraceSpans: activeTab === 'webhook' ? formData.includeTraceSpans : false, - includeRateLimits: formData.includeRateLimits, - includeUsageData: formData.includeUsageData, - alertConfig, - ...(activeTab === 'webhook' && { - webhookConfig: { - url: formData.webhookUrl, - secret: formData.webhookSecret || undefined, - }, - }), - ...(activeTab === 'email' && { - emailRecipients: formData.emailRecipients, - }), - ...(activeTab === 'slack' && { - slackConfig: { - channelId: formData.slackChannelId, - channelName: formData.slackChannelName, - accountId: formData.slackAccountId, - }, - }), - } - - try { - if (editingId) { - await updateNotification.mutateAsync({ - workspaceId, - notificationId: editingId, - data: payload, - }) - } else { - await createNotification.mutateAsync({ - workspaceId, - data: payload, - }) - } - resetForm() - setShowForm(false) - } catch (error) { - const message = getErrorMessage(error, 'Failed to save notification') - setFormErrors({ general: message }) - } - } - - const handleBackToList = () => { - resetForm() - setShowForm(false) - } - - const handleAddNew = () => { - resetForm() - setShowForm(true) - } - - const handleEdit = (subscription: NotificationSubscription) => { - setActiveTab(subscription.notificationType) - setEditingId(subscription.id) - setFormData({ - workflowIds: subscription.workflowIds || [], - allWorkflows: subscription.allWorkflows, - levelFilter: subscription.levelFilter as LogLevel[], - triggerFilter: subscription.triggerFilter, - includeFinalOutput: subscription.includeFinalOutput, - includeTraceSpans: subscription.includeTraceSpans, - includeRateLimits: subscription.includeRateLimits, - includeUsageData: subscription.includeUsageData, - webhookUrl: subscription.webhookConfig?.url || '', - webhookSecret: '', - emailRecipients: subscription.emailRecipients || [], - slackChannelId: subscription.slackConfig?.channelId || '', - slackChannelName: subscription.slackConfig?.channelName || '', - slackAccountId: subscription.slackConfig?.accountId || '', - alertRule: subscription.alertConfig?.rule || 'none', - consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3, - failureRatePercent: subscription.alertConfig?.failureRatePercent || 50, - windowHours: subscription.alertConfig?.windowHours || 24, - durationThresholdMs: subscription.alertConfig?.durationThresholdMs || 30000, - latencySpikePercent: subscription.alertConfig?.latencySpikePercent || 100, - costThresholdDollars: subscription.alertConfig?.costThresholdDollars || 1, - inactivityHours: subscription.alertConfig?.inactivityHours || 24, - errorCountThreshold: subscription.alertConfig?.errorCountThreshold || 10, - }) - setShowForm(true) - } - - const handleDelete = async () => { - if (!deletingId) return - - try { - await deleteNotification.mutateAsync({ - workspaceId, - notificationId: deletingId, - }) - } catch (error) { - logger.error('Failed to delete notification', { error }) - } finally { - setShowDeleteDialog(false) - setDeletingId(null) - } - } - - const handleTest = async (id: string) => { - setTestStatus(null) - try { - const result = await testNotification.mutateAsync({ - workspaceId, - notificationId: id, - }) - setTestStatus({ - id, - success: result.data?.success ?? false, - message: - result.data?.error || (result.data?.success ? 'Test sent successfully' : 'Test failed'), - }) - } catch (_error) { - setTestStatus({ id, success: false, message: 'Failed to send test' }) - } - } - - const renderSubscriptionItem = (subscription: NotificationSubscription) => { - const identifier = - subscription.notificationType === 'webhook' - ? subscription.webhookConfig?.url - : subscription.notificationType === 'email' - ? subscription.emailRecipients?.join(', ') - : `#${subscription.slackConfig?.channelName || subscription.slackConfig?.channelId}` - - return ( -
-
-
-

- {identifier} -

-
- {subscription.allWorkflows ? ( - All workflows - ) : ( - - {subscription.workflowIds.length} workflow(s) - - )} - {subscription.levelFilter.map((level) => ( - - {level} - - ))} - {subscription.alertConfig && ( - - {formatAlertConfigLabel(subscription.alertConfig)} - - )} -
-
- -
- - - -
-
-
- ) - } - - const renderForm = () => ( -
-
- {formErrors.general && ( -

{formErrors.general}

- )} - -
- { - setFormData({ ...formData, workflowIds: ids, allWorkflows: all }) - setFormErrors({ ...formErrors, workflows: '' }) - }} - error={formErrors.workflows} - /> - - {activeTab === 'webhook' && ( - <> - { - setFormData({ ...formData, webhookUrl: value }) - setFormErrors({ ...formErrors, webhookUrl: '' }) - }} - error={formErrors.webhookUrl} - /> - setFormData({ ...formData, webhookSecret: value })} - /> - - )} - - {activeTab === 'email' && ( - - )} - - {activeTab === 'slack' && ( - <> - - {isLoadingSlackAccounts ? ( - - ) : slackAccounts.length === 0 ? ( -
- -
- ) : ( - ({ - value: acc.id, - label: acc.displayName || 'Slack Workspace', - }))} - value={formData.slackAccountId} - onChange={(value) => { - setFormData({ - ...formData, - slackAccountId: value, - slackChannelId: '', - }) - setFormErrors({ ...formErrors, slackAccountId: '', slackChannelId: '' }) - }} - placeholder='Select account...' - /> - )} -
- {slackAccounts.length > 0 && ( - - { - setFormData({ - ...formData, - slackChannelId: channelId, - slackChannelName: channelName, - }) - setFormErrors({ ...formErrors, slackChannelId: '' }) - }} - disabled={!formData.slackAccountId} - error={formErrors.slackChannelId} - /> - - )} - - )} - - - ({ - label: level.charAt(0).toUpperCase() + level.slice(1), - value: level, - }))} - multiSelect - multiSelectValues={formData.levelFilter} - onMultiSelectChange={(values) => { - setFormData({ ...formData, levelFilter: values as LogLevel[] }) - setFormErrors({ ...formErrors, levelFilter: '' }) - }} - placeholder='Select log levels...' - overlayContent={ - formData.levelFilter.length > 0 ? ( -
- {formData.levelFilter.map((level) => ( - { - e.preventDefault() - e.stopPropagation() - setFormData({ - ...formData, - levelFilter: formData.levelFilter.filter((l) => l !== level), - }) - }} - > - {level} - - - ))} -
- ) : null - } - showAllOption - allOptionLabel='All levels' - /> -
- - - ({ - label: t.label, - value: t.value, - }))} - multiSelect - multiSelectValues={formData.triggerFilter} - onMultiSelectChange={(values) => { - setFormData({ ...formData, triggerFilter: values }) - setFormErrors({ ...formErrors, triggerFilter: '' }) - }} - placeholder='Select trigger types...' - overlayContent={ - formData.triggerFilter.length > 0 ? ( -
- {formData.triggerFilter.slice(0, 6).map((trigger) => ( - { - e.preventDefault() - e.stopPropagation() - setFormData({ - ...formData, - triggerFilter: formData.triggerFilter.filter((t) => t !== trigger), - }) - }} - > - {trigger} - - - ))} - {formData.triggerFilter.length > 6 && ( - - +{formData.triggerFilter.length - 6} - - )} -
- ) : null - } - showAllOption - allOptionLabel='All triggers' - /> -
- - - { - setFormData({ - ...formData, - includeFinalOutput: values.includes('includeFinalOutput'), - includeTraceSpans: values.includes('includeTraceSpans'), - includeRateLimits: values.includes('includeRateLimits'), - includeUsageData: values.includes('includeUsageData'), - }) - }} - placeholder='Select data to include...' - overlayContent={(() => { - const labels: Record = { - includeFinalOutput: 'Final Output', - includeTraceSpans: 'Trace Spans', - includeRateLimits: 'Rate Limits', - includeUsageData: 'Usage Data', - } - const selected = [ - formData.includeFinalOutput && 'includeFinalOutput', - formData.includeTraceSpans && activeTab === 'webhook' && 'includeTraceSpans', - formData.includeRateLimits && 'includeRateLimits', - formData.includeUsageData && 'includeUsageData', - ].filter(Boolean) as string[] - - if (selected.length === 0) return null - - return ( -
- {selected.slice(0, 2).map((key) => ( - { - e.preventDefault() - e.stopPropagation() - setFormData({ ...formData, [key]: false }) - }} - > - {labels[key]} - - - ))} - {selected.length > 2 && ( - - +{selected.length - 2} - - )} -
- ) - })()} - showAllOption - allOptionLabel='None' - /> -
- - r.value === formData.alertRule)?.description} - > - ({ - value: rule.value, - label: rule.label, - }))} - value={formData.alertRule} - onChange={(value) => setFormData({ ...formData, alertRule: value as AlertRule })} - placeholder='Select rule' - /> - - - {formData.alertRule === 'consecutive_failures' && ( - - - setFormData({ - ...formData, - consecutiveFailures: Number.parseInt(e.target.value) || 1, - }) - } - /> - - )} - - {formData.alertRule === 'failure_rate' && ( -
- - - setFormData({ - ...formData, - failureRatePercent: Number.parseInt(e.target.value) || 1, - }) - } - /> - - - - setFormData({ - ...formData, - windowHours: Number.parseInt(e.target.value) || 1, - }) - } - /> - -
- )} - - {formData.alertRule === 'latency_threshold' && ( - - - setFormData({ - ...formData, - durationThresholdMs: (Number.parseInt(e.target.value) || 1) * 1000, - }) - } - /> - - )} - - {formData.alertRule === 'latency_spike' && ( -
- - - setFormData({ - ...formData, - latencySpikePercent: Number.parseInt(e.target.value) || 10, - }) - } - /> - - - - setFormData({ - ...formData, - windowHours: Number.parseInt(e.target.value) || 1, - }) - } - /> - -
- )} - - {formData.alertRule === 'cost_threshold' && ( - - - setFormData({ - ...formData, - costThresholdDollars: Number.parseFloat(e.target.value) || 0.01, - }) - } - /> - - )} - - {formData.alertRule === 'no_activity' && ( - - - setFormData({ - ...formData, - inactivityHours: Number.parseInt(e.target.value) || 1, - }) - } - /> - - )} - - {formData.alertRule === 'error_count' && ( -
- - - setFormData({ - ...formData, - errorCountThreshold: Number.parseInt(e.target.value) || 1, - }) - } - /> - - - - setFormData({ - ...formData, - windowHours: Number.parseInt(e.target.value) || 1, - }) - } - /> - -
- )} -
-
-
- ) - - return ( - <> - - handleClose()}>Notifications - - - { - const tab = value as NotificationType - const tabHasSubscriptions = getSubscriptionsForTab(tab).length > 0 - resetForm() - setActiveTab(tab) - setShowForm(!tabHasSubscriptions) - }} - /> - -
- -
-
- - -
- - { - if (!next) setDeletingId(null) - setShowDeleteDialog(next) - }} - srTitle='Delete Notification' - title='Delete Notification' - description={ - <> - - This will permanently remove the notification and stop all deliveries. - {' '} - This action cannot be undone. - - } - confirm={{ - label: 'Delete', - onClick: handleDelete, - pending: deleteNotification.isPending, - pendingLabel: 'Deleting...', - }} - /> - - ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/index.ts index 2884924bb2..e96ac6652c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/index.ts @@ -1,3 +1,2 @@ -export { NotificationSettings } from './components/notifications' export { AutocompleteSearch } from './components/search' export { LogsToolbar } from './logs-toolbar' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index d16921f748..f628b636b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -1,7 +1,7 @@ 'use client' import { memo, useCallback, useMemo, useRef, useState } from 'react' -import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react' +import { ArrowUp, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' @@ -70,8 +70,6 @@ interface LogsToolbarProps { canEdit: boolean /** Whether there are logs to export */ hasLogs: boolean - /** Callback when notification settings is clicked */ - onOpenNotificationSettings: () => void /** Search query value */ searchQuery: string /** Callback when search query changes */ @@ -157,7 +155,6 @@ export const LogsToolbar = memo(function LogsToolbar({ onExport, canEdit, hasLogs, - onOpenNotificationSettings, searchQuery, onSearchQueryChange, onSearchOpenChange, @@ -440,10 +437,6 @@ export const LogsToolbar = memo(function LogsToolbar({ Export as CSV - - - Configure Notifications - diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 1e6d70d4d0..90967d51ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -14,7 +14,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' import { - Bell, Button, ChipCombobox, type ComboboxOption, @@ -74,13 +73,7 @@ import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' -import { - Dashboard, - ExecutionSnapshot, - LogDetails, - LogRowContextMenu, - NotificationSettings, -} from './components' +import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components' import { DELETED_WORKFLOW_LABEL, extractRetryInput, @@ -290,7 +283,6 @@ export default function Logs() { const activeLogRefetchRef = useRef<() => void>(() => {}) const activeLogTabRef = useRef('overview') const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) - const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) const [activeSort, setActiveSort] = useState<{ column: string direction: 'asc' | 'desc' @@ -720,7 +712,6 @@ export default function Logs() { }, []) const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), []) - const handleOpenNotificationSettings = useCallback(() => setIsNotificationSettingsOpen(true), []) function handleClosePreview() { setPreviewLogId(null) } @@ -1079,11 +1070,6 @@ export default function Logs() { onSelect: handleExport, disabled: !userPermissions.canEdit || isExporting || logs.length === 0, }, - { - text: 'Notifications', - icon: Bell, - onSelect: handleOpenNotificationSettings, - }, { text: 'Refresh', icon: refreshIcon, @@ -1111,7 +1097,6 @@ export default function Logs() { userPermissions.canEdit, isExporting, logs.length, - handleOpenNotificationSettings, ] ) @@ -1158,12 +1143,6 @@ export default function Logs() { )} - - { if (!multiSelect || !multiValues || multiValues.length === 0) return undefined + const visibleValues = multiValues.slice(0, MAX_VISIBLE_MULTI_SELECT_BADGES) + const overflowCount = multiValues.length - visibleValues.length + return (
- {multiValues.map((selectedValue: string, index) => { + {visibleValues.map((selectedValue: string, index) => { const label = (optionMap.get(selectedValue) || selectedValue).toLowerCase() const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ activeSearchTarget, @@ -474,14 +481,18 @@ export const Dropdown = memo(function Dropdown({ label, }) return ( - - {formatDisplayText(label, { workflowSearchHighlight })} - + + + {formatDisplayText(label, { workflowSearchHighlight })} + + ) })} + {overflowCount > 0 && ( + + +{overflowCount} + + )}
) }, [activeSearchTarget, blockId, multiSelect, multiValues, optionMap, subBlockId]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 769c75c678..174e5b43e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -729,7 +729,7 @@ export const Toolbar = memo( onContextMenu={handleItemContextMenu} /> -/** - * Type guard for workflow table row structure (sub-block table inputs) - */ -interface WorkflowTableRow { - id: string - cells: Record -} - -/** - * Type guard for field format structure (input format, response format) - */ -interface FieldFormat { - id: string - name: string - type?: string - value?: string - collapsed?: boolean -} - -/** - * Checks if a value is a table row array - */ -const isTableRowArray = (value: unknown): value is WorkflowTableRow[] => { - if (!Array.isArray(value) || value.length === 0) return false - const firstItem = value[0] - return ( - typeof firstItem === 'object' && - firstItem !== null && - 'id' in firstItem && - 'cells' in firstItem && - typeof firstItem.cells === 'object' - ) -} - -/** - * Checks if a value is a field format array - */ -const isFieldFormatArray = (value: unknown): value is FieldFormat[] => { - if (!Array.isArray(value) || value.length === 0) return false - const firstItem = value[0] - return ( - typeof firstItem === 'object' && - firstItem !== null && - 'id' in firstItem && - 'name' in firstItem && - typeof firstItem.name === 'string' - ) -} - -/** - * Checks if a value is a plain object (not array, not null) - */ -const isPlainObject = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -/** - * Type guard for variable assignments array - */ -const isVariableAssignmentsArray = ( - value: unknown -): value is Array<{ id?: string; variableId?: string; variableName?: string; value: any }> => { - return ( - Array.isArray(value) && - value.length > 0 && - value.every( - (item) => - typeof item === 'object' && - item !== null && - ('variableName' in item || 'variableId' in item) - ) - ) -} - -/** - * Type guard for agent messages array - */ -const isMessagesArray = (value: unknown): value is Array<{ role: string; content: string }> => { - return ( - Array.isArray(value) && - value.length > 0 && - value.every( - (item) => - typeof item === 'object' && - item !== null && - 'role' in item && - 'content' in item && - typeof item.role === 'string' && - typeof item.content === 'string' - ) - ) -} - -/** - * Type guard for tag filter array (used in knowledge block filters) - */ -interface TagFilterItem { - id: string - tagName: string - fieldType?: string - operator?: string - tagValue: string -} - -const isTagFilterArray = (value: unknown): value is TagFilterItem[] => { - if (!Array.isArray(value) || value.length === 0) return false - const firstItem = value[0] - return ( - typeof firstItem === 'object' && - firstItem !== null && - 'tagName' in firstItem && - 'tagValue' in firstItem && - typeof firstItem.tagName === 'string' - ) -} - -/** - * Type guard for document tag entry array (used in knowledge block create document) - */ -interface DocumentTagItem { - id: string - tagName: string - fieldType?: string - value: string -} - -const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => { - if (!Array.isArray(value) || value.length === 0) return false - const firstItem = value[0] - return ( - typeof firstItem === 'object' && - firstItem !== null && - 'tagName' in firstItem && - 'value' in firstItem && - !('tagValue' in firstItem) && // Distinguish from tag filters - typeof firstItem.tagName === 'string' - ) -} - -/** - * Type guard for filter condition array (used in table block filter builder) - */ -const isFilterConditionArray = (value: unknown): value is FilterRule[] => { - if (!Array.isArray(value) || value.length === 0) return false - const firstItem = value[0] - return ( - typeof firstItem === 'object' && - firstItem !== null && - 'column' in firstItem && - 'operator' in firstItem && - 'logicalOperator' in firstItem && - typeof firstItem.column === 'string' - ) -} - -/** - * Type guard for sort condition array (used in table block sort builder) - */ -const isSortConditionArray = (value: unknown): value is SortRule[] => { - if (!Array.isArray(value) || value.length === 0) return false - const firstItem = value[0] - return ( - typeof firstItem === 'object' && - firstItem !== null && - 'column' in firstItem && - 'direction' in firstItem && - typeof firstItem.column === 'string' && - (firstItem.direction === 'asc' || firstItem.direction === 'desc') - ) -} - -/** - * Attempts to parse a JSON string, returns the parsed value or the original value if parsing fails - */ -const tryParseJson = (value: unknown): unknown => { - if (typeof value !== 'string') return value - try { - const trimmed = value.trim() - if ( - (trimmed.startsWith('[') && trimmed.endsWith(']')) || - (trimmed.startsWith('{') && trimmed.endsWith('}')) - ) { - return JSON.parse(trimmed) - } - } catch { - // Not valid JSON, return original - } - return value -} - -/** - * Formats a subblock value for display, intelligently handling nested objects and arrays. - * Used by both the canvas workflow blocks and copilot edit summaries. - */ -export const getDisplayValue = (value: unknown): string => { - if (value == null || value === '') return '-' - - const parsedValue = tryParseJson(value) - - if (isMessagesArray(parsedValue)) { - const firstMessage = parsedValue[0] - if (!firstMessage?.content || firstMessage.content.trim() === '') return '-' - const content = firstMessage.content.trim() - return truncate(content, 50) - } - - if (isVariableAssignmentsArray(parsedValue)) { - const names = parsedValue.map((a) => a.variableName).filter((name): name is string => !!name) - if (names.length === 0) return '-' - if (names.length === 1) return names[0] - if (names.length === 2) return `${names[0]}, ${names[1]}` - return `${names[0]}, ${names[1]} +${names.length - 2}` - } - - if (isTagFilterArray(parsedValue)) { - const validFilters = parsedValue.filter( - (f) => typeof f.tagName === 'string' && f.tagName.trim() !== '' - ) - if (validFilters.length === 0) return '-' - if (validFilters.length === 1) return validFilters[0].tagName - if (validFilters.length === 2) return `${validFilters[0].tagName}, ${validFilters[1].tagName}` - return `${validFilters[0].tagName}, ${validFilters[1].tagName} +${validFilters.length - 2}` - } - - if (isDocumentTagArray(parsedValue)) { - const validTags = parsedValue.filter( - (t) => typeof t.tagName === 'string' && t.tagName.trim() !== '' - ) - if (validTags.length === 0) return '-' - if (validTags.length === 1) return validTags[0].tagName - if (validTags.length === 2) return `${validTags[0].tagName}, ${validTags[1].tagName}` - return `${validTags[0].tagName}, ${validTags[1].tagName} +${validTags.length - 2}` - } - - if (isFilterConditionArray(parsedValue)) { - const validConditions = parsedValue.filter( - (c) => typeof c.column === 'string' && c.column.trim() !== '' - ) - if (validConditions.length === 0) return '-' - const formatCondition = (c: FilterRule) => { - const opLabels: Record = { - eq: '=', - ne: '≠', - gt: '>', - gte: '≥', - lt: '<', - lte: '≤', - contains: '~', - in: 'in', - } - const op = opLabels[c.operator] || c.operator - return `${c.column} ${op} ${c.value || '?'}` - } - if (validConditions.length === 1) return formatCondition(validConditions[0]) - if (validConditions.length === 2) { - return `${formatCondition(validConditions[0])}, ${formatCondition(validConditions[1])}` - } - return `${formatCondition(validConditions[0])}, ${formatCondition(validConditions[1])} +${validConditions.length - 2}` - } - - if (isSortConditionArray(parsedValue)) { - const validConditions = parsedValue.filter( - (c) => typeof c.column === 'string' && c.column.trim() !== '' - ) - if (validConditions.length === 0) return '-' - const formatSort = (c: SortRule) => `${c.column} ${c.direction === 'desc' ? '↓' : '↑'}` - if (validConditions.length === 1) return formatSort(validConditions[0]) - if (validConditions.length === 2) { - return `${formatSort(validConditions[0])}, ${formatSort(validConditions[1])}` - } - return `${formatSort(validConditions[0])}, ${formatSort(validConditions[1])} +${validConditions.length - 2}` - } - - if (isTableRowArray(parsedValue)) { - const nonEmptyRows = parsedValue.filter((row) => { - const cellValues = Object.values(row.cells) - return cellValues.some((cell) => cell && cell.trim() !== '') - }) - - if (nonEmptyRows.length === 0) return '-' - if (nonEmptyRows.length === 1) { - const firstRow = nonEmptyRows[0] - const cellEntries = Object.entries(firstRow.cells).filter(([, val]) => val?.trim()) - if (cellEntries.length === 0) return '-' - const preview = cellEntries - .slice(0, 2) - .map(([key, val]) => `${key}: ${val}`) - .join(', ') - return cellEntries.length > 2 ? `${preview}...` : preview - } - return `${nonEmptyRows.length} rows` - } - - if (isFieldFormatArray(parsedValue)) { - const namedFields = parsedValue.filter( - (field) => typeof field.name === 'string' && field.name.trim() !== '' - ) - if (namedFields.length === 0) return '-' - if (namedFields.length === 1) return namedFields[0].name - if (namedFields.length === 2) return `${namedFields[0].name}, ${namedFields[1].name}` - return `${namedFields[0].name}, ${namedFields[1].name} +${namedFields.length - 2}` - } - - if (isPlainObject(parsedValue)) { - const entries = Object.entries(parsedValue).filter( - ([, val]) => val !== null && val !== undefined && val !== '' - ) - - if (entries.length === 0) return '-' - if (entries.length === 1) { - const [key, val] = entries[0] - const valStr = String(val).slice(0, 30) - return `${key}: ${valStr}${String(val).length > 30 ? '...' : ''}` - } - const preview = entries - .slice(0, 2) - .map(([key]) => key) - .join(', ') - return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview - } - - if (Array.isArray(parsedValue)) { - const nonEmptyItems = parsedValue.filter( - (item) => item !== null && item !== undefined && item !== '' - ) - if (nonEmptyItems.length === 0) return '-' - - const getItemDisplayValue = (item: unknown): string => { - if (typeof item === 'object' && item !== null) { - const obj = item as Record - return String(obj.title || obj.name || obj.label || obj.id || JSON.stringify(item)) - } - return String(item) - } - - if (nonEmptyItems.length === 1) return getItemDisplayValue(nonEmptyItems[0]) - if (nonEmptyItems.length === 2) { - return `${getItemDisplayValue(nonEmptyItems[0])}, ${getItemDisplayValue(nonEmptyItems[1])}` - } - return `${getItemDisplayValue(nonEmptyItems[0])}, ${getItemDisplayValue(nonEmptyItems[1])} +${nonEmptyItems.length - 2}` - } - - // For non-array, non-object values, use original value for string conversion - const stringValue = String(value) - if (stringValue === '[object Object]') { - try { - const json = JSON.stringify(parsedValue) - if (json.length <= 40) return json - return `${json.slice(0, 37)}...` - } catch { - return '-' - } - } - - return stringValue.trim().length > 0 ? stringValue : '-' -} - interface SubBlockRowProps { title: string value?: string @@ -540,20 +190,10 @@ const SubBlockRow = memo(function SubBlockRow({ const knowledgeBaseId = dependencyValues.knowledgeBaseId - const dropdownLabel = useMemo(() => { - if (!subBlock || (subBlock.type !== 'dropdown' && subBlock.type !== 'combobox')) return null - if (!rawValue || typeof rawValue !== 'string') return null - - const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options - if (!options) return null - - const option = options.find((opt) => - typeof opt === 'string' ? opt === rawValue : opt.id === rawValue - ) - - if (!option) return null - return typeof option === 'string' ? option : option.label - }, [subBlock, rawValue]) + const dropdownLabel = useMemo( + () => resolveDropdownLabel(subBlock, rawValue), + [subBlock, rawValue] + ) const resolveContextValue = useCallback( (key: string): string | undefined => { @@ -605,11 +245,26 @@ const SubBlockRow = memo(function SubBlockRow({ ) const knowledgeBaseDisplayName = kbForDisplayName?.name ?? null - const { data: workflowMapForLookup = {} } = useWorkflowMap(workspaceId) + const { + data: workflowMapForLookup = {}, + isSuccess: workflowMapLoaded, + isPlaceholderData: workflowMapIsPlaceholder, + } = useWorkflowMap(workspaceId) + /** + * Hydrates workflow-selector values and multi-select workflow dropdowns to + * names. Ready only on a successful, non-placeholder load — an errored or + * stale-placeholder map must not mislabel valid workflows as deleted. + */ const workflowSelectionName = useMemo(() => { - if (subBlock?.type !== 'workflow-selector' || typeof rawValue !== 'string') return null - return workflowMapForLookup[rawValue]?.name ?? null - }, [workflowMapForLookup, subBlock?.type, rawValue]) + const lookup = { + workflowMap: workflowMapForLookup, + ready: workflowMapLoaded && !workflowMapIsPlaceholder, + } + return ( + resolveWorkflowSelectionLabel(subBlock, rawValue, lookup) ?? + resolveWorkflowMultiSelectLabel(subBlock, rawValue, lookup) + ) + }, [workflowMapForLookup, workflowMapLoaded, workflowMapIsPlaceholder, subBlock, rawValue]) const { data: mcpServers = [] } = useMcpServers(workspaceId || '') const mcpServerDisplayName = useMemo(() => { @@ -671,145 +326,29 @@ const SubBlockRow = memo(function SubBlockRow({ isEqual ) - const variablesDisplayValue = useMemo(() => { - if (subBlock?.type !== 'variables-input' || !isVariableAssignmentsArray(rawValue)) { - return null - } - - const variablesArray = Object.values(workflowVariables) - - const names = rawValue - .map((a) => { - if (a.variableId) { - const variable = variablesArray.find((v: any) => v.id === a.variableId) - return variable?.name - } - if (a.variableName) return a.variableName - return null - }) - .filter((name): name is string => !!name) - - if (names.length === 0) return null - if (names.length === 1) return names[0] - if (names.length === 2) return `${names[0]}, ${names[1]}` - return `${names[0]}, ${names[1]} +${names.length - 2}` - }, [subBlock?.type, rawValue, workflowVariables]) + const variablesDisplayValue = useMemo( + () => resolveVariablesLabel(subBlock, rawValue, Object.values(workflowVariables)), + [subBlock, rawValue, workflowVariables] + ) - /** - * Hydrates tool references to display names. - * Follows the same pattern as other selectors (Slack channels, MCP tools, etc.) - */ + /** Hydrates tool references to display names. */ const { data: customTools = [] } = useCustomTools(workspaceId || '') + const toolsDisplayValue = useMemo( + () => resolveToolsLabel(subBlock, rawValue, customTools), + [subBlock, rawValue, customTools] + ) - const toolsDisplayValue = useMemo(() => { - if (subBlock?.type !== 'tool-input' || !Array.isArray(rawValue) || rawValue.length === 0) { - return null - } - - const toolNames = rawValue - .map((tool: any) => { - if (!tool || typeof tool !== 'object') return null - - // Priority 1: Use tool.title if already populated - if (tool.title && typeof tool.title === 'string') return tool.title - - // Priority 2: Resolve custom tools with reference ID from database - if (tool.type === 'custom-tool' && tool.customToolId) { - const customTool = customTools.find((t) => t.id === tool.customToolId) - if (customTool?.title) return customTool.title - if (customTool?.schema?.function?.name) return customTool.schema.function.name - } - - // Priority 3: Extract from inline schema (legacy format) - if (tool.schema?.function?.name) return tool.schema.function.name - - // Priority 4: Extract from OpenAI function format - if (tool.function?.name) return tool.function.name - - // Priority 5: Resolve built-in tool blocks from registry - if ( - typeof tool.type === 'string' && - tool.type !== 'custom-tool' && - tool.type !== 'mcp' && - tool.type !== 'workflow' && - tool.type !== 'workflow_input' - ) { - const blockConfig = getBlock(tool.type) - if (blockConfig?.name) return blockConfig.name - } - - return null - }) - .filter((name): name is string => !!name) - - if (toolNames.length === 0) return null - if (toolNames.length === 1) return toolNames[0] - if (toolNames.length === 2) return `${toolNames[0]}, ${toolNames[1]}` - return `${toolNames[0]}, ${toolNames[1]} +${toolNames.length - 2}` - }, [subBlock?.type, rawValue, customTools, workspaceId]) - - const filterDisplayValue = useMemo(() => { - const isFilterField = - subBlock?.id === 'filter' || subBlock?.id === 'filterCriteria' || subBlock?.id === 'sort' - - if (!isFilterField || !rawValue) return null - - const parsedValue = tryParseJson(rawValue) - - if (isPlainObject(parsedValue) || Array.isArray(parsedValue)) { - try { - const jsonStr = JSON.stringify(parsedValue, null, 0) - if (jsonStr.length <= 35) return jsonStr - return `${jsonStr.slice(0, 32)}...` - } catch { - return null - } - } - - return null - }, [subBlock?.id, rawValue]) + const filterDisplayValue = useMemo( + () => resolveFilterFieldLabel(subBlock, rawValue), + [subBlock, rawValue] + ) - /** - * Hydrates skill references to display names. - * Resolves skill IDs to their current names from the skills query. - */ + /** Hydrates skill references to display names. */ const { data: workspaceSkills = [] } = useSkills(workspaceId || '') - - const skillsDisplayValue = useMemo(() => { - if (subBlock?.type !== 'skill-input' || !Array.isArray(rawValue) || rawValue.length === 0) { - return null - } - - interface StoredSkill { - skillId: string - name?: string - } - - const skillNames = rawValue - .map((skill: StoredSkill) => { - if (!skill || typeof skill !== 'object') return null - - // Priority 1: Resolve skill name from the skills query (fresh data) - if (skill.skillId) { - const foundSkill = workspaceSkills.find((s) => s.id === skill.skillId) - if (foundSkill?.name) return foundSkill.name - } - - // Priority 2: Fall back to stored name (for deleted skills) - if (skill.name && typeof skill.name === 'string') return skill.name - - // Priority 3: Use skillId as last resort - if (skill.skillId) return skill.skillId - - return null - }) - .filter((name): name is string => !!name) - - if (skillNames.length === 0) return null - if (skillNames.length === 1) return skillNames[0] - if (skillNames.length === 2) return `${skillNames[0]}, ${skillNames[1]}` - return `${skillNames[0]}, ${skillNames[1]} +${skillNames.length - 2}` - }, [subBlock?.type, rawValue, workspaceSkills]) + const skillsDisplayValue = useMemo( + () => resolveSkillsLabel(subBlock, rawValue, workspaceSkills), + [subBlock, rawValue, workspaceSkills] + ) const isPasswordField = subBlock?.password === true const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 3a48885147..7130ec1b7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -3,14 +3,21 @@ import { type CSSProperties, memo, useMemo } from 'react' import { Handle, type NodeProps, Position } from 'reactflow' import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' +import { + getDisplayValue, + resolveDropdownLabel, + resolveSkillsLabel, + resolveToolsLabel, + resolveVariablesLabel, + resolveWorkflowMultiSelectLabel, + resolveWorkflowSelectionLabel, +} from '@/lib/workflows/subblocks/display' import { buildCanonicalIndex, evaluateSubBlockCondition, isSubBlockFeatureEnabled, isSubBlockVisibleForMode, } from '@/lib/workflows/subblocks/visibility' -import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' -import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getBlock } from '@/blocks' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { useVariablesStore } from '@/stores/variables/store' @@ -83,142 +90,6 @@ interface SubBlockRowProps { workflowLabelsReady: boolean } -/** - * Resolves dropdown/combobox value to its display label. - * Returns null if not a dropdown/combobox or no matching option found. - */ -function resolveDropdownLabel( - subBlock: SubBlockConfig | undefined, - rawValue: unknown -): string | null { - if (!subBlock || (subBlock.type !== 'dropdown' && subBlock.type !== 'combobox')) return null - if (!rawValue || typeof rawValue !== 'string') return null - - const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options - if (!options) return null - - const option = options.find((opt) => - typeof opt === 'string' ? opt === rawValue : opt.id === rawValue - ) - - if (!option) return null - return typeof option === 'string' ? option : option.label -} - -/** - * Resolves workflow ID to workflow name using the workflow registry. - * Uses synchronous store access to avoid hook dependencies. - */ -function resolveWorkflowName( - subBlock: SubBlockConfig | undefined, - rawValue: unknown, - workflowMap: Record, - workflowLabelsReady: boolean -): string | null { - if (subBlock?.type !== 'workflow-selector') return null - if (!rawValue || typeof rawValue !== 'string') return null - if (!workflowLabelsReady) return null - - return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL -} - -/** - * Type guard for variable assignments array - */ -function isVariableAssignmentsArray( - value: unknown -): value is Array<{ id?: string; variableId?: string; variableName?: string; value: unknown }> { - return ( - Array.isArray(value) && - value.length > 0 && - value.every( - (item) => - typeof item === 'object' && - item !== null && - ('variableName' in item || 'variableId' in item) - ) - ) -} - -/** - * Resolves variables-input to display names. - * Uses synchronous store access to avoid hook dependencies. - */ -function resolveVariablesDisplay( - subBlock: SubBlockConfig | undefined, - rawValue: unknown -): string | null { - if (subBlock?.type !== 'variables-input') return null - if (!isVariableAssignmentsArray(rawValue)) return null - - const variables = useVariablesStore.getState().variables - const variablesArray = Object.values(variables) - - const names = rawValue - .map((a) => { - if (a.variableId) { - const variable = variablesArray.find((v) => v.id === a.variableId) - return variable?.name - } - if (a.variableName) return a.variableName - return null - }) - .filter((name): name is string => !!name) - - if (names.length === 0) return null - if (names.length === 1) return names[0] - if (names.length === 2) return `${names[0]}, ${names[1]}` - return `${names[0]}, ${names[1]} +${names.length - 2}` -} - -/** - * Resolves tool-input to display names. - * Resolves built-in tools from block registry (no API needed). - */ -function resolveToolsDisplay( - subBlock: SubBlockConfig | undefined, - rawValue: unknown -): string | null { - if (subBlock?.type !== 'tool-input') return null - if (!Array.isArray(rawValue) || rawValue.length === 0) return null - - const toolNames = rawValue - .map((tool: unknown) => { - if (!tool || typeof tool !== 'object') return null - const t = tool as Record - - if (t.title && typeof t.title === 'string') return t.title - - const schema = t.schema as Record | undefined - if (schema?.function && typeof schema.function === 'object') { - const fn = schema.function as Record - if (fn.name && typeof fn.name === 'string') return fn.name - } - - const fn = t.function as Record | undefined - if (fn?.name && typeof fn.name === 'string') return fn.name - - if ( - typeof t.type === 'string' && - t.type !== 'custom-tool' && - t.type !== 'mcp' && - t.type !== 'workflow' && - t.type !== 'workflow_input' - ) { - const blockConfig = getBlock(t.type) - if (blockConfig?.name) return blockConfig.name - } - - return null - }) - .filter((name): name is string => !!name) - - if (toolNames.length === 0) return null - if (toolNames.length === 1) return toolNames[0] - if (toolNames.length === 2) return `${toolNames[0]}, ${toolNames[1]}` - return `${toolNames[0]}, ${toolNames[1]} +${toolNames.length - 2}` -} - /** * Renders a single subblock row with title and optional value. * Matches the SubBlockRow component in WorkflowBlock. @@ -226,7 +97,7 @@ function resolveToolsDisplay( * - Resolves dropdown/combobox labels * - Resolves workflow names from registry * - Resolves variable names from store - * - Resolves tool names from block registry + * - Resolves tool and skill names (registry + stored names; no API access) * - Shows '-' for other selector types that need hydration */ const SubBlockRow = memo(function SubBlockRow({ @@ -240,14 +111,37 @@ const SubBlockRow = memo(function SubBlockRow({ const isPasswordField = subBlock?.password === true const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null + const workflowLookup = { workflowMap, ready: workflowLabelsReady } const dropdownLabel = resolveDropdownLabel(subBlock, rawValue) - const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue) - const toolsDisplay = resolveToolsDisplay(subBlock, rawValue) - const workflowName = resolveWorkflowName(subBlock, rawValue, workflowMap, workflowLabelsReady) + // Materialize the variables store only for variables-input rows. + const variablesDisplay = + subBlock?.type === 'variables-input' + ? resolveVariablesLabel( + subBlock, + rawValue, + Object.values(useVariablesStore.getState().variables) + ) + : null + // The preview is hook-free, so custom tools referenced only by id resolve + // through their inline schema/registry fallbacks rather than the API. + const toolsDisplay = resolveToolsLabel(subBlock, rawValue, []) + const skillsDisplay = resolveSkillsLabel(subBlock, rawValue, []) + const workflowName = resolveWorkflowSelectionLabel(subBlock, rawValue, workflowLookup) + const workflowMultiSelectionNames = resolveWorkflowMultiSelectLabel( + subBlock, + rawValue, + workflowLookup + ) const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type) - const hydratedName = dropdownLabel || variablesDisplay || toolsDisplay || workflowName + const hydratedName = + dropdownLabel || + variablesDisplay || + toolsDisplay || + skillsDisplay || + workflowName || + workflowMultiSelectionNames const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index f8dddc56ab..eb5a8fe2b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -234,10 +234,12 @@ export function PreviewWorkflow({ const workspaceId = propWorkspaceId ?? params.workspaceId const { data: workflowMap = {}, - isLoading: isWorkflowMapLoading, + isSuccess: isWorkflowMapLoaded, isPlaceholderData: isWorkflowMapPlaceholderData, } = useWorkflowMap(workspaceId) - const workflowLabelsReady = !isWorkflowMapLoading && !isWorkflowMapPlaceholderData + // Ready only on a successful, non-placeholder load — an errored or stale + // placeholder map must not mislabel valid workflows as deleted. + const workflowLabelsReady = isWorkflowMapLoaded && !isWorkflowMapPlaceholderData const containerRef = useRef(null) const nodeTypes = previewNodeTypes const isValidWorkflowState = workflowState?.blocks && workflowState.edges diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts deleted file mode 100644 index 76c2333d9f..0000000000 --- a/apps/sim/background/workspace-notification-delivery.ts +++ /dev/null @@ -1,789 +0,0 @@ -import { db, workflowExecutionLogs } from '@sim/db' -import { - account, - workspaceNotificationDelivery, - workspaceNotificationSubscription, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { hmacSha256Hex } from '@sim/security/hmac' -import { toError } from '@sim/utils/errors' -import { formatDuration } from '@sim/utils/formatting' -import { generateId } from '@sim/utils/id' -import { randomFloat } from '@sim/utils/random' -import { truncate } from '@sim/utils/string' -import { getActiveWorkflowContext } from '@sim/workflow-authz' -import { task } from '@trigger.dev/sdk' -import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' -import { - type EmailRateLimitsData, - type EmailUsageData, - renderWorkflowNotificationEmail, -} from '@/components/emails' -import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { RateLimiter } from '@/lib/core/rate-limiter' -import { decryptSecret } from '@/lib/core/security/encryption' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' -import { sendEmail } from '@/lib/messaging/email/mailer' -import type { AlertConfig } from '@/lib/notifications/alert-rules' -import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' - -const logger = createLogger('WorkspaceNotificationDelivery') - -const MAX_ATTEMPTS = 5 -const RETRY_DELAYS = [5 * 1000, 15 * 1000, 60 * 1000, 3 * 60 * 1000, 10 * 60 * 1000] -function getRetryDelayWithJitter(baseDelay: number): number { - const jitter = randomFloat() * 0.1 * baseDelay - return Math.floor(baseDelay + jitter) -} - -interface NotificationPayload { - id: string - type: 'workflow.execution.completed' - timestamp: number - data: { - workflowId: string - workflowName?: string - executionId: string - status: 'success' | 'error' - level: string - trigger: string - startedAt: string - endedAt: string - totalDurationMs: number - cost?: Record - finalOutput?: unknown - traceSpans?: TraceSpan[] - rateLimits?: EmailRateLimitsData - usage?: EmailUsageData - } -} - -function generateSignature(secret: string, timestamp: number, body: string): string { - const signatureBase = `${timestamp}.${body}` - return hmacSha256Hex(signatureBase, secret) -} - -async function buildPayload( - log: WorkflowExecutionLog, - subscription: typeof workspaceNotificationSubscription.$inferSelect -): Promise { - /** - * Skip notifications when the workflow or workspace has already been archived. - */ - if (!log.workflowId) return null - - const workflowContext = await getActiveWorkflowContext(log.workflowId) - - const timestamp = Date.now() - const executionData = (log.executionData || {}) as Record - if (!workflowContext?.workspaceId) { - return null - } - const userId = workflowContext.workspaceId - ? await getWorkspaceBilledAccountUserId(workflowContext.workspaceId) - : null - - const payload: NotificationPayload = { - id: `evt_${generateId()}`, - type: 'workflow.execution.completed', - timestamp, - data: { - workflowId: log.workflowId, - workflowName: workflowContext.workflow.name || 'Unknown Workflow', - executionId: log.executionId, - status: log.level === 'error' ? 'error' : 'success', - level: log.level, - trigger: log.trigger, - startedAt: log.startedAt, - endedAt: log.endedAt, - totalDurationMs: log.totalDurationMs, - cost: log.cost as Record, - }, - } - - if (subscription.includeFinalOutput && executionData.finalOutput) { - payload.data.finalOutput = executionData.finalOutput - } - - // Trace spans only included for webhooks (too large for email/Slack) - if ( - subscription.includeTraceSpans && - subscription.notificationType === 'webhook' && - executionData.traceSpans - ) { - payload.data.traceSpans = executionData.traceSpans as TraceSpan[] - } - - if (subscription.includeRateLimits && userId) { - try { - const userSubscription = await getHighestPrioritySubscription(userId) - const rateLimiter = new RateLimiter() - const triggerType = log.trigger === 'api' ? 'api' : 'manual' - - const [syncStatus, asyncStatus] = await Promise.all([ - rateLimiter.getRateLimitStatusWithSubscription( - userId, - userSubscription, - triggerType, - false - ), - rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, triggerType, true), - ]) - - payload.data.rateLimits = { - sync: { - requestsPerMinute: syncStatus.requestsPerMinute, - maxBurst: syncStatus.maxBurst, - remaining: syncStatus.remaining, - resetAt: syncStatus.resetAt.toISOString(), - }, - async: { - requestsPerMinute: asyncStatus.requestsPerMinute, - maxBurst: asyncStatus.maxBurst, - remaining: asyncStatus.remaining, - resetAt: asyncStatus.resetAt.toISOString(), - }, - } - } catch (error) { - logger.warn('Failed to fetch rate limits for notification', { error, userId }) - } - } - - if (subscription.includeUsageData && userId) { - try { - const usageData = await checkUsageStatus(userId) - payload.data.usage = { - currentPeriodCost: usageData.currentUsage, - limit: usageData.limit, - percentUsed: usageData.percentUsed, - isExceeded: usageData.isExceeded, - } - } catch (error) { - logger.warn('Failed to fetch usage data for notification', { error, userId }) - } - } - - return payload -} - -interface WebhookConfig { - url: string - secret?: string -} - -interface SlackConfig { - channelId: string - channelName: string - accountId: string -} - -async function deliverWebhook( - subscription: typeof workspaceNotificationSubscription.$inferSelect, - payload: NotificationPayload -): Promise<{ success: boolean; status?: number; error?: string }> { - const webhookConfig = subscription.webhookConfig as WebhookConfig | null - if (!webhookConfig?.url) { - return { success: false, error: 'No webhook URL configured' } - } - - const body = JSON.stringify(payload) - const deliveryId = `delivery_${generateId()}` - const headers: Record = { - 'Content-Type': 'application/json', - 'sim-event': 'workflow.execution.completed', - 'sim-timestamp': payload.timestamp.toString(), - 'sim-delivery-id': deliveryId, - 'Idempotency-Key': deliveryId, - } - - if (webhookConfig.secret) { - const { decrypted } = await decryptSecret(webhookConfig.secret) - const signature = generateSignature(decrypted, payload.timestamp, body) - headers['sim-signature'] = `t=${payload.timestamp},v1=${signature}` - } - - try { - const response = await secureFetchWithValidation( - webhookConfig.url, - { - method: 'POST', - headers, - body, - timeout: 30000, - allowHttp: true, - }, - 'webhookUrl' - ) - - return { - success: response.ok, - status: response.status, - error: response.ok ? undefined : `HTTP ${response.status}`, - } - } catch (error: unknown) { - logger.warn('Webhook delivery failed', { - error: toError(error).message, - webhookUrl: webhookConfig.url, - }) - return { - success: false, - error: 'Failed to deliver webhook', - } - } -} - -function formatCost(cost?: Record): string { - if (!cost?.total) return 'N/A' - const total = cost.total as number - return `${dollarsToCredits(total).toLocaleString()} credits` -} - -function buildLogUrl(workspaceId: string, executionId: string): string { - return `${getBaseUrl()}/workspace/${workspaceId}/logs?executionId=${encodeURIComponent(executionId)}` -} - -function formatAlertReason(alertConfig: AlertConfig): string { - switch (alertConfig.rule) { - case 'consecutive_failures': - return `${alertConfig.consecutiveFailures} consecutive failures detected` - case 'failure_rate': - return `Failure rate exceeded ${alertConfig.failureRatePercent}% over ${alertConfig.windowHours}h` - case 'latency_threshold': - return `Execution exceeded ${Math.round((alertConfig.durationThresholdMs || 0) / 1000)}s duration threshold` - case 'latency_spike': - return `Execution was ${alertConfig.latencySpikePercent}% slower than average` - case 'cost_threshold': - return `Execution cost exceeded ${dollarsToCredits(alertConfig.costThresholdDollars || 0).toLocaleString()} credits threshold` - case 'no_activity': - return `No workflow activity detected in ${alertConfig.inactivityHours}h` - case 'error_count': - return `${alertConfig.errorCountThreshold} errors detected in ${alertConfig.windowHours}h window` - default: - return 'Alert condition met' - } -} - -async function deliverEmail( - subscription: typeof workspaceNotificationSubscription.$inferSelect, - payload: NotificationPayload, - alertConfig?: AlertConfig -): Promise<{ success: boolean; error?: string }> { - if (!subscription.emailRecipients || subscription.emailRecipients.length === 0) { - return { success: false, error: 'No email recipients configured' } - } - - const isError = payload.data.status !== 'success' - const statusText = isError ? 'Error' : 'Success' - const logUrl = buildLogUrl(subscription.workspaceId, payload.data.executionId) - const alertReason = alertConfig ? formatAlertReason(alertConfig) : undefined - - // Build subject line - const subject = alertReason - ? `Alert: ${payload.data.workflowName}` - : isError - ? `Error Alert: ${payload.data.workflowName}` - : `Workflow Completed: ${payload.data.workflowName}` - - // Build plain text for fallback - let includedDataText = '' - if (payload.data.finalOutput) { - includedDataText += `\n\nFinal Output:\n${JSON.stringify(payload.data.finalOutput, null, 2)}` - } - if (payload.data.rateLimits) { - includedDataText += `\n\nRate Limits:\n${JSON.stringify(payload.data.rateLimits, null, 2)}` - } - if (payload.data.usage) { - includedDataText += `\n\nUsage Data:\n${JSON.stringify(payload.data.usage, null, 2)}` - } - - // Render the email using the shared template - const html = await renderWorkflowNotificationEmail({ - workflowName: payload.data.workflowName || 'Unknown Workflow', - status: payload.data.status, - trigger: payload.data.trigger, - duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-', - cost: formatCost(payload.data.cost), - logUrl, - alertReason, - finalOutput: payload.data.finalOutput, - rateLimits: payload.data.rateLimits, - usageData: payload.data.usage, - }) - - const result = await sendEmail({ - to: subscription.emailRecipients, - subject, - html, - text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`, - emailType: 'notifications', - }) - - return { success: result.success, error: result.success ? undefined : result.message } -} - -async function deliverSlack( - subscription: typeof workspaceNotificationSubscription.$inferSelect, - payload: NotificationPayload, - alertConfig?: AlertConfig -): Promise<{ success: boolean; error?: string }> { - const slackConfig = subscription.slackConfig as SlackConfig | null - if (!slackConfig?.channelId || !slackConfig?.accountId) { - return { success: false, error: 'No Slack channel or account configured' } - } - - const [slackAccount] = await db - .select({ accessToken: account.accessToken, userId: account.userId }) - .from(account) - .where(eq(account.id, slackConfig.accountId)) - .limit(1) - - if (!slackAccount?.accessToken) { - return { success: false, error: 'Slack account not found or not connected' } - } - - const alertReason = alertConfig ? formatAlertReason(alertConfig) : null - const statusEmoji = alertReason - ? ':warning:' - : payload.data.status === 'success' - ? ':white_check_mark:' - : ':x:' - const statusColor = alertReason - ? '#d97706' - : payload.data.status === 'success' - ? '#22c55e' - : '#ef4444' - const logUrl = buildLogUrl(subscription.workspaceId, payload.data.executionId) - - const blocks: Array> = [] - - if (alertReason) { - blocks.push({ - type: 'section', - text: { - type: 'mrkdwn', - text: `*Reason:* ${alertReason}`, - }, - }) - } - - blocks.push( - { - type: 'section', - fields: [ - { type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` }, - { type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` }, - { - type: 'mrkdwn', - text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`, - }, - { type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` }, - ], - }, - { - type: 'actions', - elements: [ - { - type: 'button', - text: { type: 'plain_text', text: 'View Log →', emoji: true }, - url: logUrl, - style: 'primary', - }, - ], - } - ) - - if (payload.data.finalOutput) { - const outputStr = JSON.stringify(payload.data.finalOutput, null, 2) - const truncated = truncate(outputStr, 2900) - blocks.push({ - type: 'section', - text: { - type: 'mrkdwn', - text: `*Final Output:*\n\`\`\`${truncated}\`\`\``, - }, - }) - } - - if (payload.data.rateLimits) { - const limitsStr = JSON.stringify(payload.data.rateLimits, null, 2) - blocks.push({ - type: 'section', - text: { - type: 'mrkdwn', - text: `*Rate Limits:*\n\`\`\`${limitsStr}\`\`\``, - }, - }) - } - - if (payload.data.usage) { - const usageStr = JSON.stringify(payload.data.usage, null, 2) - blocks.push({ - type: 'section', - text: { - type: 'mrkdwn', - text: `*Usage Data:*\n\`\`\`${usageStr}\`\`\``, - }, - }) - } - - blocks.push({ - type: 'context', - elements: [{ type: 'mrkdwn', text: `Execution ID: \`${payload.data.executionId}\`` }], - }) - - const fallbackText = alertReason - ? `⚠️ Alert: ${payload.data.workflowName} - ${alertReason}` - : `${payload.data.status === 'success' ? '✅' : '❌'} Workflow ${payload.data.workflowName}: ${payload.data.status}` - - const slackPayload = { - channel: slackConfig.channelId, - attachments: [{ color: statusColor, blocks }], - text: fallbackText, - } - - try { - const response = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${slackAccount.accessToken}`, - }, - body: JSON.stringify(slackPayload), - }) - - const result = await response.json() - - return { success: result.ok, error: result.ok ? undefined : result.error } - } catch (error: unknown) { - const err = error as Error - return { success: false, error: err.message } - } -} - -async function updateDeliveryStatus( - deliveryId: string, - status: 'success' | 'failed' | 'pending', - error?: string, - responseStatus?: number, - nextAttemptAt?: Date -) { - await db - .update(workspaceNotificationDelivery) - .set({ - status, - errorMessage: error || null, - responseStatus: responseStatus || null, - nextAttemptAt: nextAttemptAt || null, - updatedAt: new Date(), - }) - .where(eq(workspaceNotificationDelivery.id, deliveryId)) -} - -export interface NotificationDeliveryParams { - deliveryId: string - subscriptionId: string - workspaceId: string - notificationType: 'webhook' | 'email' | 'slack' - log: WorkflowExecutionLog - alertConfig?: AlertConfig -} - -export type NotificationDeliveryResult = - | { status: 'success' | 'skipped' | 'failed' } - | { status: 'retry'; retryDelayMs: number } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function formatLogDate(value: Date | string | null | undefined, fallback = ''): string { - if (value instanceof Date) { - return value.toISOString() - } - return typeof value === 'string' ? value : fallback -} - -function normalizeLogFiles(value: unknown): WorkflowExecutionLog['files'] { - if (!Array.isArray(value)) { - return undefined - } - - return value.filter( - (file): file is NonNullable[number] => - isRecord(file) && - typeof file.id === 'string' && - typeof file.name === 'string' && - typeof file.size === 'number' && - typeof file.type === 'string' && - typeof file.url === 'string' && - typeof file.key === 'string' - ) -} - -async function normalizeWorkflowExecutionLog( - row: typeof workflowExecutionLogs.$inferSelect -): Promise { - const startedAt = formatLogDate(row.startedAt) - - // Heavy execution data may live in object storage; resolve the pointer so - // retry deliveries get finalOutput/traceSpans (no-op for inline rows). - const executionData = await materializeExecutionData( - isRecord(row.executionData) ? row.executionData : {}, - { workspaceId: row.workspaceId, workflowId: row.workflowId, executionId: row.executionId } - ) - - return { - id: row.id, - workflowId: row.workflowId, - executionId: row.executionId, - stateSnapshotId: row.stateSnapshotId, - level: row.level === 'error' ? 'error' : 'info', - trigger: row.trigger, - startedAt, - endedAt: formatLogDate(row.endedAt, startedAt), - totalDurationMs: row.totalDurationMs ?? 0, - files: normalizeLogFiles(row.files), - executionData: executionData as WorkflowExecutionLog['executionData'], - // cost_total projection of the usage_log ledger (not the deprecated jsonb). - cost: row.costTotal != null ? { total: Number(row.costTotal) } : undefined, - createdAt: formatLogDate(row.createdAt, startedAt), - } -} - -async function buildRetryLog(params: NotificationDeliveryParams): Promise { - const conditions = [eq(workflowExecutionLogs.executionId, params.log.executionId)] - if (params.log.workflowId) { - conditions.push(eq(workflowExecutionLogs.workflowId, params.log.workflowId)) - } - - const [storedLog] = await db - .select() - .from(workflowExecutionLogs) - .where(and(...conditions)) - .limit(1) - - if (storedLog) { - return await normalizeWorkflowExecutionLog(storedLog) - } - - const now = new Date().toISOString() - return { - id: `retry_log_${params.deliveryId}`, - workflowId: params.log.workflowId, - executionId: params.log.executionId, - stateSnapshotId: '', - level: 'info', - trigger: 'system', - startedAt: now, - endedAt: now, - totalDurationMs: 0, - executionData: {}, - cost: { total: 0 }, - createdAt: now, - } -} - -export async function enqueueNotificationDeliveryDispatch( - _params: NotificationDeliveryParams -): Promise { - return false -} - -const STUCK_IN_PROGRESS_THRESHOLD_MS = 5 * 60 * 1000 - -export async function sweepPendingNotificationDeliveries(limit = 50): Promise { - const stuckThreshold = new Date(Date.now() - STUCK_IN_PROGRESS_THRESHOLD_MS) - - await db - .update(workspaceNotificationDelivery) - .set({ - status: 'pending', - updatedAt: new Date(), - }) - .where( - and( - eq(workspaceNotificationDelivery.status, 'in_progress'), - lte(workspaceNotificationDelivery.lastAttemptAt, stuckThreshold) - ) - ) - - const dueDeliveries = await db - .select({ - deliveryId: workspaceNotificationDelivery.id, - subscriptionId: workspaceNotificationDelivery.subscriptionId, - workflowId: workspaceNotificationDelivery.workflowId, - executionId: workspaceNotificationDelivery.executionId, - workspaceId: workspaceNotificationSubscription.workspaceId, - alertConfig: workspaceNotificationSubscription.alertConfig, - notificationType: workspaceNotificationSubscription.notificationType, - }) - .from(workspaceNotificationDelivery) - .innerJoin( - workspaceNotificationSubscription, - eq(workspaceNotificationDelivery.subscriptionId, workspaceNotificationSubscription.id) - ) - .where( - and( - eq(workspaceNotificationDelivery.status, 'pending'), - or( - isNull(workspaceNotificationDelivery.nextAttemptAt), - lte(workspaceNotificationDelivery.nextAttemptAt, new Date()) - ) - ) - ) - .limit(limit) - - let enqueued = 0 - - for (const delivery of dueDeliveries) { - const params: NotificationDeliveryParams = { - deliveryId: delivery.deliveryId, - subscriptionId: delivery.subscriptionId, - workspaceId: delivery.workspaceId, - notificationType: delivery.notificationType, - log: await buildRetryLog({ - deliveryId: delivery.deliveryId, - subscriptionId: delivery.subscriptionId, - workspaceId: delivery.workspaceId, - notificationType: delivery.notificationType, - log: { - id: '', - workflowId: delivery.workflowId, - executionId: delivery.executionId, - stateSnapshotId: '', - level: 'info', - trigger: 'system', - startedAt: '', - endedAt: '', - totalDurationMs: 0, - executionData: {}, - cost: { total: 0 }, - createdAt: '', - }, - alertConfig: (delivery.alertConfig as AlertConfig | null) ?? undefined, - }), - alertConfig: (delivery.alertConfig as AlertConfig | null) ?? undefined, - } - - if (await enqueueNotificationDeliveryDispatch(params)) { - enqueued += 1 - } - } - - return enqueued -} - -export async function executeNotificationDelivery( - params: NotificationDeliveryParams -): Promise { - const { deliveryId, subscriptionId, notificationType, log, alertConfig } = params - - try { - const [subscription] = await db - .select() - .from(workspaceNotificationSubscription) - .where(eq(workspaceNotificationSubscription.id, subscriptionId)) - .limit(1) - - if (!subscription || !subscription.active) { - logger.warn(`Subscription ${subscriptionId} not found or inactive`) - await updateDeliveryStatus(deliveryId, 'failed', 'Subscription not found or inactive') - return { status: 'failed' } - } - - const claimed = await db - .update(workspaceNotificationDelivery) - .set({ - status: 'in_progress', - attempts: sql`${workspaceNotificationDelivery.attempts} + 1`, - lastAttemptAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(workspaceNotificationDelivery.id, deliveryId), - eq(workspaceNotificationDelivery.status, 'pending'), - or( - isNull(workspaceNotificationDelivery.nextAttemptAt), - lte(workspaceNotificationDelivery.nextAttemptAt, new Date()) - ) - ) - ) - .returning({ attempts: workspaceNotificationDelivery.attempts }) - - if (claimed.length === 0) { - logger.info(`Delivery ${deliveryId} not claimable`) - return { status: 'skipped' } - } - - const attempts = claimed[0].attempts - const payload = await buildPayload(log, subscription) - - // Skip delivery for deleted workflows - if (!payload) { - await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was archived or deleted') - logger.info(`Skipping delivery ${deliveryId} - workflow was archived or deleted`) - return { status: 'failed' } - } - - let result: { success: boolean; status?: number; error?: string } - - switch (notificationType) { - case 'webhook': - result = await deliverWebhook(subscription, payload) - break - case 'email': - result = await deliverEmail(subscription, payload, alertConfig) - break - case 'slack': - result = await deliverSlack(subscription, payload, alertConfig) - break - default: - result = { success: false, error: 'Unknown notification type' } - } - - if (result.success) { - await updateDeliveryStatus(deliveryId, 'success', undefined, result.status) - logger.info(`${notificationType} notification delivered successfully`, { deliveryId }) - return { status: 'success' } - } - if (attempts < MAX_ATTEMPTS) { - const retryDelay = getRetryDelayWithJitter( - RETRY_DELAYS[attempts - 1] || RETRY_DELAYS[RETRY_DELAYS.length - 1] - ) - const nextAttemptAt = new Date(Date.now() + retryDelay) - - await updateDeliveryStatus(deliveryId, 'pending', result.error, result.status, nextAttemptAt) - - logger.info( - `${notificationType} notification failed, scheduled retry ${attempts}/${MAX_ATTEMPTS}`, - { - deliveryId, - error: result.error, - } - ) - return { status: 'retry', retryDelayMs: retryDelay } - } - await updateDeliveryStatus(deliveryId, 'failed', result.error, result.status) - logger.error(`${notificationType} notification failed after ${MAX_ATTEMPTS} attempts`, { - deliveryId, - error: result.error, - }) - return { status: 'failed' } - } catch (error) { - logger.error('Notification delivery failed', { deliveryId, error }) - await updateDeliveryStatus(deliveryId, 'failed', 'Internal error') - return { status: 'failed' } - } -} - -export const workspaceNotificationDeliveryTask = task({ - id: 'workspace-notification-delivery', - retry: { maxAttempts: 1 }, - run: async (params: NotificationDeliveryParams) => executeNotificationDelivery(params), -}) diff --git a/apps/sim/blocks/blocks/enrichment.ts b/apps/sim/blocks/blocks/enrichment.ts index fea75a1937..49b3d7f2a3 100644 --- a/apps/sim/blocks/blocks/enrichment.ts +++ b/apps/sim/blocks/blocks/enrichment.ts @@ -76,8 +76,8 @@ export const EnrichmentBlock: BlockConfig = { description: 'Enrich data with a Sim enrichment', longDescription: 'Run a Sim enrichment to look up data — work email, phone number, company domain, company info, and more — from the fields you map in. Uses the same provider cascade as table enrichments.', - docsLink: 'https://docs.sim.ai/tools/enrichment', - category: 'tools', + docsLink: 'https://docs.sim.ai/blocks/enrichment', + category: 'blocks', integrationType: IntegrationType.Sales, bgColor: '#9333EA', icon: EnrichmentIcon, diff --git a/apps/sim/blocks/blocks/logs.ts b/apps/sim/blocks/blocks/logs.ts index d7665089f9..e11aac9f73 100644 --- a/apps/sim/blocks/blocks/logs.ts +++ b/apps/sim/blocks/blocks/logs.ts @@ -1,9 +1,11 @@ import { Library } from '@/components/emcn/icons' +import { fetchWorkspaceWorkflowOptions } from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' export const LogsBlock: BlockConfig = { type: 'logs', name: 'Logs', + hideFromToolbar: true, description: 'Query workflow execution logs', longDescription: 'Search workflow execution logs in the current workspace, fetch a single log by id, or load full execution details with the per-block state snapshot.', @@ -251,3 +253,350 @@ export const LogsBlock: BlockConfig = { }, }, } + +const COMPARISON_OPERATOR_OPTIONS = [ + { label: '=', id: '=' }, + { label: '>', id: '>' }, + { label: '<', id: '<' }, + { label: '>=', id: '>=' }, + { label: '<=', id: '<=' }, + { label: '!=', id: '!=' }, +] + +/** Preset time windows mirroring the Logs page time-range filter. */ +const TIME_RANGE_MS: Record = { + 'past-30-minutes': 30 * 60 * 1000, + 'past-hour': 60 * 60 * 1000, + 'past-6-hours': 6 * 60 * 60 * 1000, + 'past-12-hours': 12 * 60 * 60 * 1000, + 'past-24-hours': 24 * 60 * 60 * 1000, + 'past-3-days': 3 * 24 * 60 * 60 * 1000, + 'past-7-days': 7 * 24 * 60 * 60 * 1000, + 'past-14-days': 14 * 24 * 60 * 60 * 1000, + 'past-30-days': 30 * 24 * 60 * 60 * 1000, +} + +/** Normalizes multi-select arrays or comma strings into a comma-separated string. */ +function joinIds(value: unknown): string | undefined { + if (Array.isArray(value)) { + const ids = value.filter((id): id is string => typeof id === 'string' && id.length > 0) + return ids.length > 0 ? ids.join(',') : undefined + } + if (typeof value === 'string' && value.trim().length > 0) return value.trim() + return undefined +} + +export const LogsV2Block: BlockConfig = { + type: 'logs_v2', + name: 'Logs', + description: 'Query workflow runs and fetch run details', + longDescription: + 'Query workflow run logs in the current workspace with the same filters as the Logs page, returning matching run IDs. Fetch full details for a single run, including its trace spans.', + bgColor: '#EAB308', + bestPractices: ` + - The block always operates on the current workspace; you cannot query other workspaces. + - 'Query Logs' returns only run IDs, ordered by the sort settings (newest first by default). Feed an ID into 'Get Run Details' for the full picture. + - 'Get Run Details' returns the run summary plus the full trace spans (per-block inputs, outputs, and timings). + - Cost filters and outputs are denominated in credits. + `, + icon: Library, + category: 'blocks', + docsLink: 'https://docs.sim.ai/blocks/logs', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Query Logs', id: 'query' }, + { label: 'Get Run Details', id: 'get_run_details' }, + ], + value: () => 'query', + }, + { + id: 'workflowSelector', + title: 'Workflows', + type: 'dropdown', + multiSelect: true, + options: [], + placeholder: 'All workflows', + description: 'Only include runs of these workflows. Leave empty for all.', + mode: 'basic', + canonicalParamId: 'workflowIds', + condition: { field: 'operation', value: 'query' }, + fetchOptions: () => fetchWorkspaceWorkflowOptions(), + }, + { + id: 'manualWorkflowIds', + title: 'Workflow IDs', + type: 'short-input', + placeholder: 'Comma-separated workflow IDs', + mode: 'advanced', + canonicalParamId: 'workflowIds', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'level', + title: 'Status', + type: 'dropdown', + multiSelect: true, + options: [ + { label: 'Info', id: 'info' }, + { label: 'Error', id: 'error' }, + { label: 'Running', id: 'running' }, + { label: 'Pending', id: 'pending' }, + { label: 'Cancelled', id: 'cancelled' }, + ], + placeholder: 'All statuses', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'timeRange', + title: 'Time Range', + type: 'dropdown', + options: [ + { label: 'All time', id: 'all-time' }, + { label: 'Past 30 minutes', id: 'past-30-minutes' }, + { label: 'Past hour', id: 'past-hour' }, + { label: 'Past 6 hours', id: 'past-6-hours' }, + { label: 'Past 12 hours', id: 'past-12-hours' }, + { label: 'Past 24 hours', id: 'past-24-hours' }, + { label: 'Past 3 days', id: 'past-3-days' }, + { label: 'Past 7 days', id: 'past-7-days' }, + { label: 'Past 14 days', id: 'past-14-days' }, + { label: 'Past 30 days', id: 'past-30-days' }, + ], + value: () => 'all-time', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp (overrides Time Range)', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp from the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'endDate', + title: 'End Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp from the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'costOperator', + title: 'Cost Comparison', + type: 'dropdown', + options: COMPARISON_OPERATOR_OPTIONS, + value: () => '>=', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'costValue', + title: 'Cost (credits)', + type: 'short-input', + placeholder: 'e.g. 10', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'durationOperator', + title: 'Duration Comparison', + type: 'dropdown', + options: COMPARISON_OPERATOR_OPTIONS, + value: () => '>=', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'durationValue', + title: 'Duration (ms)', + type: 'short-input', + placeholder: 'e.g. 30000', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '100 (max 200)', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Date', id: 'date' }, + { label: 'Duration', id: 'duration' }, + { label: 'Cost', id: 'cost' }, + { label: 'Status', id: 'status' }, + ], + value: () => 'date', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'sortOrder', + title: 'Sort Order', + type: 'dropdown', + options: [ + { label: 'Descending', id: 'desc' }, + { label: 'Ascending', id: 'asc' }, + ], + value: () => 'desc', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'runId', + title: 'Run ID', + type: 'short-input', + placeholder: 'd864be57-0aa0-43b1-8fc3-e4ebb680572d', + condition: { field: 'operation', value: 'get_run_details' }, + required: true, + }, + ], + tools: { + access: ['logs_query_runs', 'logs_get_run_details'], + config: { + tool: (params: Record) => { + const operation = params.operation || 'query' + if (operation === 'get_run_details') return 'logs_get_run_details' + return 'logs_query_runs' + }, + params: (params: Record) => { + const operation = params.operation || 'query' + + if (operation === 'get_run_details') { + if (!params.runId) { + throw new Error('Logs Block Error: Run ID is required for Get Run Details') + } + return { runId: params.runId } + } + + const toNumber = (value: unknown): number | undefined => { + if (value === undefined || value === null || value === '') return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + + const timeRangeMs = TIME_RANGE_MS[params.timeRange] + const presetStartDate = timeRangeMs + ? new Date(Date.now() - timeRangeMs).toISOString() + : undefined + + const level = joinIds(params.level) + const costValue = toNumber(params.costValue) + const durationValue = toNumber(params.durationValue) + + return { + workflowIds: joinIds(params.workflowIds), + level, + startDate: params.startDate || presetStartDate, + endDate: params.endDate || undefined, + costOperator: costValue !== undefined ? params.costOperator || undefined : undefined, + costValue, + durationOperator: + durationValue !== undefined ? params.durationOperator || undefined : undefined, + durationValue, + limit: toNumber(params.limit), + sortBy: params.sortBy || undefined, + sortOrder: params.sortOrder || undefined, + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + workflowIds: { type: 'array', description: 'Workflow IDs to filter by (canonical param)' }, + level: { type: 'array', description: 'Statuses to include (empty for all)' }, + timeRange: { type: 'string', description: 'Preset time window' }, + startDate: { type: 'string', description: 'ISO 8601 lower bound (overrides Time Range)' }, + endDate: { type: 'string', description: 'ISO 8601 upper bound' }, + costOperator: { type: 'string', description: "Cost comparison operator ('=', '>', …)" }, + costValue: { type: 'number', description: 'Cost threshold in credits' }, + durationOperator: { + type: 'string', + description: "Duration comparison operator ('=', '>', …)", + }, + durationValue: { type: 'number', description: 'Duration threshold in milliseconds' }, + limit: { type: 'number', description: 'Max run IDs to return (default 100, max 200)' }, + sortBy: { type: 'string', description: "'date' | 'duration' | 'cost' | 'status'" }, + sortOrder: { type: 'string', description: "'desc' | 'asc'" }, + runId: { type: 'string', description: 'Run ID (Get Run Details operation)' }, + }, + outputs: { + runIds: { + type: 'array', + description: 'IDs of the runs matching the filters', + condition: { field: 'operation', value: 'query' }, + }, + runId: { + type: 'string', + description: 'The run ID', + condition: { field: 'operation', value: 'get_run_details' }, + }, + workflowId: { + type: 'string', + description: 'Workflow ID this run belongs to', + condition: { field: 'operation', value: 'get_run_details' }, + }, + workflowName: { + type: 'string', + description: 'Workflow name', + condition: { field: 'operation', value: 'get_run_details' }, + }, + status: { + type: 'string', + description: 'Run status', + condition: { field: 'operation', value: 'get_run_details' }, + }, + trigger: { + type: 'string', + description: 'How the run was triggered', + condition: { field: 'operation', value: 'get_run_details' }, + }, + startedAt: { + type: 'string', + description: 'Run start time (ISO 8601)', + condition: { field: 'operation', value: 'get_run_details' }, + }, + durationMs: { + type: 'number', + description: 'Run duration in milliseconds', + condition: { field: 'operation', value: 'get_run_details' }, + }, + cost: { + type: 'number', + description: 'Run cost in credits', + condition: { field: 'operation', value: 'get_run_details' }, + }, + traceSpans: { + type: 'array', + description: 'Full trace spans for the run', + condition: { field: 'operation', value: 'get_run_details' }, + }, + finalOutput: { + type: 'json', + description: 'Final output of the run', + condition: { field: 'operation', value: 'get_run_details' }, + }, + }, +} diff --git a/apps/sim/blocks/blocks/mysql.ts b/apps/sim/blocks/blocks/mysql.ts index e60358c3ba..33c0e32e12 100644 --- a/apps/sim/blocks/blocks/mysql.ts +++ b/apps/sim/blocks/blocks/mysql.ts @@ -11,7 +11,7 @@ export const MySQLBlock: BlockConfig = { longDescription: 'Integrate MySQL into the workflow. Can query, insert, update, delete, and execute raw SQL.', docsLink: 'https://docs.sim.ai/tools/mysql', - category: 'blocks', + category: 'tools', integrationType: IntegrationType.Databases, bgColor: '#FFFFFF', icon: MySQLIcon, diff --git a/apps/sim/blocks/blocks/postgresql.ts b/apps/sim/blocks/blocks/postgresql.ts index f15f72c076..a453d73a26 100644 --- a/apps/sim/blocks/blocks/postgresql.ts +++ b/apps/sim/blocks/blocks/postgresql.ts @@ -11,7 +11,7 @@ export const PostgreSQLBlock: BlockConfig = { longDescription: 'Integrate PostgreSQL into the workflow. Can query, insert, update, delete, and execute raw SQL.', docsLink: 'https://docs.sim.ai/tools/postgresql', - category: 'blocks', + category: 'tools', integrationType: IntegrationType.Databases, bgColor: '#336791', icon: PostgresIcon, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index 5fca7e44d4..9b2ceb4171 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -11,7 +11,7 @@ export const SftpBlock: BlockConfig = { longDescription: 'Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.', docsLink: 'https://docs.sim.ai/tools/sftp', - category: 'blocks', + category: 'tools', integrationType: IntegrationType.Documents, bgColor: '#2D3748', icon: SftpIcon, diff --git a/apps/sim/blocks/blocks/sim_workspace_event.ts b/apps/sim/blocks/blocks/sim_workspace_event.ts new file mode 100644 index 0000000000..cec575d704 --- /dev/null +++ b/apps/sim/blocks/blocks/sim_workspace_event.ts @@ -0,0 +1,40 @@ +import { SimTriggerIcon } from '@/components/icons' +import { SIM_WORKSPACE_EVENT_TRIGGER_ID } from '@/lib/workspace-events/constants' +import type { BlockConfig } from '@/blocks/types' +import { getTrigger } from '@/triggers' + +export const SimWorkspaceEventBlock: BlockConfig = { + // Literal (not SIM_WORKSPACE_EVENT_TRIGGER_ID) so scripts/generate-docs.ts + // can scrape the type for icon-map keys; a test asserts it stays equal to + // the constant. + type: 'sim_workspace_event', + name: 'Sim', + description: + 'Run this workflow when workspace events occur: execution errors or successes, deployments, and alert conditions like latency or cost spikes.', + category: 'triggers', + icon: SimTriggerIcon, + bgColor: '#33C482', + docsLink: 'https://docs.sim.ai/triggers/sim', + triggerAllowed: true, + bestPractices: ` + - Events are scoped to this workspace. Pick an event type, then optionally narrow to specific workflows (empty selection watches all). + - This workflow must be deployed for the trigger to fire, and it never receives events about itself. + - Executions started by this trigger never emit workspace events, so side-effect workflows cannot chain or loop. + - Alert conditions (latency spike, cost threshold, consecutive failures, ...) fire at most once per cooldown window; plain events fire on every occurrence. + - Compose any blocks downstream (Slack, email, webhooks, custom logic) to act on the event payload. + `, + subBlocks: [...getTrigger(SIM_WORKSPACE_EVENT_TRIGGER_ID).subBlocks], + + tools: { + access: [], + }, + + inputs: {}, + + outputs: {}, + + triggers: { + enabled: true, + available: [SIM_WORKSPACE_EVENT_TRIGGER_ID], + }, +} diff --git a/apps/sim/blocks/blocks/smtp.ts b/apps/sim/blocks/blocks/smtp.ts index 398053a650..fb8aea2d88 100644 --- a/apps/sim/blocks/blocks/smtp.ts +++ b/apps/sim/blocks/blocks/smtp.ts @@ -11,7 +11,7 @@ export const SmtpBlock: BlockConfig = { longDescription: 'Send emails using any SMTP server (Gmail, Outlook, custom servers, etc.). Configure SMTP connection settings and send emails with full control over content, recipients, and attachments.', docsLink: 'https://docs.sim.ai/tools/smtp', - category: 'blocks', + category: 'tools', integrationType: IntegrationType.Email, bgColor: '#2D3748', icon: SmtpIcon, diff --git a/apps/sim/blocks/blocks/ssh.ts b/apps/sim/blocks/blocks/ssh.ts index f380d27406..0750b7ea24 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -11,7 +11,7 @@ export const SSHBlock: BlockConfig = { longDescription: 'Execute commands, transfer files, and manage remote servers via SSH. Supports password and private key authentication for secure server access.', docsLink: 'https://docs.sim.ai/tools/ssh', - category: 'blocks', + category: 'tools', integrationType: IntegrationType.DevOps, bgColor: '#000000', icon: SshIcon, diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 53e346953d..c0a417e603 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -160,7 +160,7 @@ import { LinearBlock, LinearBlockMeta, LinearV2Block } from '@/blocks/blocks/lin import { LinkedInBlock, LinkedInBlockMeta } from '@/blocks/blocks/linkedin' import { LinkupBlock, LinkupBlockMeta } from '@/blocks/blocks/linkup' import { LinqBlock, LinqBlockMeta } from '@/blocks/blocks/linq' -import { LogsBlock } from '@/blocks/blocks/logs' +import { LogsBlock, LogsV2Block } from '@/blocks/blocks/logs' import { LoopsBlock, LoopsBlockMeta } from '@/blocks/blocks/loops' import { LumaBlock, LumaBlockMeta } from '@/blocks/blocks/luma' import { MailchimpBlock, MailchimpBlockMeta } from '@/blocks/blocks/mailchimp' @@ -252,6 +252,7 @@ import { SESBlock, SESBlockMeta } from '@/blocks/blocks/ses' import { SftpBlock } from '@/blocks/blocks/sftp' import { SharepointBlock, SharepointBlockMeta, SharepointV2Block } from '@/blocks/blocks/sharepoint' import { ShopifyBlock, ShopifyBlockMeta } from '@/blocks/blocks/shopify' +import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event' import { SimilarwebBlock, SimilarwebBlockMeta } from '@/blocks/blocks/similarweb' import { SixtyfourBlock, SixtyfourBlockMeta } from '@/blocks/blocks/sixtyfour' import { SlackBlock, SlackBlockMeta } from '@/blocks/blocks/slack' @@ -460,6 +461,7 @@ const BLOCK_REGISTRY: Record = { linkup: LinkupBlock, linq: LinqBlock, logs: LogsBlock, + logs_v2: LogsV2Block, loops: LoopsBlock, luma: LumaBlock, mailchimp: MailchimpBlock, @@ -539,6 +541,7 @@ const BLOCK_REGISTRY: Record = { sharepoint: SharepointBlock, sharepoint_v2: SharepointV2Block, shopify: ShopifyBlock, + sim_workspace_event: SimWorkspaceEventBlock, similarweb: SimilarwebBlock, sixtyfour: SixtyfourBlock, slack: SlackBlock, diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts index d35bc5ebca..ffbf4d11f2 100644 --- a/apps/sim/components/emails/index.ts +++ b/apps/sim/components/emails/index.ts @@ -8,8 +8,6 @@ export * from './billing' export * from './components' // Invitation emails export * from './invitations' -// Notification emails -export * from './notifications' // Render functions and subjects export * from './render' export * from './subjects' diff --git a/apps/sim/components/emails/notifications/index.ts b/apps/sim/components/emails/notifications/index.ts deleted file mode 100644 index 35da763fb5..0000000000 --- a/apps/sim/components/emails/notifications/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type { - EmailRateLimitsData, - EmailUsageData, - WorkflowNotificationEmailProps, -} from './workflow-notification-email' -export { WorkflowNotificationEmail } from './workflow-notification-email' diff --git a/apps/sim/components/emails/notifications/workflow-notification-email.tsx b/apps/sim/components/emails/notifications/workflow-notification-email.tsx deleted file mode 100644 index 3389c8cad3..0000000000 --- a/apps/sim/components/emails/notifications/workflow-notification-email.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { Link, Section, Text } from '@react-email/components' -import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { getBrandConfig } from '@/ee/whitelabeling' - -/** - * Serialized rate limit status for email payloads. - * Note: This differs from the canonical RateLimitStatus in @/lib/core/rate-limiter - * which uses Date for resetAt. This version uses string for JSON serialization. - */ -export interface EmailRateLimitStatus { - requestsPerMinute: number - remaining: number - maxBurst?: number - resetAt?: string -} - -export interface EmailRateLimitsData { - sync?: EmailRateLimitStatus - async?: EmailRateLimitStatus -} - -export interface EmailUsageData { - currentPeriodCost: number - limit: number - percentUsed: number - isExceeded?: boolean -} - -export interface WorkflowNotificationEmailProps { - workflowName: string - status: 'success' | 'error' - trigger: string - duration: string - cost: string - logUrl: string - alertReason?: string - finalOutput?: unknown - rateLimits?: EmailRateLimitsData - usageData?: EmailUsageData -} - -function formatJsonForEmail(data: unknown): string { - return JSON.stringify(data, null, 2) -} - -export function WorkflowNotificationEmail({ - workflowName, - status, - trigger, - duration, - cost, - logUrl, - alertReason, - finalOutput, - rateLimits, - usageData, -}: WorkflowNotificationEmailProps) { - const brand = getBrandConfig() - const isError = status === 'error' - const statusText = isError ? 'Error' : 'Success' - - const previewText = alertReason - ? `${brand.name}: Alert - ${workflowName}` - : isError - ? `${brand.name}: Workflow Failed - ${workflowName}` - : `${brand.name}: Workflow Completed - ${workflowName}` - - const message = alertReason - ? 'An alert was triggered for your workflow.' - : isError - ? 'Your workflow run failed.' - : 'Your workflow completed successfully.' - - return ( - - Hello, - {message} - -
- {alertReason && ( - - Reason: {alertReason} - - )} - - Workflow: {workflowName} - - - Status: {statusText} - - - Trigger: {trigger} - - - Duration: {duration} - - - Cost: {cost} - -
- - - View Run Log - - - {rateLimits && (rateLimits.sync || rateLimits.async) ? ( - <> -
-
- Rate Limits - {rateLimits.sync && ( - - Sync: {rateLimits.sync.remaining} of {rateLimits.sync.requestsPerMinute} remaining - - )} - {rateLimits.async && ( - - Async: {rateLimits.async.remaining} of {rateLimits.async.requestsPerMinute}{' '} - remaining - - )} -
- - ) : null} - - {usageData ? ( - <> -
-
- Usage - - {dollarsToCredits(usageData.currentPeriodCost).toLocaleString()} of{' '} - {dollarsToCredits(usageData.limit).toLocaleString()} credits used ( - {usageData.percentUsed.toFixed(1)}%) - -
- - ) : null} - - {finalOutput ? ( - <> -
-
- Final Output - - {formatJsonForEmail(finalOutput)} - -
- - ) : null} - -
- - - You're receiving this because you subscribed to workflow notifications. - - - ) -} - -export default WorkflowNotificationEmail diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 645f5a056b..2b3d416c51 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -21,10 +21,6 @@ import { PollingGroupInvitationEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' -import { - WorkflowNotificationEmail, - type WorkflowNotificationEmailProps, -} from '@/components/emails/notifications' import { HelpConfirmationEmail } from '@/components/emails/support' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -249,9 +245,3 @@ export async function renderPaymentFailedEmail(params: { }) ) } - -export async function renderWorkflowNotificationEmail( - params: WorkflowNotificationEmailProps -): Promise { - return await render(WorkflowNotificationEmail(params)) -} diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 41732af3b2..cdd2ab9401 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -6823,6 +6823,29 @@ export function SixtyfourIcon(props: SVGProps) { ) } +export function SimTriggerIcon(props: SVGProps) { + return ( + + ) +} + export function SimilarwebIcon(props: SVGProps) { return ( [...notificationKeys.all, 'list'] as const, - list: (workspaceId: string | undefined) => - [...notificationKeys.lists(), workspaceId ?? ''] as const, - details: () => [...notificationKeys.all, 'detail'] as const, - detail: (workspaceId: string, notificationId: string) => - [...notificationKeys.details(), workspaceId, notificationId] as const, -} - -/** - * Fetch notifications for a workspace - */ -async function fetchNotifications( - workspaceId: string, - signal?: AbortSignal -): Promise { - const data = await requestJson(listNotificationsContract, { - params: { id: workspaceId }, - signal, - }) - return data.data -} - -/** - * Hook to fetch notifications for a workspace - */ -export function useNotifications(workspaceId?: string) { - return useQuery({ - queryKey: notificationKeys.list(workspaceId), - queryFn: ({ signal }) => fetchNotifications(workspaceId!, signal), - enabled: Boolean(workspaceId), - staleTime: 30 * 1000, - placeholderData: keepPreviousData, - }) -} - -interface CreateNotificationParams { - workspaceId: string - data: ContractBodyInput -} - -/** - * Hook to create a notification - */ -export function useCreateNotification() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ workspaceId, data }: CreateNotificationParams) => { - return requestJson(createNotificationContract, { - params: { id: workspaceId }, - body: data, - }) - }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) - }, - onError: (error) => { - logger.error('Failed to create notification', { error }) - }, - }) -} - -interface UpdateNotificationParams { - workspaceId: string - notificationId: string - data: ContractBodyInput -} - -/** - * Hook to update a notification - */ -export function useUpdateNotification() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ workspaceId, notificationId, data }: UpdateNotificationParams) => { - return requestJson(updateNotificationContract, { - params: { id: workspaceId, notificationId }, - body: data, - }) - }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) - }, - onError: (error) => { - logger.error('Failed to update notification', { error }) - }, - }) -} - -interface DeleteNotificationParams { - workspaceId: string - notificationId: string -} - -/** - * Hook to delete a notification - */ -export function useDeleteNotification() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ workspaceId, notificationId }: DeleteNotificationParams) => { - return requestJson(deleteNotificationContract, { - params: { id: workspaceId, notificationId }, - }) - }, - onSuccess: (_, { workspaceId }) => { - queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) - }, - onError: (error) => { - logger.error('Failed to delete notification', { error }) - }, - }) -} - -interface TestNotificationParams { - workspaceId: string - notificationId: string -} - -/** - * Hook to test a notification - */ -export function useTestNotification() { - return useMutation({ - mutationFn: async ({ workspaceId, notificationId }: TestNotificationParams) => { - return requestJson(testNotificationContract, { - params: { id: workspaceId, notificationId }, - }) - }, - onError: (error) => { - logger.error('Failed to test notification', { error }) - }, - }) -} diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 0001d2b51b..85fd5fdb52 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -17,7 +17,6 @@ export * from './folders' export * from './hotspots' export * from './inbox' export * from './media' -export * from './notifications' export * from './permission-groups' export * from './primitives' export * from './selectors' diff --git a/apps/sim/lib/api/contracts/notifications.ts b/apps/sim/lib/api/contracts/notifications.ts deleted file mode 100644 index e3d2671cef..0000000000 --- a/apps/sim/lib/api/contracts/notifications.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' - -export const notificationWorkspaceParamsSchema = z.object({ - id: z.string().min(1), -}) - -export const notificationParamsSchema = z.object({ - id: z.string().min(1), - notificationId: z.string().min(1), -}) - -export const notificationTypeSchema = z.enum(['webhook', 'email', 'slack']) -export const notificationLevelSchema = z.enum(['info', 'error']) - -export const alertRuleSchema = z.enum([ - 'consecutive_failures', - 'failure_rate', - 'latency_threshold', - 'latency_spike', - 'cost_threshold', - 'no_activity', - 'error_count', -]) - -export const notificationAlertConfigSchema = z.object({ - rule: alertRuleSchema, - consecutiveFailures: z.number().int().optional(), - failureRatePercent: z.number().int().optional(), - windowHours: z.number().int().optional(), - durationThresholdMs: z.number().int().optional(), - latencySpikePercent: z.number().int().optional(), - costThresholdDollars: z.number().optional(), - inactivityHours: z.number().int().optional(), - errorCountThreshold: z.number().int().optional(), -}) - -export const notificationWebhookConfigSchema = z.object({ - url: z.string().url(), - secret: z.string().optional(), -}) - -export const notificationSlackConfigSchema = z.object({ - channelId: z.string(), - channelName: z.string(), - accountId: z.string(), -}) - -export type NotificationType = z.output -export type NotificationLogLevel = z.output -export type NotificationAlertRule = z.output -export type NotificationAlertConfig = z.output -export type NotificationWebhookConfig = z.output -export type NotificationSlackConfig = z.output - -export const notificationSubscriptionSchema = z.object({ - id: z.string(), - notificationType: notificationTypeSchema, - workflowIds: z.array(z.string()), - allWorkflows: z.boolean(), - levelFilter: z.array(notificationLevelSchema), - triggerFilter: z.array(z.string()), - includeFinalOutput: z.boolean(), - includeTraceSpans: z.boolean(), - includeRateLimits: z.boolean(), - includeUsageData: z.boolean(), - webhookConfig: notificationWebhookConfigSchema.nullish(), - emailRecipients: z.array(z.string()).nullish(), - slackConfig: notificationSlackConfigSchema.nullish(), - alertConfig: notificationAlertConfigSchema.nullish(), - active: z.boolean(), - createdAt: z.string(), - updatedAt: z.string(), -}) - -export type NotificationSubscription = z.output - -export const createNotificationBodySchema = z.object({ - notificationType: notificationTypeSchema, - workflowIds: z.array(z.string()), - allWorkflows: z.boolean(), - levelFilter: z.array(notificationLevelSchema), - triggerFilter: z.array(z.string()), - includeFinalOutput: z.boolean(), - includeTraceSpans: z.boolean(), - includeRateLimits: z.boolean(), - includeUsageData: z.boolean(), - alertConfig: notificationAlertConfigSchema.nullish(), - webhookConfig: notificationWebhookConfigSchema.optional(), - emailRecipients: z.array(z.string().email()).optional(), - slackConfig: notificationSlackConfigSchema.optional(), -}) - -export const updateNotificationBodySchema = createNotificationBodySchema - .omit({ notificationType: true }) - .partial() - .extend({ - active: z.boolean().optional(), - }) - -/** - * Server-side validation schemas with rule-specific refinements and bounded - * limits. These are stricter than the wire schemas above and are used by the - * `POST` and `PUT` notification routes to validate inbound payloads before - * persisting them. - */ -const serverAlertConfigSchema = z - .object({ - rule: alertRuleSchema, - consecutiveFailures: z.number().int().min(1).max(100).optional(), - failureRatePercent: z.number().int().min(1).max(100).optional(), - windowHours: z.number().int().min(1).max(168).optional(), - durationThresholdMs: z.number().int().min(1000).max(3600000).optional(), - latencySpikePercent: z.number().int().min(10).max(1000).optional(), - costThresholdDollars: z.number().min(0.01).max(1000).optional(), - inactivityHours: z.number().int().min(1).max(168).optional(), - errorCountThreshold: z.number().int().min(1).max(1000).optional(), - }) - .refine( - (data) => { - switch (data.rule) { - case 'consecutive_failures': - return data.consecutiveFailures !== undefined - case 'failure_rate': - return data.failureRatePercent !== undefined && data.windowHours !== undefined - case 'latency_threshold': - return data.durationThresholdMs !== undefined - case 'latency_spike': - return data.latencySpikePercent !== undefined && data.windowHours !== undefined - case 'cost_threshold': - return data.costThresholdDollars !== undefined - case 'no_activity': - return data.inactivityHours !== undefined - case 'error_count': - return data.errorCountThreshold !== undefined && data.windowHours !== undefined - default: - return false - } - }, - { message: 'Missing required fields for alert rule' } - ) - .nullable() - -export interface NotificationServerLimits { - maxEmailRecipients: number - maxWorkflowIds: number -} - -export const NOTIFICATION_SERVER_LIMITS: NotificationServerLimits = { - maxEmailRecipients: 10, - maxWorkflowIds: 1000, -} - -export function buildServerCreateNotificationSchema(limits: NotificationServerLimits) { - return z - .object({ - notificationType: notificationTypeSchema, - workflowIds: z.array(z.string()).max(limits.maxWorkflowIds).default([]), - allWorkflows: z.boolean().default(false), - levelFilter: z.array(notificationLevelSchema).default(['info', 'error']), - triggerFilter: z.array(z.string().min(1)).default([]), - includeFinalOutput: z.boolean().default(false), - includeTraceSpans: z.boolean().default(false), - includeRateLimits: z.boolean().default(false), - includeUsageData: z.boolean().default(false), - alertConfig: serverAlertConfigSchema.optional(), - webhookConfig: notificationWebhookConfigSchema.optional(), - emailRecipients: z.array(z.string().email()).max(limits.maxEmailRecipients).optional(), - slackConfig: notificationSlackConfigSchema.optional(), - }) - .refine( - (data) => { - if (data.notificationType === 'webhook') return !!data.webhookConfig?.url - if (data.notificationType === 'email') - return !!data.emailRecipients && data.emailRecipients.length > 0 - if (data.notificationType === 'slack') - return !!data.slackConfig?.channelId && !!data.slackConfig?.accountId - return false - }, - { message: 'Missing required fields for notification type' } - ) - .refine((data) => !(data.allWorkflows && data.workflowIds.length > 0), { - message: 'Cannot specify both allWorkflows and workflowIds', - }) -} - -export function buildServerUpdateNotificationSchema(limits: NotificationServerLimits) { - return z - .object({ - workflowIds: z.array(z.string()).max(limits.maxWorkflowIds).optional(), - allWorkflows: z.boolean().optional(), - levelFilter: z.array(notificationLevelSchema).optional(), - triggerFilter: z.array(z.string().min(1)).optional(), - includeFinalOutput: z.boolean().optional(), - includeTraceSpans: z.boolean().optional(), - includeRateLimits: z.boolean().optional(), - includeUsageData: z.boolean().optional(), - alertConfig: serverAlertConfigSchema.optional(), - webhookConfig: notificationWebhookConfigSchema.optional(), - emailRecipients: z.array(z.string().email()).max(limits.maxEmailRecipients).optional(), - slackConfig: notificationSlackConfigSchema.optional(), - active: z.boolean().optional(), - }) - .refine((data) => !(data.allWorkflows && data.workflowIds && data.workflowIds.length > 0), { - message: 'Cannot specify both allWorkflows and workflowIds', - }) -} - -export const listNotificationsContract = defineRouteContract({ - method: 'GET', - path: '/api/workspaces/[id]/notifications', - params: notificationWorkspaceParamsSchema, - response: { - mode: 'json', - schema: z.object({ - data: z.array(notificationSubscriptionSchema), - }), - }, -}) - -export const createNotificationContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/notifications', - params: notificationWorkspaceParamsSchema, - body: createNotificationBodySchema, - response: { - mode: 'json', - schema: z.object({ - data: notificationSubscriptionSchema, - }), - }, -}) - -export const createNotificationServerContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/notifications', - params: notificationWorkspaceParamsSchema, - body: buildServerCreateNotificationSchema(NOTIFICATION_SERVER_LIMITS), - response: { - mode: 'json', - schema: z.object({ - data: notificationSubscriptionSchema, - }), - }, -}) - -export const updateNotificationContract = defineRouteContract({ - method: 'PUT', - path: '/api/workspaces/[id]/notifications/[notificationId]', - params: notificationParamsSchema, - body: updateNotificationBodySchema, - response: { - mode: 'json', - schema: z.object({ - data: notificationSubscriptionSchema, - }), - }, -}) - -export const updateNotificationServerContract = defineRouteContract({ - method: 'PUT', - path: '/api/workspaces/[id]/notifications/[notificationId]', - params: notificationParamsSchema, - body: buildServerUpdateNotificationSchema(NOTIFICATION_SERVER_LIMITS), - response: { - mode: 'json', - schema: z.object({ - data: notificationSubscriptionSchema, - }), - }, -}) - -export const deleteNotificationContract = defineRouteContract({ - method: 'DELETE', - path: '/api/workspaces/[id]/notifications/[notificationId]', - params: notificationParamsSchema, - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - }), - }, -}) - -export const testNotificationContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/notifications/[notificationId]/test', - params: notificationParamsSchema, - response: { - mode: 'json', - schema: z.object({ - data: z.object({ - success: z.boolean(), - error: z.string().optional(), - channel: z.string().optional(), - timestamp: z.string().optional(), - }), - }), - }, -}) diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 7704a069ac..6f4cf89271 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -128,6 +128,7 @@ import { MistralIcon, MondayIcon, MongoDBIcon, + MySQLIcon, Neo4jIcon, NeverBounceIcon, NewRelicIcon, @@ -145,6 +146,7 @@ import { PineconeIcon, PipedriveIcon, PolymarketIcon, + PostgresIcon, PosthogIcon, ProfoundIcon, ProspeoIcon, @@ -172,11 +174,15 @@ import { SentryIcon, SerperIcon, ServiceNowIcon, + SftpIcon, ShopifyIcon, SimilarwebIcon, + SimTriggerIcon, SixtyfourIcon, SlackIcon, + SmtpIcon, SQSIcon, + SshIcon, STSIcon, STTIcon, StagehandIcon, @@ -335,6 +341,7 @@ export const blockTypeToIconMap: Record = { mistral_parse_v3: MistralIcon, monday: MondayIcon, mongodb: MongoDBIcon, + mysql: MySQLIcon, neo4j: Neo4jIcon, neverbounce: NeverBounceIcon, new_relic: NewRelicIcon, @@ -352,6 +359,7 @@ export const blockTypeToIconMap: Record = { pinecone: PineconeIcon, pipedrive: PipedriveIcon, polymarket: PolymarketIcon, + postgresql: PostgresIcon, posthog: PosthogIcon, profound: ProfoundIcon, prospeo: ProspeoIcon, @@ -379,12 +387,16 @@ export const blockTypeToIconMap: Record = { serper: SerperIcon, servicenow: ServiceNowIcon, ses: SESIcon, + sftp: SftpIcon, sharepoint_v2: MicrosoftSharepointIcon, shopify: ShopifyIcon, + sim_workspace_event: SimTriggerIcon, similarweb: SimilarwebIcon, sixtyfour: SixtyfourIcon, slack: SlackIcon, + smtp: SmtpIcon, sqs: SQSIcon, + ssh: SshIcon, stagehand: StagehandIcon, stripe: StripeIcon, sts: STSIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index dc45cec20f..fac30504d2 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -3338,24 +3338,6 @@ "integrationType": "observability", "tags": ["data-analytics", "automation"] }, - { - "type": "enrichment", - "slug": "data-enrichment", - "name": "Data Enrichment", - "description": "Enrich data with a Sim enrichment", - "longDescription": "Run a Sim enrichment to look up data — work email, phone number, company domain, company info, and more — from the fields you map in. Uses the same provider cascade as table enrichments.", - "bgColor": "#9333EA", - "iconName": "EnrichmentIcon", - "docsUrl": "https://docs.sim.ai/tools/enrichment", - "operations": [], - "operationCount": 0, - "triggers": [], - "triggerCount": 0, - "authType": "none", - "category": "tools", - "integrationType": "sales", - "tags": ["enrichment", "sales-engagement"] - }, { "type": "databricks", "slug": "databricks", @@ -9943,6 +9925,48 @@ "integrationType": "databases", "tags": ["data-warehouse", "cloud"] }, + { + "type": "mysql", + "slug": "mysql", + "name": "MySQL", + "description": "Connect to MySQL database", + "longDescription": "Integrate MySQL into the workflow. Can query, insert, update, delete, and execute raw SQL.", + "bgColor": "#FFFFFF", + "iconName": "MySQLIcon", + "docsUrl": "https://docs.sim.ai/tools/mysql", + "operations": [ + { + "name": "Query (SELECT)", + "description": "Execute SELECT query on MySQL database" + }, + { + "name": "Insert Data", + "description": "Insert new record into MySQL database" + }, + { + "name": "Update Data", + "description": "Update existing records in MySQL database" + }, + { + "name": "Delete Data", + "description": "Delete records from MySQL database" + }, + { + "name": "Execute Raw SQL", + "description": "Execute raw SQL query on MySQL database" + }, + { + "name": "Introspect Schema", + "description": "Introspect MySQL database schema to retrieve table structures, columns, and relationships" + } + ], + "operationCount": 6, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationType": "databases" + }, { "type": "neo4j", "slug": "neo4j", @@ -10783,6 +10807,48 @@ "integrationType": "analytics", "tags": ["prediction-markets", "data-analytics"] }, + { + "type": "postgresql", + "slug": "postgresql", + "name": "PostgreSQL", + "description": "Connect to PostgreSQL database", + "longDescription": "Integrate PostgreSQL into the workflow. Can query, insert, update, delete, and execute raw SQL.", + "bgColor": "#336791", + "iconName": "PostgresIcon", + "docsUrl": "https://docs.sim.ai/tools/postgresql", + "operations": [ + { + "name": "Query (SELECT)", + "description": "Execute a SELECT query on PostgreSQL database" + }, + { + "name": "Insert Data", + "description": "Insert data into PostgreSQL database" + }, + { + "name": "Update Data", + "description": "Update data in PostgreSQL database" + }, + { + "name": "Delete Data", + "description": "Delete data from PostgreSQL database" + }, + { + "name": "Execute Raw SQL", + "description": "Execute raw SQL query on PostgreSQL database" + }, + { + "name": "Introspect Schema", + "description": "Introspect PostgreSQL database schema to retrieve table structures, columns, and relationships" + } + ], + "operationCount": 6, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationType": "databases" + }, { "type": "posthog", "slug": "posthog", @@ -13385,6 +13451,48 @@ "integrationType": "support", "tags": ["customer-support", "ticketing", "incident-management"] }, + { + "type": "sftp", + "slug": "sftp", + "name": "SFTP", + "description": "Transfer files via SFTP (SSH File Transfer Protocol)", + "longDescription": "Upload, download, list, and manage files on remote servers via SFTP. Supports both password and private key authentication for secure file transfers.", + "bgColor": "#2D3748", + "iconName": "SftpIcon", + "docsUrl": "https://docs.sim.ai/tools/sftp", + "operations": [ + { + "name": "Upload Files", + "description": "Upload files to a remote SFTP server" + }, + { + "name": "Create File", + "description": "" + }, + { + "name": "Download File", + "description": "Download a file from a remote SFTP server" + }, + { + "name": "List Directory", + "description": "List files and directories on a remote SFTP server" + }, + { + "name": "Delete File/Directory", + "description": "Delete a file or directory on a remote SFTP server" + }, + { + "name": "Create Directory", + "description": "Create a directory on a remote SFTP server" + } + ], + "operationCount": 6, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "documents" + }, { "type": "sharepoint_v2", "slug": "sharepoint", @@ -13807,6 +13915,93 @@ "aiDisclaimer": "Sim agents use AI models to generate messages and responses sent to Slack. AI-generated content can be inaccurate or incomplete — review automated outputs before relying on them, especially for important communications." } }, + { + "type": "smtp", + "slug": "smtp", + "name": "SMTP", + "description": "Send emails via any SMTP mail server", + "longDescription": "Send emails using any SMTP server (Gmail, Outlook, custom servers, etc.). Configure SMTP connection settings and send emails with full control over content, recipients, and attachments.", + "bgColor": "#2D3748", + "iconName": "SmtpIcon", + "docsUrl": "https://docs.sim.ai/tools/smtp", + "operations": [], + "operationCount": 0, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "email" + }, + { + "type": "ssh", + "slug": "ssh", + "name": "SSH", + "description": "Connect to remote servers via SSH", + "longDescription": "Execute commands, transfer files, and manage remote servers via SSH. Supports password and private key authentication for secure server access.", + "bgColor": "#000000", + "iconName": "SshIcon", + "docsUrl": "https://docs.sim.ai/tools/ssh", + "operations": [ + { + "name": "Execute Command", + "description": "Execute a shell command on a remote SSH server" + }, + { + "name": "Execute Script", + "description": "Upload and execute a multi-line script on a remote SSH server" + }, + { + "name": "Check Command Exists", + "description": "Check if a command/program exists on the remote SSH server" + }, + { + "name": "Upload File", + "description": "Upload a file to a remote SSH server" + }, + { + "name": "Download File", + "description": "Download a file from a remote SSH server" + }, + { + "name": "List Directory", + "description": "List files and directories in a remote directory" + }, + { + "name": "Check File/Directory Exists", + "description": "Check if a file or directory exists on the remote SSH server" + }, + { + "name": "Create Directory", + "description": "Create a directory on the remote SSH server" + }, + { + "name": "Delete File/Directory", + "description": "Delete a file or directory from the remote SSH server" + }, + { + "name": "Move/Rename", + "description": "Move or rename a file or directory on the remote SSH server" + }, + { + "name": "Get System Info", + "description": "Retrieve system information from the remote SSH server" + }, + { + "name": "Read File Content", + "description": "Read the contents of a remote file" + }, + { + "name": "Write File Content", + "description": "Write or append content to a remote file" + } + ], + "operationCount": 13, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "devops" + }, { "type": "stagehand", "slug": "stagehand", diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts deleted file mode 100644 index 5a40fab190..0000000000 --- a/apps/sim/lib/logs/events.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { db } from '@sim/db' -import { workspaceNotificationDelivery, workspaceNotificationSubscription } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { getActiveWorkflowContext } from '@sim/workflow-authz' -import { and, eq, or, sql } from 'drizzle-orm' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' -import type { WorkflowExecutionLog } from '@/lib/logs/types' -import { - type AlertCheckContext, - type AlertConfig, - shouldTriggerAlert, -} from '@/lib/notifications/alert-rules' -import { - executeNotificationDelivery, - workspaceNotificationDeliveryTask, -} from '@/background/workspace-notification-delivery' - -const logger = createLogger('LogsEventEmitter') - -function prepareLogData( - log: WorkflowExecutionLog, - subscription: { - includeFinalOutput: boolean - includeTraceSpans: boolean - } -) { - const preparedLog = { ...log, executionData: {} as Record } - - if (log.executionData) { - const data = log.executionData as Record - const webhookData: Record = {} - - if (subscription.includeFinalOutput && data.finalOutput) { - webhookData.finalOutput = data.finalOutput - } - - if (subscription.includeTraceSpans && data.traceSpans) { - webhookData.traceSpans = data.traceSpans - } - - preparedLog.executionData = webhookData - } - - return preparedLog -} - -export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): Promise { - try { - if (!log.workflowId) return - - const workflowContext = await getActiveWorkflowContext(log.workflowId) - if (!workflowContext?.workspaceId) return - - const workspaceId = workflowContext.workspaceId - - const subscriptions = await db - .select() - .from(workspaceNotificationSubscription) - .where( - and( - eq(workspaceNotificationSubscription.workspaceId, workspaceId), - eq(workspaceNotificationSubscription.active, true), - or( - eq(workspaceNotificationSubscription.allWorkflows, true), - sql`${log.workflowId} = ANY(${workspaceNotificationSubscription.workflowIds})` - ) - ) - ) - - if (subscriptions.length === 0) return - - logger.debug( - `Found ${subscriptions.length} active notification subscriptions for workspace ${workspaceId}` - ) - - for (const subscription of subscriptions) { - const levelMatches = subscription.levelFilter.includes(log.level) - const triggerMatches = - subscription.triggerFilter.length === 0 || subscription.triggerFilter.includes(log.trigger) - - if (!levelMatches || !triggerMatches) { - logger.debug(`Skipping subscription ${subscription.id} due to filter mismatch`) - continue - } - - const alertConfig = subscription.alertConfig as AlertConfig | null - - if (alertConfig) { - const context: AlertCheckContext = { - workflowId: log.workflowId, - executionId: log.executionId, - status: log.level === 'error' ? 'error' : 'success', - durationMs: log.totalDurationMs || 0, - cost: (log.cost as { total?: number })?.total || 0, - triggerFilter: subscription.triggerFilter, - } - - const shouldAlert = await shouldTriggerAlert(alertConfig, context, subscription.lastAlertAt) - - if (!shouldAlert) { - logger.debug(`Alert condition not met for subscription ${subscription.id}`) - continue - } - - await db - .update(workspaceNotificationSubscription) - .set({ lastAlertAt: new Date() }) - .where(eq(workspaceNotificationSubscription.id, subscription.id)) - - logger.info(`Alert triggered for subscription ${subscription.id}`, { - workflowId: log.workflowId, - alertConfig, - }) - } - - const deliveryId = generateId() - - await db.insert(workspaceNotificationDelivery).values({ - id: deliveryId, - subscriptionId: subscription.id, - workflowId: log.workflowId, - executionId: log.executionId, - status: 'pending', - attempts: 0, - nextAttemptAt: new Date(), - }) - - const notificationLog = prepareLogData(log, subscription) - - const payload = { - deliveryId, - subscriptionId: subscription.id, - workspaceId, - notificationType: subscription.notificationType, - log: notificationLog, - alertConfig: alertConfig || undefined, - } - - if (isTriggerDevEnabled) { - await workspaceNotificationDeliveryTask.trigger(payload, { - tags: [ - `workspaceId:${workspaceId}`, - `workflowId:${log.workflowId}`, - `notificationType:${subscription.notificationType}`, - ], - }) - logger.info( - `Enqueued ${subscription.notificationType} notification ${deliveryId} via Trigger.dev` - ) - } else { - void executeNotificationDelivery(payload).catch((error) => { - logger.error(`Direct notification delivery failed for ${deliveryId}`, { error }) - }) - logger.info(`Enqueued ${subscription.notificationType} notification ${deliveryId} directly`) - } - } - } catch (error) { - logger.error('Failed to emit workflow execution completed event', { - error, - workflowId: log.workflowId, - executionId: log.executionId, - }) - } -} diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index cf245552cc..f016533b1e 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -68,9 +68,9 @@ vi.mock('@/lib/core/utils/display-filters', () => ({ filterForDisplay: vi.fn((data) => data), })) -// Mock events -vi.mock('@/lib/logs/events', () => ({ - emitWorkflowExecutionCompleted: vi.fn(() => Promise.resolve()), +// Mock workspace event emission +vi.mock('@/lib/workspace-events/emitter', () => ({ + emitExecutionCompletedEvent: vi.fn(() => Promise.resolve()), })) // Mock snapshot service diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 369b9397ce..5c46172adc 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -32,7 +32,6 @@ import { collectLargeValueReferenceKeys, replaceLargeValueReferenceKeysWithClient, } from '@/lib/execution/payloads/large-value-metadata' -import { emitWorkflowExecutionCompleted } from '@/lib/logs/events' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { externalizeExecutionData, @@ -50,6 +49,7 @@ import type { WorkflowExecutionSnapshot, WorkflowState, } from '@/lib/logs/types' +import { emitExecutionCompletedEvent } from '@/lib/workspace-events/emitter' import type { SerializableExecutionState } from '@/executor/execution/types' const logger = createLogger('ExecutionLogger') @@ -991,8 +991,8 @@ export class ExecutionLogger implements IExecutionLoggerService { createdAt: updatedLog.createdAt.toISOString(), } - emitWorkflowExecutionCompleted(completedLog).catch((error) => { - execLog.error('Failed to emit workflow execution completed event', { error }) + emitExecutionCompletedEvent(completedLog).catch((error) => { + execLog.error('Failed to emit workspace execution event', { error }) }) return completedLog diff --git a/apps/sim/lib/notifications/alert-rules.ts b/apps/sim/lib/notifications/alert-rules.ts deleted file mode 100644 index 708184cdac..0000000000 --- a/apps/sim/lib/notifications/alert-rules.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, avg, count, desc, eq, gte, inArray } from 'drizzle-orm' - -const logger = createLogger('AlertRules') - -/** - * Alert rule types supported by the notification system - */ -export type AlertRuleType = - | 'consecutive_failures' - | 'failure_rate' - | 'latency_threshold' - | 'latency_spike' - | 'cost_threshold' - | 'no_activity' - | 'error_count' - -/** - * Configuration for alert rules - */ -export interface AlertConfig { - rule: AlertRuleType - consecutiveFailures?: number - failureRatePercent?: number - windowHours?: number - durationThresholdMs?: number - latencySpikePercent?: number - costThresholdDollars?: number - inactivityHours?: number - errorCountThreshold?: number -} - -/** - * Metadata for alert rule types - */ -interface AlertRuleDefinition { - type: AlertRuleType - name: string - description: string - requiredFields: (keyof AlertConfig)[] - defaultValues: Partial -} - -/** - * Registry of all alert rule definitions - */ -export const ALERT_RULES: Record = { - consecutive_failures: { - type: 'consecutive_failures', - name: 'Consecutive Failures', - description: 'Alert after X consecutive failed executions', - requiredFields: ['consecutiveFailures'], - defaultValues: { consecutiveFailures: 3 }, - }, - failure_rate: { - type: 'failure_rate', - name: 'Failure Rate', - description: 'Alert when failure rate exceeds X% over a time window', - requiredFields: ['failureRatePercent', 'windowHours'], - defaultValues: { failureRatePercent: 50, windowHours: 24 }, - }, - latency_threshold: { - type: 'latency_threshold', - name: 'Latency Threshold', - description: 'Alert when execution duration exceeds a threshold', - requiredFields: ['durationThresholdMs'], - defaultValues: { durationThresholdMs: 30000 }, - }, - latency_spike: { - type: 'latency_spike', - name: 'Latency Spike', - description: 'Alert when execution is X% slower than average', - requiredFields: ['latencySpikePercent', 'windowHours'], - defaultValues: { latencySpikePercent: 100, windowHours: 24 }, - }, - cost_threshold: { - type: 'cost_threshold', - name: 'Cost Threshold', - description: 'Alert when execution cost exceeds a threshold', - requiredFields: ['costThresholdDollars'], - defaultValues: { costThresholdDollars: 1 }, - }, - no_activity: { - type: 'no_activity', - name: 'No Activity', - description: 'Alert when no executions occur within a time window', - requiredFields: ['inactivityHours'], - defaultValues: { inactivityHours: 24 }, - }, - error_count: { - type: 'error_count', - name: 'Error Count', - description: 'Alert when error count exceeds threshold within time window', - requiredFields: ['errorCountThreshold', 'windowHours'], - defaultValues: { errorCountThreshold: 10, windowHours: 1 }, - }, -} - -/** - * Cooldown period in hours to prevent alert spam - */ -export const ALERT_COOLDOWN_HOURS = 1 - -/** - * Minimum executions required for rate-based alerts - */ -export const MIN_EXECUTIONS_FOR_RATE_ALERT = 5 - -/** - * Validates an alert configuration - */ -export function validateAlertConfig(config: AlertConfig): { valid: boolean; error?: string } { - const definition = ALERT_RULES[config.rule] - if (!definition) { - return { valid: false, error: `Unknown alert rule: ${config.rule}` } - } - - for (const field of definition.requiredFields) { - if (config[field] === undefined || config[field] === null) { - return { valid: false, error: `Missing required field: ${field}` } - } - } - - return { valid: true } -} - -/** - * Checks if a subscription is within its cooldown period - */ -export function isInCooldown(lastAlertAt: Date | null): boolean { - if (!lastAlertAt) return false - const cooldownEnd = new Date(lastAlertAt.getTime() + ALERT_COOLDOWN_HOURS * 60 * 60 * 1000) - return new Date() < cooldownEnd -} - -export interface AlertCheckContext { - workflowId: string - executionId: string - status: 'success' | 'error' - durationMs: number - cost: number - triggerFilter: string[] -} - -async function checkConsecutiveFailures( - workflowId: string, - threshold: number, - triggerFilter: string[] -): Promise { - const recentLogs = await db - .select({ level: workflowExecutionLogs.level }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.workflowId, workflowId), - inArray(workflowExecutionLogs.trigger, triggerFilter) - ) - ) - .orderBy(desc(workflowExecutionLogs.createdAt)) - .limit(threshold) - - if (recentLogs.length < threshold) return false - - return recentLogs.every((log) => log.level === 'error') -} - -async function checkFailureRate( - workflowId: string, - ratePercent: number, - windowHours: number, - triggerFilter: string[] -): Promise { - const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) - - const logs = await db - .select({ - level: workflowExecutionLogs.level, - createdAt: workflowExecutionLogs.createdAt, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.workflowId, workflowId), - gte(workflowExecutionLogs.createdAt, windowStart), - inArray(workflowExecutionLogs.trigger, triggerFilter) - ) - ) - .orderBy(workflowExecutionLogs.createdAt) - - if (logs.length < MIN_EXECUTIONS_FOR_RATE_ALERT) return false - - const oldestLog = logs[0] - if (oldestLog && oldestLog.createdAt > windowStart) { - return false - } - - const errorCount = logs.filter((log) => log.level === 'error').length - const failureRate = (errorCount / logs.length) * 100 - - return failureRate >= ratePercent -} - -/** - * Check if execution duration exceeds threshold - */ -function checkLatencyThreshold(durationMs: number, thresholdMs: number): boolean { - return durationMs > thresholdMs -} - -async function checkLatencySpike( - workflowId: string, - currentDurationMs: number, - spikePercent: number, - windowHours: number, - triggerFilter: string[] -): Promise { - const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) - - const result = await db - .select({ - avgDuration: avg(workflowExecutionLogs.totalDurationMs), - count: count(), - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.workflowId, workflowId), - gte(workflowExecutionLogs.createdAt, windowStart), - inArray(workflowExecutionLogs.trigger, triggerFilter) - ) - ) - - const avgDuration = result[0]?.avgDuration - const execCount = result[0]?.count || 0 - - if (!avgDuration || execCount < MIN_EXECUTIONS_FOR_RATE_ALERT) return false - - const avgMs = Number(avgDuration) - const threshold = avgMs * (1 + spikePercent / 100) - - return currentDurationMs > threshold -} - -/** - * Check if execution cost exceeds threshold - */ -function checkCostThreshold(cost: number, thresholdDollars: number): boolean { - return cost > thresholdDollars -} - -async function checkErrorCount( - workflowId: string, - threshold: number, - windowHours: number, - triggerFilter: string[] -): Promise { - const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) - - const result = await db - .select({ count: count() }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.workflowId, workflowId), - eq(workflowExecutionLogs.level, 'error'), - gte(workflowExecutionLogs.createdAt, windowStart), - inArray(workflowExecutionLogs.trigger, triggerFilter) - ) - ) - - const errorCount = result[0]?.count || 0 - return errorCount >= threshold -} - -export async function shouldTriggerAlert( - config: AlertConfig, - context: AlertCheckContext, - lastAlertAt: Date | null -): Promise { - if (isInCooldown(lastAlertAt)) { - logger.debug('Subscription in cooldown, skipping alert check') - return false - } - - const { rule } = config - const { workflowId, status, durationMs, cost, triggerFilter } = context - - switch (rule) { - case 'consecutive_failures': - if (status !== 'error') return false - return checkConsecutiveFailures(workflowId, config.consecutiveFailures!, triggerFilter) - - case 'failure_rate': - if (status !== 'error') return false - return checkFailureRate( - workflowId, - config.failureRatePercent!, - config.windowHours!, - triggerFilter - ) - - case 'latency_threshold': - return checkLatencyThreshold(durationMs, config.durationThresholdMs!) - - case 'latency_spike': - return checkLatencySpike( - workflowId, - durationMs, - config.latencySpikePercent!, - config.windowHours!, - triggerFilter - ) - - case 'cost_threshold': - return checkCostThreshold(cost, config.costThresholdDollars!) - - case 'no_activity': - return false - - case 'error_count': - if (status !== 'error') return false - return checkErrorCount( - workflowId, - config.errorCountThreshold!, - config.windowHours!, - triggerFilter - ) - - default: - logger.warn(`Unknown alert rule: ${rule}`) - return false - } -} diff --git a/apps/sim/lib/notifications/inactivity-polling.ts b/apps/sim/lib/notifications/inactivity-polling.ts deleted file mode 100644 index 254d07be46..0000000000 --- a/apps/sim/lib/notifications/inactivity-polling.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { db } from '@sim/db' -import { - workflow, - workflowDeploymentVersion, - workflowExecutionLogs, - workspaceNotificationDelivery, - workspaceNotificationSubscription, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, gte, inArray, sql } from 'drizzle-orm' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' -import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers' -import { - executeNotificationDelivery, - workspaceNotificationDeliveryTask, -} from '@/background/workspace-notification-delivery' -import type { WorkflowState } from '@/stores/workflows/workflow/types' -import type { AlertConfig } from './alert-rules' -import { isInCooldown } from './alert-rules' - -const logger = createLogger('InactivityPolling') - -const SCHEDULE_BLOCK_TYPES: string[] = [TRIGGER_TYPES.SCHEDULE] -const WEBHOOK_BLOCK_TYPES: string[] = [TRIGGER_TYPES.WEBHOOK, TRIGGER_TYPES.GENERIC_WEBHOOK] - -function deploymentHasTriggerType( - deploymentState: Pick, - triggerFilter: string[] -): boolean { - const blocks = deploymentState.blocks - if (!blocks) return false - - const alwaysAvailable = ['api', 'manual', 'chat'] - if (triggerFilter.some((t) => alwaysAvailable.includes(t))) { - return true - } - - for (const block of Object.values(blocks)) { - if (triggerFilter.includes('schedule') && SCHEDULE_BLOCK_TYPES.includes(block.type)) { - return true - } - - if (triggerFilter.includes('webhook')) { - if (WEBHOOK_BLOCK_TYPES.includes(block.type)) { - return true - } - if (block.triggerMode === true) { - return true - } - } - } - - return false -} - -async function getWorkflowsWithTriggerTypes( - workspaceId: string, - triggerFilter: string[] -): Promise> { - const workflowIds = new Set() - - const deployedWorkflows = await db - .select({ - workflowId: workflow.id, - deploymentState: workflowDeploymentVersion.state, - }) - .from(workflow) - .innerJoin( - workflowDeploymentVersion, - and( - eq(workflowDeploymentVersion.workflowId, workflow.id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .where(and(eq(workflow.workspaceId, workspaceId), eq(workflow.isDeployed, true))) - - for (const w of deployedWorkflows) { - const state = w.deploymentState as WorkflowState | null - if (state && deploymentHasTriggerType(state, triggerFilter)) { - workflowIds.add(w.workflowId) - } - } - - return workflowIds -} - -interface InactivityCheckResult { - subscriptionId: string - workflowId: string - triggered: boolean - reason?: string -} - -async function checkWorkflowInactivity( - subscription: typeof workspaceNotificationSubscription.$inferSelect, - workflowId: string, - alertConfig: AlertConfig -): Promise { - const result: InactivityCheckResult = { - subscriptionId: subscription.id, - workflowId, - triggered: false, - } - - if (isInCooldown(subscription.lastAlertAt)) { - result.reason = 'in_cooldown' - return result - } - - const windowStart = new Date(Date.now() - (alertConfig.inactivityHours || 24) * 60 * 60 * 1000) - const triggerFilter = subscription.triggerFilter - const levelFilter = subscription.levelFilter - - const recentLogs = await db - .select({ id: workflowExecutionLogs.id }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.workflowId, workflowId), - gte(workflowExecutionLogs.createdAt, windowStart), - inArray(workflowExecutionLogs.trigger, triggerFilter), - inArray(workflowExecutionLogs.level, levelFilter) - ) - ) - .limit(1) - - if (recentLogs.length > 0) { - result.reason = 'has_activity' - return result - } - - const [workflowData] = await db - .select({ - name: workflow.name, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData || !workflowData.workspaceId) { - result.reason = 'workflow_not_found' - return result - } - - await db - .update(workspaceNotificationSubscription) - .set({ lastAlertAt: new Date() }) - .where(eq(workspaceNotificationSubscription.id, subscription.id)) - - const deliveryId = generateId() - - await db.insert(workspaceNotificationDelivery).values({ - id: deliveryId, - subscriptionId: subscription.id, - workflowId, - executionId: `inactivity_${Date.now()}`, - status: 'pending', - attempts: 0, - nextAttemptAt: new Date(), - }) - - const now = new Date().toISOString() - const mockLog = { - id: `inactivity_log_${generateId()}`, - workflowId, - executionId: `inactivity_${Date.now()}`, - stateSnapshotId: '', - level: 'info' as const, - trigger: 'system' as const, - startedAt: now, - endedAt: now, - totalDurationMs: 0, - executionData: {}, - cost: { total: 0 }, - workspaceId: workflowData.workspaceId, - createdAt: now, - } - - const payload = { - deliveryId, - subscriptionId: subscription.id, - workspaceId: workflowData.workspaceId, - notificationType: subscription.notificationType, - log: mockLog, - alertConfig, - } - - if (isTriggerDevEnabled) { - await workspaceNotificationDeliveryTask.trigger(payload, { - tags: [ - `workspaceId:${workflowData.workspaceId}`, - `workflowId:${workflowId}`, - `notificationType:${subscription.notificationType}`, - ], - }) - } else { - void executeNotificationDelivery(payload).catch((error) => { - logger.error(`Direct notification delivery failed for ${deliveryId}`, { error }) - }) - } - - result.triggered = true - result.reason = 'alert_sent' - - logger.info(`Inactivity alert triggered for workflow ${workflowId}`, { - subscriptionId: subscription.id, - inactivityHours: alertConfig.inactivityHours, - }) - - return result -} - -export async function pollInactivityAlerts(): Promise<{ - total: number - triggered: number - skipped: number - details: InactivityCheckResult[] -}> { - logger.info('Starting inactivity alert polling') - - const subscriptions = await db - .select() - .from(workspaceNotificationSubscription) - .where( - and( - eq(workspaceNotificationSubscription.active, true), - sql`${workspaceNotificationSubscription.alertConfig}->>'rule' = 'no_activity'` - ) - ) - - if (subscriptions.length === 0) { - logger.info('No active no_activity subscriptions found') - return { total: 0, triggered: 0, skipped: 0, details: [] } - } - - logger.info(`Found ${subscriptions.length} no_activity subscriptions to check`) - - const results: InactivityCheckResult[] = [] - let triggered = 0 - let skipped = 0 - - for (const subscription of subscriptions) { - const alertConfig = subscription.alertConfig as AlertConfig - if (!alertConfig || alertConfig.rule !== 'no_activity') { - continue - } - - const triggerFilter = subscription.triggerFilter as string[] - if (!triggerFilter || triggerFilter.length === 0) { - logger.warn(`Subscription ${subscription.id} has no trigger filter, skipping`) - continue - } - - const eligibleWorkflowIds = await getWorkflowsWithTriggerTypes( - subscription.workspaceId, - triggerFilter - ) - - let workflowIds: string[] = [] - - if (subscription.allWorkflows) { - workflowIds = Array.from(eligibleWorkflowIds) - } else { - workflowIds = (subscription.workflowIds || []).filter((id) => eligibleWorkflowIds.has(id)) - } - - logger.debug(`Checking ${workflowIds.length} workflows for subscription ${subscription.id}`, { - triggerFilter, - eligibleCount: eligibleWorkflowIds.size, - }) - - for (const workflowId of workflowIds) { - const result = await checkWorkflowInactivity(subscription, workflowId, alertConfig) - results.push(result) - - if (result.triggered) { - triggered++ - } else { - skipped++ - } - } - } - - logger.info(`Inactivity polling completed: ${triggered} alerts triggered, ${skipped} skipped`) - - return { - total: results.length, - triggered, - skipped, - details: results, - } -} diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 96ce09813b..3352048b94 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -19,6 +19,7 @@ import { } from '@/lib/webhooks/pending-verification' import { getProviderHandler } from '@/lib/webhooks/providers' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' +import { SIM_TRIGGER_PROVIDER } from '@/lib/workspace-events/constants' import { executeWebhookJob } from '@/background/webhook-execution' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import { isPollingWebhookProvider } from '@/triggers/constants' @@ -772,7 +773,9 @@ export async function processPolledWebhookEvent( ...(credentialId ? { credentialId } : {}), } - if (isPollingWebhookProvider(payload.provider) && !shouldExecuteInline()) { + const isQueueRoutedProvider = + isPollingWebhookProvider(payload.provider) || payload.provider === SIM_TRIGGER_PROVIDER + if (isQueueRoutedProvider && !shouldExecuteInline()) { const jobId = await (await getJobQueue()).enqueue('webhook-execution', payload, { metadata: { workflowId: foundWorkflow.id, diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index 15fb4161b7..15e130bdd6 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -267,8 +267,15 @@ export function getBlockOutputs( if (triggerId && isTriggerValid(triggerId)) { const trigger = getTrigger(triggerId) if (trigger.outputs) { - // TriggerOutput is compatible with OutputFieldDefinition at runtime - return trigger.outputs as OutputDefinition + // TriggerOutput is compatible with OutputFieldDefinition at runtime. + // Conditions narrow outputs to the selected trigger configuration + // (e.g. the Sim trigger only surfaces execution fields for + // execution-backed event types). + return filterOutputsByCondition( + trigger.outputs as OutputDefinition, + subBlocks, + includeHidden + ) } } } diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index 2fff78e912..ffe40b6fc9 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -10,6 +10,11 @@ const { mockRecordAudit, mockCaptureServerEvent, mockTransaction, + mockDeployWorkflow, + mockActivateWorkflowVersion, + mockValidateWorkflowSchedules, + mockValidateTriggerWebhookConfigForDeploy, + mockEmitWorkflowDeployedEvent, mockTx, } = vi.hoisted(() => ({ mockLimit: vi.fn(), @@ -18,6 +23,11 @@ const { mockRecordAudit: vi.fn(), mockCaptureServerEvent: vi.fn(), mockTransaction: vi.fn(), + mockDeployWorkflow: vi.fn(), + mockActivateWorkflowVersion: vi.fn(), + mockValidateWorkflowSchedules: vi.fn(), + mockValidateTriggerWebhookConfigForDeploy: vi.fn(), + mockEmitWorkflowDeployedEvent: vi.fn(), mockTx: { select: vi.fn(() => ({ from: vi.fn(() => ({ @@ -59,11 +69,26 @@ vi.mock('@sim/db', () => ({ })) vi.mock('@sim/audit', () => ({ - AuditAction: { WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED' }, + AuditAction: { + WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED', + WORKFLOW_DEPLOYED: 'WORKFLOW_DEPLOYED', + WORKFLOW_UNDEPLOYED: 'WORKFLOW_UNDEPLOYED', + WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED', + }, AuditResourceType: { WORKFLOW: 'WORKFLOW' }, recordAudit: mockRecordAudit, })) +vi.mock('@/lib/workflows/deployment-outbox', () => ({ + enqueueWorkflowDeploymentSideEffects: vi.fn().mockResolvedValue('outbox-1'), + enqueueWorkflowUndeploySideEffects: vi.fn().mockResolvedValue('outbox-2'), + processWorkflowDeploymentOutboxEvent: vi.fn().mockResolvedValue('completed'), +})) + +vi.mock('@/lib/workspace-events/emitter', () => ({ + emitWorkflowDeployedEvent: mockEmitWorkflowDeployedEvent, +})) + vi.mock('@/lib/core/config/env', () => ({ env: { INTERNAL_API_SECRET: 'secret' }, })) @@ -78,9 +103,9 @@ vi.mock('@/lib/posthog/server', () => ({ })) vi.mock('@/lib/workflows/persistence/utils', () => ({ - activateWorkflowVersion: vi.fn(), + activateWorkflowVersion: mockActivateWorkflowVersion, activateWorkflowVersionById: vi.fn(), - deployWorkflow: vi.fn(), + deployWorkflow: mockDeployWorkflow, loadWorkflowDeploymentSnapshot: vi.fn(), saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, undeployWorkflow: vi.fn(), @@ -95,15 +120,20 @@ vi.mock('@/lib/webhooks/deploy', () => ({ cleanupWebhooksForWorkflow: vi.fn(), restorePreviousVersionWebhooks: vi.fn(), saveTriggerWebhooksForDeploy: vi.fn(), + validateTriggerWebhookConfigForDeploy: mockValidateTriggerWebhookConfigForDeploy, })) vi.mock('@/lib/workflows/schedules', () => ({ cleanupDeploymentVersion: vi.fn(), createSchedulesForDeploy: vi.fn(), - validateWorkflowSchedules: vi.fn(), + validateWorkflowSchedules: mockValidateWorkflowSchedules, })) -import { performRevertToVersion } from '@/lib/workflows/orchestration/deploy' +import { + performActivateVersion, + performFullDeploy, + performRevertToVersion, +} from '@/lib/workflows/orchestration/deploy' describe('performRevertToVersion', () => { beforeEach(() => { @@ -209,3 +239,122 @@ describe('performRevertToVersion', () => { expect(Object.hasOwn(workflowUpdate, 'variables')).toBe(false) }) }) + +describe('performFullDeploy workspace event emission', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) + mockLimit.mockResolvedValue([ + { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + ]) + mockDeployWorkflow.mockResolvedValue({ + success: true, + deployedAt: new Date(), + version: 4, + deploymentVersionId: 'dv-1', + previousVersionId: null, + currentState: { blocks: {} }, + }) + }) + + it('emits workflow_deployed after a successful deploy', async () => { + const result = await performFullDeploy({ + workflowId: 'workflow-1', + userId: 'user-1', + }) + + expect(result.success).toBe(true) + expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1) + expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + workflowName: 'My Workflow', + workspaceId: 'workspace-1', + version: 4, + }) + }) + + it('does not emit when the deploy fails', async () => { + mockDeployWorkflow.mockResolvedValueOnce({ success: false, error: 'nope' }) + + const result = await performFullDeploy({ + workflowId: 'workflow-1', + userId: 'user-1', + }) + + expect(result.success).toBe(false) + expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() + }) + + it('emission rejection does not fail the deploy', async () => { + mockEmitWorkflowDeployedEvent.mockRejectedValueOnce(new Error('emit failed')) + + const result = await performFullDeploy({ + workflowId: 'workflow-1', + userId: 'user-1', + }) + + expect(result.success).toBe(true) + }) +}) + +describe('performActivateVersion workspace event emission', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 }))) + mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) + mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) + mockLimit.mockResolvedValue([{ id: 'dv-2', state: { blocks: {} }, isActive: false }]) + mockActivateWorkflowVersion.mockResolvedValue({ + success: true, + deployedAt: new Date(), + previousVersionId: 'dv-1', + }) + }) + + it('emits workflow_deployed when activating a version (rollback/activation)', async () => { + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + }) + + expect(result.success).toBe(true) + expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + workflowName: 'My Workflow', + workspaceId: 'workspace-1', + version: 2, + }) + }) + + it('does not emit when the version is already active (no-op activation)', async () => { + mockLimit + .mockResolvedValueOnce([{ id: 'dv-2', state: { blocks: {} }, isActive: true }]) + .mockResolvedValueOnce([{ deployedAt: new Date() }]) + + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + }) + + expect(result.success).toBe(true) + expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() + }) + + it('does not emit when activation fails', async () => { + mockActivateWorkflowVersion.mockResolvedValueOnce({ success: false, error: 'nope' }) + + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' }, + }) + + expect(result.success).toBe(false) + expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 1b6089cc5b..16ba0be08a 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -22,6 +22,7 @@ import { undeployWorkflow, } from '@/lib/workflows/persistence/utils' import { validateWorkflowSchedules } from '@/lib/workflows/schedules' +import { emitWorkflowDeployedEvent } from '@/lib/workspace-events/emitter' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('DeployOrchestration') @@ -188,6 +189,16 @@ export async function performFullDeploy( const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId) await notifySocketDeploymentChanged(workflowId) + const workspaceId = workflowData.workspaceId as string | null + if (workspaceId) { + void emitWorkflowDeployedEvent({ + workflowId, + workflowName: (workflowData.name as string) || workflowId, + workspaceId, + version: deployResult.version ?? null, + }) + } + return { success: true, deployedAt, @@ -419,6 +430,16 @@ export async function performActivateVersion( const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId) await notifySocketDeploymentChanged(workflowId) + const activationWorkspaceId = (workflow.workspaceId as string) || null + if (activationWorkspaceId) { + void emitWorkflowDeployedEvent({ + workflowId, + workflowName: (workflow.name as string) || workflowId, + workspaceId: activationWorkspaceId, + version, + }) + } + return { success: true, deployedAt: result.deployedAt, diff --git a/apps/sim/lib/workflows/subblocks/display.test.ts b/apps/sim/lib/workflows/subblocks/display.test.ts new file mode 100644 index 0000000000..305c3cd6dd --- /dev/null +++ b/apps/sim/lib/workflows/subblocks/display.test.ts @@ -0,0 +1,183 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/blocks', () => ({ + getBlock: (type: string) => (type === 'slack' ? { name: 'Slack' } : undefined), +})) + +import { + getDisplayValue, + resolveDropdownLabel, + resolveFilterFieldLabel, + resolveSkillsLabel, + resolveToolsLabel, + resolveVariablesLabel, + resolveWorkflowMultiSelectLabel, + resolveWorkflowSelectionLabel, + summarizeNames, +} from '@/lib/workflows/subblocks/display' +import type { SubBlockConfig } from '@/blocks/types' + +const workflowSelector = { id: 'workflowId', type: 'workflow-selector' } as SubBlockConfig +const workflowMulti = { + id: 'workflowIds', + type: 'dropdown', + multiSelect: true, +} as SubBlockConfig +const variablesInput = { id: 'variables', type: 'variables-input' } as SubBlockConfig +const toolInput = { id: 'tools', type: 'tool-input' } as SubBlockConfig +const skillInput = { id: 'skills', type: 'skill-input' } as SubBlockConfig + +describe('summarizeNames', () => { + it('formats 0, 1, 2, and 2+N name lists', () => { + expect(summarizeNames([])).toBeNull() + expect(summarizeNames(['A'])).toBe('A') + expect(summarizeNames(['A', 'B'])).toBe('A, B') + expect(summarizeNames(['A', 'B', 'C', 'D'])).toBe('A, B +2') + }) +}) + +describe('workflow selection labels', () => { + const lookup = { workflowMap: { 'wf-1': { name: 'Billing' } }, ready: true } + + it('resolves a single workflow selection to its name', () => { + expect(resolveWorkflowSelectionLabel(workflowSelector, 'wf-1', lookup)).toBe('Billing') + }) + + it('labels missing workflows as deleted only after the lookup is ready', () => { + expect(resolveWorkflowSelectionLabel(workflowSelector, 'wf-gone', lookup)).toBe( + 'Deleted Workflow' + ) + expect( + resolveWorkflowSelectionLabel(workflowSelector, 'wf-gone', { ...lookup, ready: false }) + ).toBeNull() + }) + + it('summarizes multi-select workflow ids with the deleted fallback', () => { + expect(resolveWorkflowMultiSelectLabel(workflowMulti, ['wf-1', 'wf-gone'], lookup)).toBe( + 'Billing, Deleted Workflow' + ) + expect( + resolveWorkflowMultiSelectLabel(workflowMulti, ['wf-1'], { ...lookup, ready: false }) + ).toBeNull() + }) + + it('matches multi-select subblocks by canonicalParamId as well as id', () => { + const canonical = { + id: 'workflowSelector', + type: 'dropdown', + multiSelect: true, + canonicalParamId: 'workflowIds', + } as SubBlockConfig + expect(resolveWorkflowMultiSelectLabel(canonical, ['wf-1'], lookup)).toBe('Billing') + }) +}) + +describe('resolveVariablesLabel', () => { + it('resolves variable ids to live names and falls back to stored names', () => { + const variables = [{ id: 'var-1', name: 'apiKey' }] + expect( + resolveVariablesLabel( + variablesInput, + [ + { variableId: 'var-1', value: 1 }, + { variableName: 'region', value: 2 }, + ], + variables + ) + ).toBe('apiKey, region') + }) +}) + +describe('resolveToolsLabel', () => { + it('resolves titles, custom tools by id, schema names, and registry blocks', () => { + const customTools = [{ id: 'ct-1', title: 'My Tool' }] + expect( + resolveToolsLabel( + toolInput, + [ + { title: 'Explicit' }, + { type: 'custom-tool', customToolId: 'ct-1' }, + { schema: { function: { name: 'inline_fn' } } }, + { type: 'slack' }, + ], + customTools + ) + ).toBe('Explicit, My Tool +2') + }) + + it('skips unresolvable entries instead of inventing labels', () => { + expect(resolveToolsLabel(toolInput, [{ type: 'custom-tool', customToolId: 'gone' }], [])).toBe( + null + ) + }) +}) + +describe('resolveSkillsLabel', () => { + it('prefers live skill names and falls back to the stored name', () => { + const skills = [{ id: 'sk-1', name: 'Research' }] + expect( + resolveSkillsLabel( + skillInput, + [{ skillId: 'sk-1' }, { skillId: 'sk-deleted', name: 'Old Name' }], + skills + ) + ).toBe('Research, Old Name') + }) + + it('never renders raw skill ids', () => { + expect(resolveSkillsLabel(skillInput, [{ skillId: 'sk-unknown' }], [])).toBeNull() + }) +}) + +describe('resolveDropdownLabel', () => { + const dropdown = { + id: 'mode', + type: 'dropdown', + options: [{ id: 'opt-1', label: 'Option One' }, 'literal'], + } as SubBlockConfig + + it('resolves option ids and string options to labels', () => { + expect(resolveDropdownLabel(dropdown, 'opt-1')).toBe('Option One') + expect(resolveDropdownLabel(dropdown, 'literal')).toBe('literal') + expect(resolveDropdownLabel(dropdown, 'missing')).toBeNull() + }) +}) + +describe('resolveFilterFieldLabel', () => { + const filterField = { id: 'filter', type: 'short-input' } as SubBlockConfig + + it('renders compact JSON for filter fields and truncates long values', () => { + expect(resolveFilterFieldLabel(filterField, '{"a":1}')).toBe('{"a":1}') + const long = JSON.stringify({ column: 'status', operator: 'contains', value: 'running' }) + expect(resolveFilterFieldLabel(filterField, long)).toBe(`${long.slice(0, 32)}...`) + }) + + it('returns null for non-filter subblocks and non-JSON values', () => { + expect( + resolveFilterFieldLabel({ id: 'other', type: 'short-input' } as SubBlockConfig, '{"a":1}') + ).toBeNull() + expect(resolveFilterFieldLabel(filterField, 'plain text')).toBeNull() + }) +}) + +describe('getDisplayValue', () => { + it('handles empty, scalar, and object values', () => { + expect(getDisplayValue(null)).toBe('-') + expect(getDisplayValue('hello')).toBe('hello') + expect(getDisplayValue({ a: 1 })).toBe('a: 1') + }) + + it('summarizes name-bearing arrays', () => { + expect( + getDisplayValue([ + { variableName: 'one', variableId: 'v1', value: 1 }, + { variableName: 'two', variableId: 'v2', value: 2 }, + { variableName: 'three', variableId: 'v3', value: 3 }, + ]) + ).toBe('one, two +1') + expect(getDisplayValue(['a', 'b'])).toBe('a, b') + }) +}) diff --git a/apps/sim/lib/workflows/subblocks/display.ts b/apps/sim/lib/workflows/subblocks/display.ts new file mode 100644 index 0000000000..8ddb49f63a --- /dev/null +++ b/apps/sim/lib/workflows/subblocks/display.ts @@ -0,0 +1,513 @@ +/** + * Pure display helpers for collapsed subblock rows. + * + * Shared by the canvas editor (`workflow-block.tsx`, hook-fed data) and the + * read-only preview (`preview-workflow/.../block.tsx`, prop/store-fed data). + * Every resolver takes plain data instead of hooks so both surfaces run the + * exact same logic and cannot drift. + */ +import { truncate } from '@sim/utils/string' +import type { FilterRule, SortRule } from '@/lib/table/types' +import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' +import { getBlock } from '@/blocks' +import type { SubBlockConfig } from '@/blocks/types' + +/** + * Joins display names as "A", "A, B", or "A, B +N". + * Returns null for an empty list so callers can fall through. + */ +export function summarizeNames(names: string[]): string | null { + if (names.length === 0) return null + if (names.length === 1) return names[0] + if (names.length === 2) return `${names[0]}, ${names[1]}` + return `${names[0]}, ${names[1]} +${names.length - 2}` +} + +interface WorkflowTableRow { + id: string + cells: Record +} + +interface FieldFormat { + id: string + name: string + type?: string + value?: string + collapsed?: boolean +} + +interface TagFilterItem { + id: string + tagName: string + fieldType?: string + operator?: string + tagValue: string +} + +interface DocumentTagItem { + id: string + tagName: string + fieldType?: string + value: string +} + +const isTableRowArray = (value: unknown): value is WorkflowTableRow[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'id' in firstItem && + 'cells' in firstItem && + typeof firstItem.cells === 'object' + ) +} + +const isFieldFormatArray = (value: unknown): value is FieldFormat[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'id' in firstItem && + 'name' in firstItem && + typeof firstItem.name === 'string' + ) +} + +/** Checks if a value is a plain object (not array, not null). */ +const isPlainObject = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Type guard for variable assignments arrays (variables-input subblocks). */ +const isVariableAssignmentsArray = ( + value: unknown +): value is Array<{ id?: string; variableId?: string; variableName?: string; value: unknown }> => { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (item) => + typeof item === 'object' && + item !== null && + ('variableName' in item || 'variableId' in item) + ) + ) +} + +const isMessagesArray = (value: unknown): value is Array<{ role: string; content: string }> => { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (item) => + typeof item === 'object' && + item !== null && + 'role' in item && + 'content' in item && + typeof item.role === 'string' && + typeof item.content === 'string' + ) + ) +} + +const isTagFilterArray = (value: unknown): value is TagFilterItem[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'tagName' in firstItem && + 'tagValue' in firstItem && + typeof firstItem.tagName === 'string' + ) +} + +const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'tagName' in firstItem && + 'value' in firstItem && + !('tagValue' in firstItem) && + typeof firstItem.tagName === 'string' + ) +} + +const isFilterConditionArray = (value: unknown): value is FilterRule[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'column' in firstItem && + 'operator' in firstItem && + 'logicalOperator' in firstItem && + typeof firstItem.column === 'string' + ) +} + +const isSortConditionArray = (value: unknown): value is SortRule[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'column' in firstItem && + 'direction' in firstItem && + typeof firstItem.column === 'string' && + (firstItem.direction === 'asc' || firstItem.direction === 'desc') + ) +} + +/** + * Attempts to parse a JSON string, returning the parsed value or the + * original value if parsing fails. + */ +const tryParseJson = (value: unknown): unknown => { + if (typeof value !== 'string') return value + try { + const trimmed = value.trim() + if ( + (trimmed.startsWith('[') && trimmed.endsWith(']')) || + (trimmed.startsWith('{') && trimmed.endsWith('}')) + ) { + return JSON.parse(trimmed) + } + } catch { + return value + } + return value +} + +/** + * Formats a subblock value for display, intelligently handling nested + * objects and arrays. + */ +export const getDisplayValue = (value: unknown): string => { + if (value == null || value === '') return '-' + + const parsedValue = tryParseJson(value) + + if (isMessagesArray(parsedValue)) { + const firstMessage = parsedValue[0] + if (!firstMessage?.content || firstMessage.content.trim() === '') return '-' + const content = firstMessage.content.trim() + return truncate(content, 50) + } + + if (isVariableAssignmentsArray(parsedValue)) { + const names = parsedValue.map((a) => a.variableName).filter((name): name is string => !!name) + return summarizeNames(names) ?? '-' + } + + if (isTagFilterArray(parsedValue)) { + const names = parsedValue + .filter((f) => typeof f.tagName === 'string' && f.tagName.trim() !== '') + .map((f) => f.tagName) + return summarizeNames(names) ?? '-' + } + + if (isDocumentTagArray(parsedValue)) { + const names = parsedValue + .filter((t) => typeof t.tagName === 'string' && t.tagName.trim() !== '') + .map((t) => t.tagName) + return summarizeNames(names) ?? '-' + } + + if (isFilterConditionArray(parsedValue)) { + const opLabels: Record = { + eq: '=', + ne: '≠', + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + contains: '~', + in: 'in', + } + const names = parsedValue + .filter((c) => typeof c.column === 'string' && c.column.trim() !== '') + .map((c) => `${c.column} ${opLabels[c.operator] || c.operator} ${c.value || '?'}`) + return summarizeNames(names) ?? '-' + } + + if (isSortConditionArray(parsedValue)) { + const names = parsedValue + .filter((c) => typeof c.column === 'string' && c.column.trim() !== '') + .map((c) => `${c.column} ${c.direction === 'desc' ? '↓' : '↑'}`) + return summarizeNames(names) ?? '-' + } + + if (isTableRowArray(parsedValue)) { + const nonEmptyRows = parsedValue.filter((row) => { + const cellValues = Object.values(row.cells) + return cellValues.some((cell) => cell && cell.trim() !== '') + }) + + if (nonEmptyRows.length === 0) return '-' + if (nonEmptyRows.length === 1) { + const firstRow = nonEmptyRows[0] + const cellEntries = Object.entries(firstRow.cells).filter(([, val]) => val?.trim()) + if (cellEntries.length === 0) return '-' + const preview = cellEntries + .slice(0, 2) + .map(([key, val]) => `${key}: ${val}`) + .join(', ') + return cellEntries.length > 2 ? `${preview}...` : preview + } + return `${nonEmptyRows.length} rows` + } + + if (isFieldFormatArray(parsedValue)) { + const names = parsedValue + .filter((field) => typeof field.name === 'string' && field.name.trim() !== '') + .map((field) => field.name) + return summarizeNames(names) ?? '-' + } + + if (isPlainObject(parsedValue)) { + const entries = Object.entries(parsedValue).filter( + ([, val]) => val !== null && val !== undefined && val !== '' + ) + + if (entries.length === 0) return '-' + if (entries.length === 1) { + const [key, val] = entries[0] + const valStr = String(val).slice(0, 30) + return `${key}: ${valStr}${String(val).length > 30 ? '...' : ''}` + } + const preview = entries + .slice(0, 2) + .map(([key]) => key) + .join(', ') + return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview + } + + if (Array.isArray(parsedValue)) { + const getItemDisplayValue = (item: unknown): string => { + if (typeof item === 'object' && item !== null) { + const obj = item as Record + return String(obj.title || obj.name || obj.label || obj.id || JSON.stringify(item)) + } + return String(item) + } + const names = parsedValue + .filter((item) => item !== null && item !== undefined && item !== '') + .map(getItemDisplayValue) + return summarizeNames(names) ?? '-' + } + + const stringValue = String(value) + if (stringValue === '[object Object]') { + try { + const json = JSON.stringify(parsedValue) + if (json.length <= 40) return json + return `${json.slice(0, 37)}...` + } catch { + return '-' + } + } + + return stringValue.trim().length > 0 ? stringValue : '-' +} + +/** + * Workflow id -> metadata lookup for the workflow selector resolvers. + * `ready` gates resolution so missing entries only render as deleted once + * the lookup has actually loaded. + */ +interface WorkflowNameLookup { + workflowMap: Record + ready: boolean +} + +/** + * Resolves filter/sort builder subblocks to a compact single-line JSON + * preview. Returns null for other subblocks; callers use a non-null result + * to apply monospace styling. + */ +export function resolveFilterFieldLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown +): string | null { + const isFilterField = + subBlock?.id === 'filter' || subBlock?.id === 'filterCriteria' || subBlock?.id === 'sort' + if (!isFilterField || !rawValue) return null + + const parsedValue = tryParseJson(rawValue) + if (!isPlainObject(parsedValue) && !Array.isArray(parsedValue)) return null + + try { + const jsonStr = JSON.stringify(parsedValue, null, 0) + return jsonStr.length <= 35 ? jsonStr : truncate(jsonStr, 32) + } catch { + return null + } +} + +/** + * Resolves a static dropdown/combobox value to its option label. + * Returns null if not a dropdown/combobox or no matching option is found. + */ +export function resolveDropdownLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown +): string | null { + if (!subBlock || (subBlock.type !== 'dropdown' && subBlock.type !== 'combobox')) return null + if (!rawValue || typeof rawValue !== 'string') return null + + const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options + if (!options) return null + + const option = options.find((opt) => + typeof opt === 'string' ? opt === rawValue : opt.id === rawValue + ) + + if (!option) return null + return typeof option === 'string' ? option : option.label +} + +/** Resolves a workflow-selector value to the workflow's name. */ +export function resolveWorkflowSelectionLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown, + lookup: WorkflowNameLookup +): string | null { + if (subBlock?.type !== 'workflow-selector') return null + if (!rawValue || typeof rawValue !== 'string') return null + if (!lookup.ready) return null + + return lookup.workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL +} + +/** + * Resolves multi-select workflow dropdowns (e.g. the Sim trigger's workflow + * scope) to a workflow-name summary. + */ +export function resolveWorkflowMultiSelectLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown, + lookup: WorkflowNameLookup +): string | null { + const isWorkflowMultiSelect = + subBlock?.type === 'dropdown' && + subBlock.multiSelect && + (subBlock.id === 'workflowIds' || subBlock.canonicalParamId === 'workflowIds') + if (!isWorkflowMultiSelect) return null + if (!Array.isArray(rawValue) || rawValue.length === 0) return null + if (!lookup.ready) return null + + const names = rawValue + .filter((id): id is string => typeof id === 'string' && id.length > 0) + .map((id) => lookup.workflowMap[id]?.name ?? DELETED_WORKFLOW_LABEL) + + return summarizeNames(names) +} + +/** Resolves a variables-input value to a variable-name summary. */ +export function resolveVariablesLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown, + variables: Array<{ id: string; name: string }> +): string | null { + if (subBlock?.type !== 'variables-input') return null + if (!isVariableAssignmentsArray(rawValue)) return null + + const names = rawValue + .map((assignment) => { + if (assignment.variableId) { + return variables.find((variable) => variable.id === assignment.variableId)?.name + } + if (assignment.variableName) return assignment.variableName + return null + }) + .filter((name): name is string => !!name) + + return summarizeNames(names) +} + +/** + * Resolves a tool-input value to a tool-name summary. Stored tool entries + * come in several historical shapes, checked in priority order: explicit + * title, custom tool referenced by id, inline schema name, OpenAI function + * name, then the block registry. + */ +export function resolveToolsLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown, + customTools: Array<{ id: string; title?: string; schema?: { function?: { name?: string } } }> +): string | null { + if (subBlock?.type !== 'tool-input') return null + if (!Array.isArray(rawValue) || rawValue.length === 0) return null + + const names = rawValue + .map((tool: unknown) => { + if (!tool || typeof tool !== 'object') return null + const t = tool as Record + + if (typeof t.title === 'string' && t.title) return t.title + + if (t.type === 'custom-tool' && typeof t.customToolId === 'string') { + const customTool = customTools.find((candidate) => candidate.id === t.customToolId) + if (customTool?.title) return customTool.title + if (customTool?.schema?.function?.name) return customTool.schema.function.name + } + + const schema = t.schema as { function?: { name?: string } } | undefined + if (schema?.function?.name) return schema.function.name + + const fn = t.function as { name?: string } | undefined + if (fn?.name) return fn.name + + if ( + typeof t.type === 'string' && + t.type !== 'custom-tool' && + t.type !== 'mcp' && + t.type !== 'workflow' && + t.type !== 'workflow_input' + ) { + const blockConfig = getBlock(t.type) + if (blockConfig?.name) return blockConfig.name + } + + return null + }) + .filter((name): name is string => !!name) + + return summarizeNames(names) +} + +/** + * Resolves a skill-input value to a skill-name summary: the live skill name + * when the skill still exists, otherwise the name stored alongside the + * reference. Unresolvable entries are skipped rather than shown as raw ids. + */ +export function resolveSkillsLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown, + skills: Array<{ id: string; name: string }> +): string | null { + if (subBlock?.type !== 'skill-input') return null + if (!Array.isArray(rawValue) || rawValue.length === 0) return null + + const names = rawValue + .map((skill: unknown) => { + if (!skill || typeof skill !== 'object') return null + const s = skill as { skillId?: string; name?: string } + + if (s.skillId) { + const found = skills.find((candidate) => candidate.id === s.skillId) + if (found?.name) return found.name + } + if (typeof s.name === 'string' && s.name) return s.name + + return null + }) + .filter((name): name is string => !!name) + + return summarizeNames(names) +} diff --git a/apps/sim/lib/workflows/subblocks/options.ts b/apps/sim/lib/workflows/subblocks/options.ts new file mode 100644 index 0000000000..526dcb0d82 --- /dev/null +++ b/apps/sim/lib/workflows/subblocks/options.ts @@ -0,0 +1,32 @@ +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface SubBlockOption { + label: string + id: string +} + +/** + * Loads the active workspace's workflows for multi-select subblocks + * (`fetchOptions`). Set `excludeActiveWorkflow` for surfaces where selecting + * the current workflow is meaningless (e.g. the Sim trigger never receives + * events about itself). + */ +export async function fetchWorkspaceWorkflowOptions(options?: { + excludeActiveWorkflow?: boolean +}): Promise { + const registry = useWorkflowRegistry.getState() + const workspaceId = registry.hydration.workspaceId + if (!workspaceId) return [] + + const workflows = await getQueryClient().fetchQuery( + getWorkflowListQueryOptions(workspaceId, 'active') + ) + + return workflows + .filter( + (workflow) => !options?.excludeActiveWorkflow || workflow.id !== registry.activeWorkflowId + ) + .map((workflow) => ({ id: workflow.id, label: workflow.name })) +} diff --git a/apps/sim/lib/workflows/triggers/triggers.ts b/apps/sim/lib/workflows/triggers/triggers.ts index ab5de09c21..a7b5077afc 100644 --- a/apps/sim/lib/workflows/triggers/triggers.ts +++ b/apps/sim/lib/workflows/triggers/triggers.ts @@ -12,6 +12,7 @@ export const TRIGGER_TYPES = { WEBHOOK: 'webhook', GENERIC_WEBHOOK: 'generic_webhook', SCHEDULE: 'schedule', + SIM: 'sim_workspace_event', START: 'start_trigger', STARTER: 'starter', // Legacy } as const @@ -96,6 +97,7 @@ export function classifyStartBlockType( return StartBlockPath.SPLIT_MANUAL case TRIGGER_TYPES.WEBHOOK: case TRIGGER_TYPES.SCHEDULE: + case TRIGGER_TYPES.SIM: return StartBlockPath.EXTERNAL_TRIGGER default: if (opts?.category === 'triggers' || opts?.triggerModeEnabled) { diff --git a/apps/sim/lib/workspace-events/constants.ts b/apps/sim/lib/workspace-events/constants.ts new file mode 100644 index 0000000000..5a5c0f09b8 --- /dev/null +++ b/apps/sim/lib/workspace-events/constants.ts @@ -0,0 +1,169 @@ +/** + * Shared constants for the Sim workspace-event trigger. + * + * This module is imported from both client code (trigger/block definitions) + * and server code (the event emitter), so it must stay free of server-only + * dependencies such as the database client. + */ + +/** Provider string recorded on webhook rows and execution logs for Sim trigger runs. */ +export const SIM_TRIGGER_PROVIDER = 'sim' + +/** Trigger ID in the trigger registry. Must equal the block type for pure trigger blocks. */ +export const SIM_WORKSPACE_EVENT_TRIGGER_ID = 'sim_workspace_event' + +/** Events that fire 1:1 with their source occurrence (no rule evaluation, no cooldown). */ +export const SIM_PLAIN_EVENT_TYPES = [ + 'execution_success', + 'execution_error', + 'workflow_deployed', +] as const + +/** Rule-based events ported from the legacy notification alert rules. */ +export const SIM_RULE_EVENT_TYPES = [ + 'consecutive_failures', + 'failure_rate', + 'latency_threshold', + 'latency_spike', + 'cost_threshold', + 'error_count', + 'no_activity', +] as const + +export const SIM_EVENT_TYPES = [...SIM_PLAIN_EVENT_TYPES, ...SIM_RULE_EVENT_TYPES] as const + +export type SimPlainEventType = (typeof SIM_PLAIN_EVENT_TYPES)[number] +export type SimRuleEventType = (typeof SIM_RULE_EVENT_TYPES)[number] +export type SimEventType = (typeof SIM_EVENT_TYPES)[number] + +/** + * Plain events that ARE a run completing. These carry the run summary fields + * (runId, durationMs, cost, finalOutput) at the top level. + */ +const SIM_PLAIN_RUN_EVENT_TYPES = ['execution_success', 'execution_error'] as const + +/** + * Rule events tripped by a run completing. The run is evidence for the + * condition rather than the event itself, so its summary nests under + * `triggeringRun`. no_activity is excluded — it has no triggering run. + */ +const SIM_RUN_BACKED_RULE_EVENT_TYPES = SIM_RULE_EVENT_TYPES.filter( + (eventType) => eventType !== 'no_activity' +) + +export function isSimRuleEventType(eventType: string): eventType is SimRuleEventType { + return (SIM_RULE_EVENT_TYPES as readonly string[]).includes(eventType) +} + +/** Cooldown between firings of the same rule-based subscription. */ +export const SIM_RULE_COOLDOWN_HOURS = 1 + +/** Minimum executions in the window before rate-based rules can fire. */ +export const SIM_MIN_EXECUTIONS_FOR_RATE_RULES = 5 + +/** Default values for rule configuration subblocks, ported from the legacy alert rules. */ +export const SIM_RULE_DEFAULTS = { + consecutiveFailures: 3, + failureRatePercent: 50, + windowHours: 24, + durationThresholdMs: 30000, + latencySpikePercent: 100, + /** 200 credits = $1 (1 credit = $0.005). */ + costThresholdCredits: 200, + errorCountThreshold: 10, + inactivityHours: 24, +} as const + +/** Maximum serialized size of the finalOutput payload field. */ +export const SIM_FINAL_OUTPUT_MAX_BYTES = 64 * 1024 + +interface SimEventPayloadFieldCondition { + field: 'eventType' + value: SimEventType | SimEventType[] +} + +interface SimEventPayloadField { + type: 'string' | 'number' | 'json' | 'boolean' + description: string + /** Restricts which event types surface this field in the tag dropdown. */ + condition?: SimEventPayloadFieldCondition + /** Nested fields for json outputs, surfaced as dotted paths in the tag dropdown. */ + properties?: Record +} + +/** Run summary fields shared by top-level plain events and the nested triggeringRun. */ +const RUN_SUMMARY_FIELDS = { + runId: { + type: 'string', + description: 'The source run ID', + }, + durationMs: { + type: 'number', + description: 'Source run duration in milliseconds', + }, + cost: { + type: 'number', + description: 'Source run cost in credits', + }, + finalOutput: { + type: 'json', + description: 'Final output of the source run (truncated when large)', + }, +} as const + +/** + * Canonical payload shape delivered to Sim trigger workflows. + * + * The trigger's declared outputs and the runtime payload builder both derive + * from this map so the tag dropdown and the actual payload can never drift + * (enforced by tests on both sides). Conditions narrow the tag dropdown to + * the fields that are meaningful for the selected event type; the runtime + * payload always carries every key (null where not applicable). + */ +export const SIM_EVENT_PAYLOAD_FIELDS = { + event: { + type: 'string', + description: 'The workspace event type that fired this trigger', + }, + timestamp: { + type: 'string', + description: 'Event timestamp in ISO format', + }, + workflowId: { + type: 'string', + description: 'The source workflow ID', + }, + workflowName: { + type: 'string', + description: 'The source workflow name', + }, + runId: { + ...RUN_SUMMARY_FIELDS.runId, + condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] }, + }, + durationMs: { + ...RUN_SUMMARY_FIELDS.durationMs, + condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] }, + }, + cost: { + ...RUN_SUMMARY_FIELDS.cost, + condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] }, + }, + finalOutput: { + ...RUN_SUMMARY_FIELDS.finalOutput, + condition: { field: 'eventType', value: [...SIM_PLAIN_RUN_EVENT_TYPES] }, + }, + triggeringRun: { + type: 'json', + description: 'The run that tripped this condition', + condition: { field: 'eventType', value: [...SIM_RUN_BACKED_RULE_EVENT_TYPES] }, + properties: RUN_SUMMARY_FIELDS, + }, + version: { + type: 'number', + description: 'The deployment version number that was activated', + condition: { field: 'eventType', value: 'workflow_deployed' }, + }, +} as const satisfies Record + +export type SimEventPayloadFieldKey = keyof typeof SIM_EVENT_PAYLOAD_FIELDS diff --git a/apps/sim/lib/workspace-events/emitter.test.ts b/apps/sim/lib/workspace-events/emitter.test.ts new file mode 100644 index 0000000000..9fc893ee6a --- /dev/null +++ b/apps/sim/lib/workspace-events/emitter.test.ts @@ -0,0 +1,386 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetActiveWorkflowContext, + mockFetchSubscriptions, + mockEvaluateRule, + mockReadLastFiredAt, + mockClaimCooldown, + mockProcessPolledWebhookEvent, +} = vi.hoisted(() => ({ + mockGetActiveWorkflowContext: vi.fn(), + mockFetchSubscriptions: vi.fn(), + mockEvaluateRule: vi.fn(), + mockReadLastFiredAt: vi.fn(), + mockClaimCooldown: vi.fn(), + mockProcessPolledWebhookEvent: vi.fn(), +})) + +vi.mock('@sim/workflow-authz', () => ({ + getActiveWorkflowContext: mockGetActiveWorkflowContext, +})) + +vi.mock('@/lib/workspace-events/subscriptions', () => ({ + fetchSimTriggerSubscriptions: mockFetchSubscriptions, + parseSubscriptionConfig: vi.fn((providerConfig: unknown) => providerConfig), +})) + +vi.mock('@/lib/workspace-events/rules', () => ({ + evaluateRule: mockEvaluateRule, +})) + +vi.mock('@/lib/workspace-events/state', () => ({ + readLastFiredAt: mockReadLastFiredAt, + claimCooldown: mockClaimCooldown, + isWithinCooldown: vi.fn( + (lastFiredAt: Date | null, cooldownMs: number) => + lastFiredAt !== null && Date.now() - lastFiredAt.getTime() < cooldownMs + ), +})) + +vi.mock('@/lib/webhooks/processor', () => ({ + processPolledWebhookEvent: mockProcessPolledWebhookEvent, +})) + +import type { WorkflowExecutionLog } from '@/lib/logs/types' +import { + emitExecutionCompletedEvent, + emitWorkflowDeployedEvent, +} from '@/lib/workspace-events/emitter' +import type { SimSubscriptionConfig } from '@/lib/workspace-events/types' + +function makeConfig(overrides: Partial = {}): SimSubscriptionConfig { + return { + eventType: 'execution_error', + workflowIds: [], + consecutiveFailures: 3, + failureRatePercent: 50, + windowHours: 24, + durationThresholdMs: 30000, + latencySpikePercent: 100, + costThresholdCredits: 200, + errorCountThreshold: 10, + inactivityHours: 24, + ...overrides, + } +} + +function makeSubscription( + config: SimSubscriptionConfig, + overrides: { subscriberWorkflowId?: string; blockId?: string } = {} +) { + const subscriberWorkflowId = overrides.subscriberWorkflowId ?? 'wf-subscriber' + return { + webhook: { + id: `wh-${subscriberWorkflowId}`, + workflowId: subscriberWorkflowId, + blockId: overrides.blockId ?? 'block-1', + path: 'block-1', + provider: 'sim', + providerConfig: config, + isActive: true, + }, + workflow: { + id: subscriberWorkflowId, + name: 'Subscriber Workflow', + }, + } +} + +function makeLog(overrides: Partial = {}): WorkflowExecutionLog { + return { + id: 'log-1', + workflowId: 'wf-source', + executionId: 'exec-1', + stateSnapshotId: 'snap-1', + level: 'error', + trigger: 'manual', + startedAt: '2026-06-09T00:00:00.000Z', + endedAt: '2026-06-09T00:00:01.000Z', + totalDurationMs: 1000, + executionData: { + error: 'boom', + finalOutput: { result: 42 }, + } as WorkflowExecutionLog['executionData'], + cost: { total: 0.25 } as WorkflowExecutionLog['cost'], + createdAt: '2026-06-09T00:00:01.000Z', + ...overrides, + } +} + +describe('emitExecutionCompletedEvent', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: { id: 'wf-source', name: 'Source Workflow' }, + workspaceId: 'ws-1', + }) + mockFetchSubscriptions.mockResolvedValue([]) + mockProcessPolledWebhookEvent.mockResolvedValue({ success: true, executionId: 'exec-2' }) + mockReadLastFiredAt.mockResolvedValue(null) + mockClaimCooldown.mockResolvedValue(true) + mockEvaluateRule.mockResolvedValue(true) + }) + + it('never emits for executions started by the sim trigger (loop guard)', async () => { + await emitExecutionCompletedEvent(makeLog({ trigger: 'sim' })) + + expect(mockGetActiveWorkflowContext).not.toHaveBeenCalled() + expect(mockFetchSubscriptions).not.toHaveBeenCalled() + expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled() + }) + + it('does nothing without a workflow id or workspace context', async () => { + await emitExecutionCompletedEvent(makeLog({ workflowId: null })) + expect(mockFetchSubscriptions).not.toHaveBeenCalled() + + mockGetActiveWorkflowContext.mockResolvedValueOnce(null) + await emitExecutionCompletedEvent(makeLog()) + expect(mockFetchSubscriptions).not.toHaveBeenCalled() + }) + + it('looks up subscriptions scoped to the source workspace', async () => { + await emitExecutionCompletedEvent(makeLog()) + expect(mockFetchSubscriptions).toHaveBeenCalledWith('ws-1') + }) + + it('fires execution_error subscribers for error logs but not execution_success ones', async () => { + const errorSub = makeSubscription(makeConfig({ eventType: 'execution_error' }), { + subscriberWorkflowId: 'wf-error-sub', + }) + const successSub = makeSubscription(makeConfig({ eventType: 'execution_success' }), { + subscriberWorkflowId: 'wf-success-sub', + }) + mockFetchSubscriptions.mockResolvedValueOnce([errorSub, successSub]) + + await emitExecutionCompletedEvent(makeLog({ level: 'error' })) + + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1) + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledWith( + errorSub.webhook, + errorSub.workflow, + expect.objectContaining({ + event: 'execution_error', + workflowId: 'wf-source', + workflowName: 'Source Workflow', + runId: 'exec-1', + durationMs: 1000, + // $0.25 reported as credits (1 credit = $0.005) + cost: 50, + }), + expect.any(String) + ) + }) + + it('fires execution_success subscribers for info logs', async () => { + const successSub = makeSubscription(makeConfig({ eventType: 'execution_success' })) + mockFetchSubscriptions.mockResolvedValueOnce([successSub]) + + await emitExecutionCompletedEvent(makeLog({ level: 'info' })) + + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1) + expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({ + event: 'execution_success', + runId: 'exec-1', + }) + }) + + it('respects the workflow scope filter, ignoring stale workflow ids', async () => { + const matching = makeSubscription(makeConfig({ workflowIds: ['wf-source', 'wf-deleted'] }), { + subscriberWorkflowId: 'wf-a', + }) + const nonMatching = makeSubscription(makeConfig({ workflowIds: ['wf-other', 'wf-deleted'] }), { + subscriberWorkflowId: 'wf-b', + }) + mockFetchSubscriptions.mockResolvedValueOnce([matching, nonMatching]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1) + expect(mockProcessPolledWebhookEvent.mock.calls[0][0]).toBe(matching.webhook) + }) + + it('an empty workflow selection watches every workflow', async () => { + const watchAll = makeSubscription(makeConfig({ workflowIds: [] })) + mockFetchSubscriptions.mockResolvedValueOnce([watchAll]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1) + }) + + it('never fires a subscription for its own workflow, even when watching all workflows', async () => { + const selfSub = makeSubscription(makeConfig({ workflowIds: [] }), { + subscriberWorkflowId: 'wf-source', + }) + mockFetchSubscriptions.mockResolvedValueOnce([selfSub]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled() + }) + + it('plain events bypass cooldown state entirely', async () => { + mockFetchSubscriptions.mockResolvedValueOnce([ + makeSubscription(makeConfig({ eventType: 'execution_error' })), + ]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1) + expect(mockReadLastFiredAt).not.toHaveBeenCalled() + expect(mockClaimCooldown).not.toHaveBeenCalled() + }) + + it('rule events evaluate the rule and claim the cooldown before dispatching', async () => { + const sub = makeSubscription(makeConfig({ eventType: 'cost_threshold' })) + mockFetchSubscriptions.mockResolvedValueOnce([sub]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockEvaluateRule).toHaveBeenCalledTimes(1) + expect(mockClaimCooldown).toHaveBeenCalledWith( + 'wf-subscriber', + 'block-1', + '', + expect.any(Number) + ) + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1) + expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({ + event: 'cost_threshold', + runId: null, + triggeringRun: { runId: 'exec-1' }, + }) + }) + + it('skips no_activity subscriptions before any cooldown read or rule evaluation (poller-owned)', async () => { + const sub = makeSubscription(makeConfig({ eventType: 'no_activity' })) + mockFetchSubscriptions.mockResolvedValueOnce([sub]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockReadLastFiredAt).not.toHaveBeenCalled() + expect(mockEvaluateRule).not.toHaveBeenCalled() + expect(mockClaimCooldown).not.toHaveBeenCalled() + expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled() + }) + + it('skips rule evaluation while within the cooldown window', async () => { + mockReadLastFiredAt.mockResolvedValueOnce(new Date()) + mockFetchSubscriptions.mockResolvedValueOnce([ + makeSubscription(makeConfig({ eventType: 'latency_threshold' })), + ]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockEvaluateRule).not.toHaveBeenCalled() + expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled() + }) + + it('does not dispatch when the rule does not fire', async () => { + mockEvaluateRule.mockResolvedValueOnce(false) + mockFetchSubscriptions.mockResolvedValueOnce([ + makeSubscription(makeConfig({ eventType: 'consecutive_failures' })), + ]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockClaimCooldown).not.toHaveBeenCalled() + expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled() + }) + + it('does not dispatch when a concurrent emitter wins the cooldown claim', async () => { + mockClaimCooldown.mockResolvedValueOnce(false) + mockFetchSubscriptions.mockResolvedValueOnce([ + makeSubscription(makeConfig({ eventType: 'error_count' })), + ]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled() + }) + + it('always includes the source execution finalOutput', async () => { + mockFetchSubscriptions.mockResolvedValueOnce([makeSubscription(makeConfig())]) + + await emitExecutionCompletedEvent(makeLog()) + + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1) + expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({ + finalOutput: { result: 42 }, + }) + }) + + it('never throws when emission internals fail', async () => { + mockFetchSubscriptions.mockRejectedValueOnce(new Error('db down')) + await expect(emitExecutionCompletedEvent(makeLog())).resolves.toBeUndefined() + + mockProcessPolledWebhookEvent.mockRejectedValueOnce(new Error('enqueue failed')) + mockFetchSubscriptions.mockResolvedValueOnce([makeSubscription(makeConfig())]) + await expect(emitExecutionCompletedEvent(makeLog())).resolves.toBeUndefined() + }) +}) + +describe('emitWorkflowDeployedEvent', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchSubscriptions.mockResolvedValue([]) + mockProcessPolledWebhookEvent.mockResolvedValue({ success: true, executionId: 'exec-2' }) + }) + + const deployParams = { + workflowId: 'wf-source', + workflowName: 'Source Workflow', + workspaceId: 'ws-1', + version: 4, + } + + it('fires only workflow_deployed subscribers on deploys', async () => { + const deploySub = makeSubscription(makeConfig({ eventType: 'workflow_deployed' })) + const errorSub = makeSubscription(makeConfig({ eventType: 'execution_error' }), { + subscriberWorkflowId: 'wf-other', + }) + mockFetchSubscriptions.mockResolvedValueOnce([deploySub, errorSub]) + + await emitWorkflowDeployedEvent(deployParams) + + expect(mockProcessPolledWebhookEvent).toHaveBeenCalledTimes(1) + expect(mockProcessPolledWebhookEvent.mock.calls[0][2]).toMatchObject({ + event: 'workflow_deployed', + workflowId: 'wf-source', + workflowName: 'Source Workflow', + runId: null, + version: 4, + }) + }) + + it('does not fire a subscription when its own workflow is deployed', async () => { + const selfSub = makeSubscription(makeConfig({ eventType: 'workflow_deployed' }), { + subscriberWorkflowId: 'wf-source', + }) + mockFetchSubscriptions.mockResolvedValueOnce([selfSub]) + + await emitWorkflowDeployedEvent(deployParams) + + expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled() + }) + + it('respects the workflow scope filter', async () => { + const outOfScope = makeSubscription( + makeConfig({ eventType: 'workflow_deployed', workflowIds: ['wf-x'] }) + ) + mockFetchSubscriptions.mockResolvedValueOnce([outOfScope]) + + await emitWorkflowDeployedEvent(deployParams) + + expect(mockProcessPolledWebhookEvent).not.toHaveBeenCalled() + }) + + it('never throws when emission internals fail', async () => { + mockFetchSubscriptions.mockRejectedValueOnce(new Error('db down')) + await expect(emitWorkflowDeployedEvent(deployParams)).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workspace-events/emitter.ts b/apps/sim/lib/workspace-events/emitter.ts new file mode 100644 index 0000000000..07e3ddaa2f --- /dev/null +++ b/apps/sim/lib/workspace-events/emitter.ts @@ -0,0 +1,198 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { getActiveWorkflowContext } from '@sim/workflow-authz' +import type { WorkflowExecutionLog } from '@/lib/logs/types' +import { + isSimRuleEventType, + SIM_RULE_COOLDOWN_HOURS, + SIM_TRIGGER_PROVIDER, +} from '@/lib/workspace-events/constants' +import { buildDeployEventPayload, buildExecutionEventPayload } from '@/lib/workspace-events/payload' +import { evaluateRule } from '@/lib/workspace-events/rules' +import { claimCooldown, isWithinCooldown, readLastFiredAt } from '@/lib/workspace-events/state' +import { + fetchSimTriggerSubscriptions, + parseSubscriptionConfig, +} from '@/lib/workspace-events/subscriptions' +import type { + ExecutionEventContext, + SimEventPayload, + SimSubscription, + SimSubscriptionConfig, +} from '@/lib/workspace-events/types' + +const logger = createLogger('WorkspaceEventEmitter') + +const SIM_RULE_COOLDOWN_MS = SIM_RULE_COOLDOWN_HOURS * 60 * 60 * 1000 + +/** Stable cooldown identity for a subscriber block, surviving redeploys. */ +function subscriptionBlockKey(subscription: SimSubscription): string { + return subscription.webhook.blockId ?? subscription.webhook.path +} + +/** + * Enqueues one side-effect workflow execution for a matched subscription. + * + * Routes through the shared polled-webhook pipeline, which provides admission + * control, billing attribution, deployment checks, and queue-vs-inline + * routing. The processor stack (executor, blocks) is imported lazily so this + * module stays cheap for the execution logger to import. + */ +export async function dispatchSimEvent( + subscription: SimSubscription, + payload: SimEventPayload +): Promise { + const requestId = generateShortId() + try { + const { processPolledWebhookEvent } = await import('@/lib/webhooks/processor') + const result = await processPolledWebhookEvent( + subscription.webhook, + subscription.workflow, + payload, + requestId + ) + + if (!result.success) { + logger.error( + `[${requestId}] Failed to fire sim trigger for workflow ${subscription.workflow.id}:`, + result.statusCode, + result.error + ) + } + } catch (error) { + logger.error( + `[${requestId}] Error firing sim trigger for workflow ${subscription.workflow.id}:`, + error + ) + } +} + +/** Workflow-scope filter shared by all event kinds. Empty selection watches every workflow. */ +function matchesWorkflowScope(config: SimSubscriptionConfig, sourceWorkflowId: string): boolean { + if (config.workflowIds.length === 0) return true + return config.workflowIds.includes(sourceWorkflowId) +} + +/** + * Emits workspace events for a completed workflow execution. + * + * Fire-and-forget: errors are logged and never thrown, so event emission can + * never break the source execution. Executions started by the Sim trigger + * itself never emit (loop prevention). + */ +export async function emitExecutionCompletedEvent(log: WorkflowExecutionLog): Promise { + try { + if (!log.workflowId) return + if (log.trigger === SIM_TRIGGER_PROVIDER) return + + const workflowContext = await getActiveWorkflowContext(log.workflowId) + if (!workflowContext?.workspaceId) return + + const subscriptions = await fetchSimTriggerSubscriptions(workflowContext.workspaceId) + if (subscriptions.length === 0) return + + const executionData = (log.executionData ?? {}) as Record + const context: ExecutionEventContext = { + workflowId: log.workflowId, + executionId: log.executionId, + status: log.level === 'error' ? 'error' : 'success', + durationMs: log.totalDurationMs || 0, + cost: (log.cost as { total?: number } | undefined)?.total || 0, + finalOutput: executionData.finalOutput, + } + + for (const subscription of subscriptions) { + const config = parseSubscriptionConfig(subscription.webhook.providerConfig) + if (!config) continue + if (config.eventType === 'workflow_deployed') continue + // no_activity is owned by the inactivity poller and can never fire from + // a completed execution; skip before the rule branch costs a cooldown + // read on this hot path. + if (config.eventType === 'no_activity') continue + + if (subscription.webhook.workflowId === log.workflowId) continue + if (!matchesWorkflowScope(config, log.workflowId)) continue + + if (config.eventType === 'execution_success' && context.status !== 'success') continue + if (config.eventType === 'execution_error' && context.status !== 'error') continue + + if (isSimRuleEventType(config.eventType)) { + const blockKey = subscriptionBlockKey(subscription) + + const lastFiredAt = await readLastFiredAt(subscription.webhook.workflowId, blockKey, '') + if (isWithinCooldown(lastFiredAt, SIM_RULE_COOLDOWN_MS)) continue + + const ruleFired = await evaluateRule(config.eventType, config, context) + if (!ruleFired) continue + + const claimed = await claimCooldown( + subscription.webhook.workflowId, + blockKey, + '', + SIM_RULE_COOLDOWN_MS + ) + if (!claimed) continue + + logger.info(`Sim trigger rule ${config.eventType} fired`, { + subscriberWorkflowId: subscription.webhook.workflowId, + sourceWorkflowId: log.workflowId, + executionId: log.executionId, + }) + } + + const payload = buildExecutionEventPayload({ + event: config.eventType as Parameters[0]['event'], + workflowName: workflowContext.workflow.name, + context, + }) + + await dispatchSimEvent(subscription, payload) + } + } catch (error) { + logger.error('Failed to emit workspace execution event', { + error, + workflowId: log.workflowId, + executionId: log.executionId, + }) + } +} + +/** + * Emits a workflow_deployed event to subscribed side-effect workflows. + * + * Fired on any deployment activation (fresh deploy, redeploy, version + * rollback/activation). Fire-and-forget: failures never affect the deploy. + */ +export async function emitWorkflowDeployedEvent(params: { + workflowId: string + workflowName: string + workspaceId: string + version: number | null +}): Promise { + try { + const subscriptions = await fetchSimTriggerSubscriptions(params.workspaceId) + if (subscriptions.length === 0) return + + for (const subscription of subscriptions) { + const config = parseSubscriptionConfig(subscription.webhook.providerConfig) + if (!config) continue + if (config.eventType !== 'workflow_deployed') continue + + if (subscription.webhook.workflowId === params.workflowId) continue + if (!matchesWorkflowScope(config, params.workflowId)) continue + + const payload = buildDeployEventPayload({ + workflowId: params.workflowId, + workflowName: params.workflowName, + version: params.version, + }) + + await dispatchSimEvent(subscription, payload) + } + } catch (error) { + logger.error('Failed to emit workflow deployed event', { + error, + workflowId: params.workflowId, + }) + } +} diff --git a/apps/sim/lib/workspace-events/no-activity.test.ts b/apps/sim/lib/workspace-events/no-activity.test.ts new file mode 100644 index 0000000000..bee1fec1bf --- /dev/null +++ b/apps/sim/lib/workspace-events/no-activity.test.ts @@ -0,0 +1,286 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDispatchSimEvent, mockReadLastFiredAt, mockClaimCooldown } = vi.hoisted(() => ({ + mockDispatchSimEvent: vi.fn(), + mockReadLastFiredAt: vi.fn(), + mockClaimCooldown: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/workspace-events/emitter', () => ({ + dispatchSimEvent: mockDispatchSimEvent, +})) + +vi.mock('@/lib/workspace-events/state', () => ({ + readLastFiredAt: mockReadLastFiredAt, + claimCooldown: mockClaimCooldown, + isWithinCooldown: vi.fn( + (lastFiredAt: Date | null, cooldownMs: number) => + lastFiredAt !== null && Date.now() - lastFiredAt.getTime() < cooldownMs + ), +})) + +vi.mock('@/lib/workspace-events/subscriptions', () => ({ + parseSubscriptionConfig: vi.fn((providerConfig: unknown) => providerConfig), +})) + +vi.mock('@/lib/workspace-events/rules', () => ({ + excludeSimExecutionsCondition: vi.fn(() => ({ type: 'ne', right: 'sim' })), +})) + +import { + NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE, + NO_ACTIVITY_WORKFLOW_PAGE_SIZE, + pollNoActivityEvents, +} from '@/lib/workspace-events/no-activity' +import type { SimSubscriptionConfig } from '@/lib/workspace-events/types' + +function makeConfig(overrides: Partial = {}): SimSubscriptionConfig { + return { + eventType: 'no_activity', + workflowIds: [], + consecutiveFailures: 3, + failureRatePercent: 50, + windowHours: 24, + durationThresholdMs: 30000, + latencySpikePercent: 100, + costThresholdCredits: 200, + errorCountThreshold: 10, + inactivityHours: 24, + ...overrides, + } +} + +/** Flattens nested and/or condition trees from the drizzle operator mocks. */ +function flattenCondition(condition: unknown): unknown[] { + if (!condition || typeof condition !== 'object') return [] + const node = condition as { type?: string; conditions?: unknown[] } + if (node.type === 'and' || node.type === 'or') { + return [node, ...(node.conditions ?? []).flatMap(flattenCondition)] + } + return [node] +} + +function allWhereConditions(): unknown[] { + return dbChainMockFns.where.mock.calls.flatMap(([condition]) => flattenCondition(condition)) +} + +function makeSubscriptionRow(config: SimSubscriptionConfig, webhookId = 'wh-1') { + return { + webhook: { + id: webhookId, + workflowId: 'wf-subscriber', + blockId: 'block-1', + path: 'block-1', + provider: 'sim', + providerConfig: config, + isActive: true, + }, + workflow: { + id: 'wf-subscriber', + name: 'Subscriber', + workspaceId: 'ws-1', + }, + } +} + +describe('pollNoActivityEvents', () => { + beforeEach(() => { + vi.clearAllMocks() + mockReadLastFiredAt.mockResolvedValue(null) + mockClaimCooldown.mockResolvedValue(true) + mockDispatchSimEvent.mockResolvedValue(undefined) + }) + + it('does nothing when there are no no_activity subscriptions', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const result = await pollNoActivityEvents() + + expect(result).toEqual({ subscriptions: 0, checked: 0, fired: 0, skipped: 0 }) + expect(mockDispatchSimEvent).not.toHaveBeenCalled() + }) + + it('fires for a watched workflow with no executions in the window', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig())]) + .mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }]) + .mockResolvedValueOnce([]) + + const result = await pollNoActivityEvents() + + expect(result.fired).toBe(1) + expect(mockClaimCooldown).toHaveBeenCalledWith( + 'wf-subscriber', + 'block-1', + 'wf-quiet', + expect.any(Number) + ) + expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1) + expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ + event: 'no_activity', + workflowId: 'wf-quiet', + workflowName: 'Quiet Workflow', + runId: null, + }) + }) + + it('does not fire for a workflow with recent activity', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig())]) + .mockResolvedValueOnce([{ id: 'wf-busy', name: 'Busy Workflow' }]) + .mockResolvedValueOnce([{ id: 'log-1' }]) + + const result = await pollNoActivityEvents() + + expect(result.fired).toBe(0) + expect(result.skipped).toBe(1) + expect(mockDispatchSimEvent).not.toHaveBeenCalled() + }) + + it('only fires for the inactive workflow when watching several', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig())]) + .mockResolvedValueOnce([ + { id: 'wf-busy', name: 'Busy Workflow' }, + { id: 'wf-quiet', name: 'Quiet Workflow' }, + ]) + .mockResolvedValueOnce([{ id: 'log-1' }]) + .mockResolvedValueOnce([]) + + const result = await pollNoActivityEvents() + + expect(result.fired).toBe(1) + expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1) + expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' }) + }) + + it('cooldown is scoped per watched workflow: a cooled-down workflow does not suppress others', async () => { + mockReadLastFiredAt.mockImplementation((_wf: string, _block: string, scopeKey: string) => + Promise.resolve(scopeKey === 'wf-cooled' ? new Date() : null) + ) + dbChainMockFns.limit + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig())]) + .mockResolvedValueOnce([ + { id: 'wf-cooled', name: 'Cooled Workflow' }, + { id: 'wf-quiet', name: 'Quiet Workflow' }, + ]) + .mockResolvedValueOnce([]) + + const result = await pollNoActivityEvents() + + expect(result.fired).toBe(1) + expect(result.skipped).toBe(1) + expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' }) + }) + + it('a never-executed workflow fires once, then the lost claim suppresses repeats', async () => { + mockClaimCooldown.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + for (let poll = 0; poll < 2; poll++) { + dbChainMockFns.limit + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig())]) + .mockResolvedValueOnce([{ id: 'wf-never-ran', name: 'Never Ran' }]) + .mockResolvedValueOnce([]) + } + + const first = await pollNoActivityEvents() + const second = await pollNoActivityEvents() + + expect(first.fired).toBe(1) + expect(second.fired).toBe(0) + expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1) + }) + + it('scopes the watched-workflow query to the explicit selection in SQL (before the LIMIT)', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig({ workflowIds: ['wf-watched'] }))]) + .mockResolvedValueOnce([{ id: 'wf-watched', name: 'Watched' }]) + .mockResolvedValueOnce([]) + + const result = await pollNoActivityEvents() + + expect(result.checked).toBe(1) + expect(mockDispatchSimEvent).toHaveBeenCalledTimes(1) + expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-watched' }) + expect(allWhereConditions()).toContainEqual( + expect.objectContaining({ type: 'inArray', values: ['wf-watched'] }) + ) + }) + + it('pages through subscriptions past the page size with a keyset cursor (no starvation)', async () => { + // Full first page of non-matching subscriptions (skipped without further + // queries), then a second page holding the one real no_activity + // subscription that must still be reached. + const firstPage = Array.from({ length: NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE }, (_, i) => + makeSubscriptionRow(makeConfig({ eventType: 'execution_error' }), `wh-page1-${i}`) + ) + dbChainMockFns.limit + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig(), 'wh-page2-0')]) + .mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }]) + .mockResolvedValueOnce([]) + + const result = await pollNoActivityEvents() + + expect(result.subscriptions).toBe(NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE + 1) + expect(result.fired).toBe(1) + expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' }) + expect(allWhereConditions()).toContainEqual( + expect.objectContaining({ + type: 'gt', + right: `wh-page1-${NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE - 1}`, + }) + ) + }) + + it('pages through watched workflows past the page size with a keyset cursor (no lost coverage)', async () => { + // Full first page of watched workflows all inside their cooldown (skipped + // without activity queries), then a second page holding the quiet + // workflow that must still be reached. + mockReadLastFiredAt.mockImplementation((_wf: string, _block: string, scopeKey: string) => + Promise.resolve(scopeKey.startsWith('wf-p1-') ? new Date() : null) + ) + const firstPage = Array.from({ length: NO_ACTIVITY_WORKFLOW_PAGE_SIZE }, (_, i) => ({ + id: `wf-p1-${i}`, + name: `Workflow ${i}`, + })) + dbChainMockFns.limit + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig())]) + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce([{ id: 'wf-quiet', name: 'Quiet Workflow' }]) + .mockResolvedValueOnce([]) + + const result = await pollNoActivityEvents() + + expect(result.checked).toBe(NO_ACTIVITY_WORKFLOW_PAGE_SIZE + 1) + expect(result.skipped).toBe(NO_ACTIVITY_WORKFLOW_PAGE_SIZE) + expect(result.fired).toBe(1) + expect(mockDispatchSimEvent.mock.calls[0][1]).toMatchObject({ workflowId: 'wf-quiet' }) + expect(allWhereConditions()).toContainEqual( + expect.objectContaining({ + type: 'gt', + right: `wf-p1-${NO_ACTIVITY_WORKFLOW_PAGE_SIZE - 1}`, + }) + ) + }) + + it('excludes the subscriber workflow in SQL (before the LIMIT)', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([makeSubscriptionRow(makeConfig())]) + .mockResolvedValueOnce([]) + + const result = await pollNoActivityEvents() + + expect(result.checked).toBe(0) + expect(mockDispatchSimEvent).not.toHaveBeenCalled() + expect(allWhereConditions()).toContainEqual( + expect.objectContaining({ type: 'ne', right: 'wf-subscriber' }) + ) + }) +}) diff --git a/apps/sim/lib/workspace-events/no-activity.ts b/apps/sim/lib/workspace-events/no-activity.ts new file mode 100644 index 0000000000..de69868997 --- /dev/null +++ b/apps/sim/lib/workspace-events/no-activity.ts @@ -0,0 +1,274 @@ +import { db } from '@sim/db' +import { webhook, workflow, workflowDeploymentVersion, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, asc, eq, gt, gte, inArray, isNull, ne, or, sql } from 'drizzle-orm' +import { SIM_RULE_COOLDOWN_HOURS, SIM_TRIGGER_PROVIDER } from '@/lib/workspace-events/constants' +import { dispatchSimEvent } from '@/lib/workspace-events/emitter' +import { buildNoActivityEventPayload } from '@/lib/workspace-events/payload' +import { excludeSimExecutionsCondition } from '@/lib/workspace-events/rules' +import { claimCooldown, isWithinCooldown, readLastFiredAt } from '@/lib/workspace-events/state' +import { parseSubscriptionConfig } from '@/lib/workspace-events/subscriptions' +import type { SimSubscription, SimSubscriptionConfig } from '@/lib/workspace-events/types' + +const logger = createLogger('WorkspaceEventNoActivity') + +/** + * Page size for the keyset-paginated subscription scan. Every subscription is + * visited each poll — pagination bounds memory, not total work. + */ +export const NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE = 500 + +/** + * Page size for the keyset-paginated watched-workflow scan. Every watched + * workflow is visited each poll — pagination bounds memory, not total work. + */ +export const NO_ACTIVITY_WORKFLOW_PAGE_SIZE = 500 + +export interface NoActivityPollResult { + subscriptions: number + checked: number + fired: number + skipped: number +} + +/** + * Fetches one page of deployed Sim-trigger subscriptions configured for + * no_activity, across all workspaces, keyset-paginated by webhook id. A + * fixed cap would silently starve subscriptions beyond it; paging visits + * every subscription while keeping memory bounded. This runs from a + * low-frequency cron, so a global paged scan is acceptable — unlike the hot + * execution-completion path. + */ +async function fetchNoActivitySubscriptionPage( + afterWebhookId: string | null +): Promise { + const rows = await db + .select({ webhook, workflow }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.provider, SIM_TRIGGER_PROVIDER), + eq(webhook.isActive, true), + isNull(webhook.archivedAt), + eq(workflow.isDeployed, true), + isNull(workflow.archivedAt), + sql`${webhook.providerConfig}->>'eventType' = 'no_activity'`, + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ), + afterWebhookId === null ? undefined : gt(webhook.id, afterWebhookId) + ) + ) + .orderBy(asc(webhook.id)) + .limit(NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE) + + return rows +} + +/** + * Fetches one page of the workflows a no_activity subscription watches: + * deployed, active workflows in the subscriber's workspace, minus the + * subscriber itself, narrowed to the explicit selection when one is set + * (empty selection watches everything). Deployed-only keeps never-runnable + * draft workflows from alerting forever. Keyset-paginated by workflow id so + * watch-everything subscriptions in large workspaces never silently lose + * coverage past a cap. + */ +async function fetchWatchedWorkflowPage( + workspaceId: string, + subscriberWorkflowId: string, + config: SimSubscriptionConfig, + afterWorkflowId: string | null +): Promise> { + // Subscriber exclusion and the explicit selection must be SQL conditions: + // filtering in memory after the LIMIT could drop an explicitly watched + // workflow. The ORDER BY drives the keyset cursor. + const conditions = [ + eq(workflow.workspaceId, workspaceId), + eq(workflow.isDeployed, true), + isNull(workflow.archivedAt), + ne(workflow.id, subscriberWorkflowId), + ] + if (config.workflowIds.length > 0) { + conditions.push(inArray(workflow.id, config.workflowIds)) + } + if (afterWorkflowId !== null) { + conditions.push(gt(workflow.id, afterWorkflowId)) + } + + return db + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where(and(...conditions)) + .orderBy(asc(workflow.id)) + .limit(NO_ACTIVITY_WORKFLOW_PAGE_SIZE) +} + +/** True when the workflow had at least one qualifying execution inside the window. */ +async function hasRecentActivity( + workflowId: string, + config: SimSubscriptionConfig +): Promise { + const windowStart = new Date(Date.now() - config.inactivityHours * 60 * 60 * 1000) + + const recentLogs = await db + .select({ id: workflowExecutionLogs.id }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + gte(workflowExecutionLogs.startedAt, windowStart), + excludeSimExecutionsCondition() + ) + ) + .limit(1) + + return recentLogs.length > 0 +} + +/** + * Cooldown for no_activity firings. At least the inactivity window itself — + * an hour-long cooldown with a multi-hour window would re-alert every hour + * for the same ongoing inactivity. + */ +function noActivityCooldownMs(config: SimSubscriptionConfig): number { + return Math.max(SIM_RULE_COOLDOWN_HOURS, config.inactivityHours) * 60 * 60 * 1000 +} + +/** + * Checks one watched workflow and fires when it has gone quiet, accumulating + * counts into `result`. + */ +async function checkWatchedWorkflow( + subscription: SimSubscription, + config: SimSubscriptionConfig, + sourceWorkflow: { id: string; name: string }, + result: NoActivityPollResult +): Promise { + result.checked++ + + const blockKey = subscription.webhook.blockId ?? subscription.webhook.path + const cooldownMs = noActivityCooldownMs(config) + + const lastFiredAt = await readLastFiredAt( + subscription.webhook.workflowId, + blockKey, + sourceWorkflow.id + ) + if (isWithinCooldown(lastFiredAt, cooldownMs)) { + result.skipped++ + return + } + + if (await hasRecentActivity(sourceWorkflow.id, config)) { + result.skipped++ + return + } + + const claimed = await claimCooldown( + subscription.webhook.workflowId, + blockKey, + sourceWorkflow.id, + cooldownMs + ) + if (!claimed) { + result.skipped++ + return + } + + const payload = buildNoActivityEventPayload({ + workflowId: sourceWorkflow.id, + workflowName: sourceWorkflow.name, + }) + + await dispatchSimEvent(subscription, payload) + result.fired++ + + logger.info(`no_activity event fired for workflow ${sourceWorkflow.id}`, { + subscriberWorkflowId: subscription.webhook.workflowId, + inactivityHours: config.inactivityHours, + }) +} + +/** + * Checks a single no_activity subscription's watched workflows and fires + * events for the inactive ones, accumulating counts into `result`. The + * watched-workflow scan is keyset-paginated, so coverage is complete even in + * workspaces with more workflows than one page. + */ +async function checkSubscription( + subscription: SimSubscription, + result: NoActivityPollResult +): Promise { + const config = parseSubscriptionConfig(subscription.webhook.providerConfig) + if (!config || config.eventType !== 'no_activity') return + + const workspaceId = subscription.workflow.workspaceId + if (!workspaceId) return + + let cursor: string | null = null + while (true) { + const page = await fetchWatchedWorkflowPage( + workspaceId, + subscription.webhook.workflowId, + config, + cursor + ) + if (page.length === 0) break + + cursor = page[page.length - 1].id + + for (const sourceWorkflow of page) { + await checkWatchedWorkflow(subscription, config, sourceWorkflow, result) + } + + if (page.length < NO_ACTIVITY_WORKFLOW_PAGE_SIZE) break + } +} + +/** + * Checks every no_activity subscription and fires side-effect workflows for + * watched workflows with no qualifying executions inside the window. The + * subscription scan is keyset-paginated, so every subscription is visited + * each poll regardless of how many exist. + * + * Cooldown state is keyed per (subscriber block × watched workflow), so one + * inactive workflow never suppresses alerts for others — a deliberate fix + * over the legacy per-subscription cooldown. A deployed workflow that has + * never executed fires once, then respects the cooldown. + */ +export async function pollNoActivityEvents(): Promise { + const result: NoActivityPollResult = { subscriptions: 0, checked: 0, fired: 0, skipped: 0 } + + let cursor: string | null = null + while (true) { + const page = await fetchNoActivitySubscriptionPage(cursor) + if (page.length === 0) break + + result.subscriptions += page.length + cursor = page[page.length - 1].webhook.id + + for (const subscription of page) { + await checkSubscription(subscription, result) + } + + if (page.length < NO_ACTIVITY_SUBSCRIPTION_PAGE_SIZE) break + } + + if (result.subscriptions === 0) return result + + logger.info( + `no_activity poll completed: ${result.fired} fired, ${result.skipped} skipped of ${result.checked} checked` + ) + + return result +} diff --git a/apps/sim/lib/workspace-events/payload.test.ts b/apps/sim/lib/workspace-events/payload.test.ts new file mode 100644 index 0000000000..f74b08a3b9 --- /dev/null +++ b/apps/sim/lib/workspace-events/payload.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + SIM_EVENT_PAYLOAD_FIELDS, + SIM_FINAL_OUTPUT_MAX_BYTES, +} from '@/lib/workspace-events/constants' +import { + buildDeployEventPayload, + buildExecutionEventPayload, + buildNoActivityEventPayload, +} from '@/lib/workspace-events/payload' +import type { ExecutionEventContext } from '@/lib/workspace-events/types' + +const payloadKeys = Object.keys(SIM_EVENT_PAYLOAD_FIELDS).sort() + +function makeContext(overrides: Partial = {}): ExecutionEventContext { + return { + workflowId: 'wf-source', + executionId: 'exec-1', + status: 'error', + durationMs: 1000, + cost: 0.25, + finalOutput: { result: 42 }, + ...overrides, + } +} + +describe('payload builders align with the shared field constants', () => { + it('run-backed event payload has exactly the declared keys', () => { + const payload = buildExecutionEventPayload({ + event: 'execution_error', + workflowName: 'Source', + context: makeContext(), + }) + expect(Object.keys(payload).sort()).toEqual(payloadKeys) + expect(payload).toMatchObject({ + event: 'execution_error', + workflowId: 'wf-source', + workflowName: 'Source', + runId: 'exec-1', + durationMs: 1000, + // $0.25 reported as credits (1 credit = $0.005) + cost: 50, + }) + }) + + it('deploy event payload has exactly the declared keys with run fields null', () => { + const payload = buildDeployEventPayload({ + workflowId: 'wf-source', + workflowName: 'Source', + version: 3, + }) + expect(Object.keys(payload).sort()).toEqual(payloadKeys) + expect(payload).toMatchObject({ + event: 'workflow_deployed', + workflowId: 'wf-source', + workflowName: 'Source', + runId: null, + durationMs: null, + cost: null, + finalOutput: null, + version: 3, + }) + }) + + it('rule event payload nests the triggering run instead of top-level run fields', () => { + const payload = buildExecutionEventPayload({ + event: 'cost_threshold', + workflowName: 'Source', + context: makeContext(), + }) + expect(Object.keys(payload).sort()).toEqual(payloadKeys) + expect(payload).toMatchObject({ + event: 'cost_threshold', + runId: null, + durationMs: null, + cost: null, + finalOutput: null, + triggeringRun: { + runId: 'exec-1', + durationMs: 1000, + // $0.25 reported as credits (1 credit = $0.005) + cost: 50, + finalOutput: { result: 42 }, + }, + }) + }) + + it('no-activity payload has exactly the declared keys with run fields null', () => { + const payload = buildNoActivityEventPayload({ + workflowId: 'wf-source', + workflowName: 'Source', + }) + expect(Object.keys(payload).sort()).toEqual(payloadKeys) + expect(payload).toMatchObject({ + event: 'no_activity', + runId: null, + finalOutput: null, + }) + }) +}) + +describe('finalOutput handling', () => { + it('passes small outputs through untouched', () => { + const payload = buildExecutionEventPayload({ + event: 'execution_error', + workflowName: 'Source', + context: makeContext(), + }) + expect(payload.finalOutput).toEqual({ result: 42 }) + }) + + it('serializes and truncates oversized outputs', () => { + const huge = { blob: 'x'.repeat(SIM_FINAL_OUTPUT_MAX_BYTES + 1024) } + const payload = buildExecutionEventPayload({ + event: 'execution_error', + workflowName: 'Source', + context: makeContext({ finalOutput: huge }), + }) + expect(typeof payload.finalOutput).toBe('string') + expect((payload.finalOutput as string).length).toBeLessThanOrEqual(SIM_FINAL_OUTPUT_MAX_BYTES) + }) + + it('is nested under triggeringRun for rule events', () => { + const payload = buildExecutionEventPayload({ + event: 'cost_threshold', + workflowName: 'Source', + context: makeContext(), + }) + expect(payload.finalOutput).toBeNull() + expect(payload.triggeringRun?.finalOutput).toEqual({ result: 42 }) + }) + + it('is null when the source run produced no output', () => { + const payload = buildExecutionEventPayload({ + event: 'execution_success', + workflowName: 'Source', + context: makeContext({ status: 'success', finalOutput: undefined }), + }) + expect(payload.finalOutput).toBeNull() + }) +}) diff --git a/apps/sim/lib/workspace-events/payload.ts b/apps/sim/lib/workspace-events/payload.ts new file mode 100644 index 0000000000..8d1082ec13 --- /dev/null +++ b/apps/sim/lib/workspace-events/payload.ts @@ -0,0 +1,112 @@ +import { truncate } from '@sim/utils/string' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { + SIM_FINAL_OUTPUT_MAX_BYTES, + type SimEventType, + type SimPlainEventType, + type SimRuleEventType, +} from '@/lib/workspace-events/constants' +import type { + ExecutionEventContext, + SimEventPayload, + SimRunSummary, +} from '@/lib/workspace-events/types' + +/** + * Bounds the finalOutput field. Trace spans are never included; the payload + * travels through the job queue, so large outputs are serialized and + * truncated instead of being passed through whole. + */ +function boundFinalOutput(finalOutput: unknown): unknown { + if (finalOutput === undefined || finalOutput === null) return null + + try { + const serialized = JSON.stringify(finalOutput) + if (serialized === undefined) return null + if (serialized.length <= SIM_FINAL_OUTPUT_MAX_BYTES) return finalOutput + const suffix = '... [truncated]' + return truncate(serialized, SIM_FINAL_OUTPUT_MAX_BYTES - suffix.length, suffix) + } catch { + return null + } +} + +function basePayload(params: { + event: SimEventType + workflowId: string + workflowName: string +}): SimEventPayload { + return { + event: params.event, + timestamp: new Date().toISOString(), + workflowId: params.workflowId, + workflowName: params.workflowName, + runId: null, + durationMs: null, + cost: null, + finalOutput: null, + triggeringRun: null, + version: null, + } +} + +/** Run summary in user-facing units: cost in credits, finalOutput bounded. */ +function summarizeRun(context: ExecutionEventContext): SimRunSummary { + return { + runId: context.executionId, + durationMs: context.durationMs, + // Costs are stored in dollars; credits are the user-facing unit. + cost: dollarsToCredits(context.cost), + finalOutput: boundFinalOutput(context.finalOutput), + } +} + +/** + * Payload for run-backed events. Plain success/error events ARE the run, so + * its summary sits at the top level; for rule events the run is evidence for + * the condition that fired, so it nests under `triggeringRun`. + */ +export function buildExecutionEventPayload(params: { + event: Exclude | SimRuleEventType + workflowName: string + context: ExecutionEventContext +}): SimEventPayload { + const { event, workflowName, context } = params + + const base = basePayload({ event, workflowId: context.workflowId, workflowName }) + const run = summarizeRun(context) + + if (event === 'execution_success' || event === 'execution_error') { + return { ...base, ...run } + } + + return { ...base, triggeringRun: run } +} + +/** Payload for workflow_deployed events. */ +export function buildDeployEventPayload(params: { + workflowId: string + workflowName: string + version: number | null +}): SimEventPayload { + return { + ...basePayload({ + event: 'workflow_deployed', + workflowId: params.workflowId, + workflowName: params.workflowName, + }), + version: params.version, + } +} + +/** Payload for no_activity events (no source run exists). */ +export function buildNoActivityEventPayload(params: { + workflowId: string + workflowName: string +}): SimEventPayload { + return basePayload({ + event: 'no_activity', + workflowId: params.workflowId, + workflowName: params.workflowName, + }) +} diff --git a/apps/sim/lib/workspace-events/rules.test.ts b/apps/sim/lib/workspace-events/rules.test.ts new file mode 100644 index 0000000000..5350c25c07 --- /dev/null +++ b/apps/sim/lib/workspace-events/rules.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => dbChainMock) + +import { evaluateRule, excludeSimExecutionsCondition } from '@/lib/workspace-events/rules' +import type { ExecutionEventContext, SimSubscriptionConfig } from '@/lib/workspace-events/types' + +function makeConfig(overrides: Partial = {}): SimSubscriptionConfig { + return { + eventType: 'execution_error', + workflowIds: [], + consecutiveFailures: 3, + failureRatePercent: 50, + windowHours: 24, + durationThresholdMs: 30000, + latencySpikePercent: 100, + costThresholdCredits: 200, + errorCountThreshold: 10, + inactivityHours: 24, + ...overrides, + } +} + +function makeContext(overrides: Partial = {}): ExecutionEventContext { + return { + workflowId: 'wf-source', + executionId: 'exec-1', + status: 'error', + trigger: 'manual', + durationMs: 1000, + cost: 0.25, + errorMessage: 'boom', + finalOutput: null, + ...overrides, + } +} + +describe('excludeSimExecutionsCondition', () => { + it('excludes sim-triggered executions from rule statistics', () => { + const condition = excludeSimExecutionsCondition() as unknown as { + type: string + right?: unknown + } + expect(condition).toMatchObject({ type: 'ne', right: 'sim' }) + }) +}) + +describe('evaluateRule', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('consecutive_failures', () => { + it('fires when the last N executions all failed', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { level: 'error' }, + { level: 'error' }, + { level: 'error' }, + ]) + await expect(evaluateRule('consecutive_failures', makeConfig(), makeContext())).resolves.toBe( + true + ) + }) + + it('does not fire when any recent execution succeeded', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { level: 'error' }, + { level: 'info' }, + { level: 'error' }, + ]) + await expect(evaluateRule('consecutive_failures', makeConfig(), makeContext())).resolves.toBe( + false + ) + }) + + it('does not fire with fewer executions than the threshold', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ level: 'error' }, { level: 'error' }]) + await expect(evaluateRule('consecutive_failures', makeConfig(), makeContext())).resolves.toBe( + false + ) + }) + + it('only runs on failed executions', async () => { + await expect( + evaluateRule('consecutive_failures', makeConfig(), makeContext({ status: 'success' })) + ).resolves.toBe(false) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + }) + + describe('failure_rate', () => { + it('fires when the in-window failure rate meets the threshold (fixed legacy dead code)', async () => { + dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ total: 6, errors: 4 }])) + await expect(evaluateRule('failure_rate', makeConfig(), makeContext())).resolves.toBe(true) + }) + + it('does not fire below the minimum execution count', async () => { + dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ total: 4, errors: 4 }])) + await expect(evaluateRule('failure_rate', makeConfig(), makeContext())).resolves.toBe(false) + }) + + it('does not fire when the rate is below the threshold', async () => { + dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ total: 5, errors: 1 }])) + await expect(evaluateRule('failure_rate', makeConfig(), makeContext())).resolves.toBe(false) + }) + }) + + describe('latency_threshold', () => { + it('fires when duration exceeds the threshold', async () => { + await expect( + evaluateRule( + 'latency_threshold', + makeConfig({ durationThresholdMs: 1000 }), + makeContext({ durationMs: 1001 }) + ) + ).resolves.toBe(true) + }) + + it('does not fire at exactly the threshold', async () => { + await expect( + evaluateRule( + 'latency_threshold', + makeConfig({ durationThresholdMs: 1000 }), + makeContext({ durationMs: 1000 }) + ) + ).resolves.toBe(false) + }) + }) + + describe('latency_spike', () => { + it('fires when the execution is slower than the spike threshold over the average', async () => { + dbChainMockFns.where.mockImplementationOnce(() => + Promise.resolve([{ avgDuration: '1000', count: 5 }]) + ) + await expect( + evaluateRule('latency_spike', makeConfig(), makeContext({ durationMs: 2001 })) + ).resolves.toBe(true) + }) + + it('does not fire at exactly the spike threshold', async () => { + dbChainMockFns.where.mockImplementationOnce(() => + Promise.resolve([{ avgDuration: '1000', count: 5 }]) + ) + await expect( + evaluateRule('latency_spike', makeConfig(), makeContext({ durationMs: 2000 })) + ).resolves.toBe(false) + }) + + it('does not fire below the minimum execution count', async () => { + dbChainMockFns.where.mockImplementationOnce(() => + Promise.resolve([{ avgDuration: '1000', count: 4 }]) + ) + await expect( + evaluateRule('latency_spike', makeConfig(), makeContext({ durationMs: 5000 })) + ).resolves.toBe(false) + }) + }) + + describe('cost_threshold', () => { + it('fires when the run cost exceeds the credit-denominated threshold', async () => { + // 200 credits = $1; a $1.50 run exceeds it. + await expect( + evaluateRule( + 'cost_threshold', + makeConfig({ costThresholdCredits: 200 }), + makeContext({ cost: 1.5 }) + ) + ).resolves.toBe(true) + }) + + it('does not fire at exactly the threshold', async () => { + await expect( + evaluateRule( + 'cost_threshold', + makeConfig({ costThresholdCredits: 200 }), + makeContext({ cost: 1 }) + ) + ).resolves.toBe(false) + }) + }) + + describe('error_count', () => { + it('fires when the in-window error count reaches the threshold', async () => { + dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ count: 10 }])) + await expect(evaluateRule('error_count', makeConfig(), makeContext())).resolves.toBe(true) + }) + + it('does not fire below the threshold', async () => { + dbChainMockFns.where.mockImplementationOnce(() => Promise.resolve([{ count: 9 }])) + await expect(evaluateRule('error_count', makeConfig(), makeContext())).resolves.toBe(false) + }) + + it('only runs on failed executions', async () => { + await expect( + evaluateRule('error_count', makeConfig(), makeContext({ status: 'success' })) + ).resolves.toBe(false) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + }) + + it('no_activity never fires at execution time (owned by the poller)', async () => { + await expect(evaluateRule('no_activity', makeConfig(), makeContext())).resolves.toBe(false) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-events/rules.ts b/apps/sim/lib/workspace-events/rules.ts new file mode 100644 index 0000000000..674d446076 --- /dev/null +++ b/apps/sim/lib/workspace-events/rules.ts @@ -0,0 +1,179 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, avg, count, desc, eq, gte, ne, type SQL, sql } from 'drizzle-orm' +import { creditsToDollars } from '@/lib/billing/credits/conversion' +import { + SIM_MIN_EXECUTIONS_FOR_RATE_RULES, + SIM_TRIGGER_PROVIDER, + type SimRuleEventType, +} from '@/lib/workspace-events/constants' +import type { ExecutionEventContext, SimSubscriptionConfig } from '@/lib/workspace-events/types' + +const logger = createLogger('WorkspaceEventRules') + +/** + * Excludes executions started by the Sim trigger from rule statistics, so + * side-effect runs never pollute failure/latency counts for workflows that + * are both source and subscriber. + */ +export function excludeSimExecutionsCondition(): SQL { + return ne(workflowExecutionLogs.trigger, SIM_TRIGGER_PROVIDER) +} + +async function checkConsecutiveFailures(workflowId: string, threshold: number): Promise { + const recentLogs = await db + .select({ level: workflowExecutionLogs.level }) + .from(workflowExecutionLogs) + .where(and(eq(workflowExecutionLogs.workflowId, workflowId), excludeSimExecutionsCondition())) + .orderBy(desc(workflowExecutionLogs.startedAt)) + .limit(threshold) + + if (recentLogs.length < threshold) return false + + return recentLogs.every((log) => log.level === 'error') +} + +/** + * Fires when the in-window failure rate meets the threshold with at least + * SIM_MIN_EXECUTIONS_FOR_RATE_RULES executions. + * + * Intentionally diverges from the legacy notification rule, which required + * the oldest in-window log to predate the window start — a condition that is + * false for every in-window log, making the legacy rule dead code. + */ +async function checkFailureRate( + workflowId: string, + ratePercent: number, + windowHours: number +): Promise { + const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) + + // Single DB-side aggregate: the window is user-configured and this runs on + // the execution-completion path, so never materialize the in-window rows. + const result = await db + .select({ + total: count(), + errors: count(sql`case when ${workflowExecutionLogs.level} = 'error' then 1 end`), + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + gte(workflowExecutionLogs.startedAt, windowStart), + excludeSimExecutionsCondition() + ) + ) + + const total = result[0]?.total ?? 0 + if (total < SIM_MIN_EXECUTIONS_FOR_RATE_RULES) return false + + const errorCount = result[0]?.errors ?? 0 + const failureRate = (errorCount / total) * 100 + + return failureRate >= ratePercent +} + +async function checkLatencySpike( + workflowId: string, + currentDurationMs: number, + spikePercent: number, + windowHours: number +): Promise { + const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) + + const result = await db + .select({ + avgDuration: avg(workflowExecutionLogs.totalDurationMs), + count: count(), + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + gte(workflowExecutionLogs.startedAt, windowStart), + excludeSimExecutionsCondition() + ) + ) + + const avgDuration = result[0]?.avgDuration + const execCount = result[0]?.count || 0 + + if (!avgDuration || execCount < SIM_MIN_EXECUTIONS_FOR_RATE_RULES) return false + + const avgMs = Number(avgDuration) + const threshold = avgMs * (1 + spikePercent / 100) + + return currentDurationMs > threshold +} + +async function checkErrorCount( + workflowId: string, + threshold: number, + windowHours: number +): Promise { + const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) + + const result = await db + .select({ count: count() }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + eq(workflowExecutionLogs.level, 'error'), + gte(workflowExecutionLogs.startedAt, windowStart), + excludeSimExecutionsCondition() + ) + ) + + const errorCount = result[0]?.count || 0 + return errorCount >= threshold +} + +/** + * Evaluates a rule-based event type against a completed execution. + * `no_activity` always returns false here — it has no triggering execution + * and is owned by the inactivity poller. + */ +export async function evaluateRule( + eventType: SimRuleEventType, + config: SimSubscriptionConfig, + context: ExecutionEventContext +): Promise { + switch (eventType) { + case 'consecutive_failures': + if (context.status !== 'error') return false + return checkConsecutiveFailures(context.workflowId, config.consecutiveFailures) + + case 'failure_rate': + if (context.status !== 'error') return false + return checkFailureRate(context.workflowId, config.failureRatePercent, config.windowHours) + + case 'latency_threshold': + return context.durationMs > config.durationThresholdMs + + case 'latency_spike': + return checkLatencySpike( + context.workflowId, + context.durationMs, + config.latencySpikePercent, + config.windowHours + ) + + case 'cost_threshold': + // The threshold is credit-denominated (the UI unit); run costs are + // stored in dollars, so convert the threshold for the comparison. + return context.cost > creditsToDollars(config.costThresholdCredits) + + case 'error_count': + if (context.status !== 'error') return false + return checkErrorCount(context.workflowId, config.errorCountThreshold, config.windowHours) + + case 'no_activity': + return false + + default: + logger.warn(`Unknown sim trigger rule: ${eventType}`) + return false + } +} diff --git a/apps/sim/lib/workspace-events/state.ts b/apps/sim/lib/workspace-events/state.ts new file mode 100644 index 0000000000..39c3c9ca2a --- /dev/null +++ b/apps/sim/lib/workspace-events/state.ts @@ -0,0 +1,72 @@ +import { db } from '@sim/db' +import { simTriggerState } from '@sim/db/schema' +import { and, eq, sql } from 'drizzle-orm' + +/** + * Reads the last firing time for a subscription scope. Cheap pre-check used + * to skip rule SQL while a subscription is cooling down; the atomic claim in + * {@link claimCooldown} remains the source of truth. + */ +export async function readLastFiredAt( + workflowId: string, + blockId: string, + scopeKey: string +): Promise { + const rows = await db + .select({ lastFiredAt: simTriggerState.lastFiredAt }) + .from(simTriggerState) + .where( + and( + eq(simTriggerState.workflowId, workflowId), + eq(simTriggerState.blockId, blockId), + eq(simTriggerState.scopeKey, scopeKey) + ) + ) + .limit(1) + + return rows[0]?.lastFiredAt ?? null +} + +/** + * Atomically claims a cooldown slot for a subscription scope. + * + * Uses an upsert whose update only applies when the previous firing is + * outside the cooldown window, so concurrent qualifying events can never + * double-fire: exactly one caller gets a row back. + * + * State is keyed by (workflowId, blockId, scopeKey) rather than the webhook + * row so cooldowns survive redeploys (webhook rows are recreated per + * deployment version). + */ +export async function claimCooldown( + workflowId: string, + blockId: string, + scopeKey: string, + cooldownMs: number +): Promise { + const now = new Date() + const threshold = new Date(now.getTime() - cooldownMs) + + const rows = await db + .insert(simTriggerState) + .values({ + workflowId, + blockId, + scopeKey, + lastFiredAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [simTriggerState.workflowId, simTriggerState.blockId, simTriggerState.scopeKey], + set: { lastFiredAt: now, updatedAt: now }, + setWhere: sql`${simTriggerState.lastFiredAt} IS NULL OR ${simTriggerState.lastFiredAt} < ${threshold}`, + }) + .returning({ workflowId: simTriggerState.workflowId }) + + return rows.length > 0 +} + +export function isWithinCooldown(lastFiredAt: Date | null, cooldownMs: number): boolean { + if (!lastFiredAt) return false + return Date.now() - lastFiredAt.getTime() < cooldownMs +} diff --git a/apps/sim/lib/workspace-events/subscriptions.test.ts b/apps/sim/lib/workspace-events/subscriptions.test.ts new file mode 100644 index 0000000000..08ccfbf7d8 --- /dev/null +++ b/apps/sim/lib/workspace-events/subscriptions.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { SIM_RULE_DEFAULTS } from '@/lib/workspace-events/constants' +import { parseSubscriptionConfig } from '@/lib/workspace-events/subscriptions' + +describe('parseSubscriptionConfig', () => { + it('returns null for configs without a recognizable event type', () => { + expect(parseSubscriptionConfig(null)).toBeNull() + expect(parseSubscriptionConfig({})).toBeNull() + expect(parseSubscriptionConfig({ eventType: 'bogus' })).toBeNull() + expect(parseSubscriptionConfig('not-an-object')).toBeNull() + }) + + it('parses workflow ids from arrays and comma-separated strings', () => { + expect( + parseSubscriptionConfig({ eventType: 'execution_error', workflowIds: ['a', 'b', ''] }) + ?.workflowIds + ).toEqual(['a', 'b']) + expect( + parseSubscriptionConfig({ eventType: 'execution_error', workflowIds: 'a, b,' })?.workflowIds + ).toEqual(['a', 'b']) + }) + + it('treats a missing workflow selection as watching every workflow (empty list)', () => { + expect(parseSubscriptionConfig({ eventType: 'execution_error' })?.workflowIds).toEqual([]) + }) + + it('coerces numeric rule fields and falls back to defaults for invalid values', () => { + const config = parseSubscriptionConfig({ + eventType: 'consecutive_failures', + consecutiveFailures: '5', + windowHours: 'not-a-number', + costThresholdCredits: -2, + }) + expect(config?.consecutiveFailures).toBe(5) + expect(config?.windowHours).toBe(SIM_RULE_DEFAULTS.windowHours) + expect(config?.costThresholdCredits).toBe(SIM_RULE_DEFAULTS.costThresholdCredits) + }) + + it('clamps rule fields to the legacy bounds (hot-path queries must stay bounded)', () => { + const config = parseSubscriptionConfig({ + eventType: 'failure_rate', + windowHours: 1_000_000, + consecutiveFailures: 5000, + failureRatePercent: 250, + durationThresholdMs: 5, + latencySpikePercent: 1, + costThresholdCredits: 10_000_000, + errorCountThreshold: 99999, + inactivityHours: 0.01, + }) + expect(config?.windowHours).toBe(168) + expect(config?.consecutiveFailures).toBe(100) + expect(config?.failureRatePercent).toBe(100) + expect(config?.durationThresholdMs).toBe(1000) + expect(config?.latencySpikePercent).toBe(10) + expect(config?.costThresholdCredits).toBe(200_000) + expect(config?.errorCountThreshold).toBe(1000) + expect(config?.inactivityHours).toBe(1) + }) + + it('rounds fractional integer fields (counts feed SQL LIMIT) but keeps credits fractional', () => { + const config = parseSubscriptionConfig({ + eventType: 'consecutive_failures', + consecutiveFailures: '2.5', + windowHours: 12.4, + costThresholdCredits: 250.5, + }) + expect(config?.consecutiveFailures).toBe(3) + expect(config?.windowHours).toBe(12) + expect(config?.costThresholdCredits).toBe(250.5) + }) +}) diff --git a/apps/sim/lib/workspace-events/subscriptions.ts b/apps/sim/lib/workspace-events/subscriptions.ts new file mode 100644 index 0000000000..d44ef32bc1 --- /dev/null +++ b/apps/sim/lib/workspace-events/subscriptions.ts @@ -0,0 +1,158 @@ +import { db } from '@sim/db' +import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' +import { and, eq, isNull, or } from 'drizzle-orm' +import { + SIM_EVENT_TYPES, + SIM_RULE_DEFAULTS, + SIM_TRIGGER_PROVIDER, + type SimEventType, +} from '@/lib/workspace-events/constants' +import type { SimSubscription, SimSubscriptionConfig } from '@/lib/workspace-events/types' + +/** + * Fetches active Sim-trigger subscriptions for one workspace. + * + * Workspace-scoped on purpose: execution completion is the hottest event in + * the platform, so this must never degrade into a global provider scan. The + * deployment-version join enforces that subscribers are deployed and the + * webhook row belongs to the active deployment version. + */ +export async function fetchSimTriggerSubscriptions( + workspaceId: string +): Promise { + const rows = await db + .select({ webhook, workflow }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.provider, SIM_TRIGGER_PROVIDER), + eq(webhook.isActive, true), + isNull(webhook.archivedAt), + eq(workflow.workspaceId, workspaceId), + eq(workflow.isDeployed, true), + isNull(workflow.archivedAt), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) + + return rows +} + +function parseStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) + } + if (typeof value === 'string' && value.length > 0) { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + } + return [] +} + +/** + * Per-field bounds ported from the legacy notifications contract. Rule SQL + * runs on the execution-completion hot path, so windows and counts must stay + * inside the designed envelope regardless of what the free-text subblocks + * contain. Integer fields are rounded — counts feed SQL LIMIT, which rejects + * fractional values. The credit bounds are the legacy dollar bounds + * ($0.01-$1000) at 200 credits per dollar; credits stay fractional like the + * legacy dollar threshold. + */ +const SIM_RULE_BOUNDS = { + consecutiveFailures: { min: 1, max: 100, integer: true }, + failureRatePercent: { min: 1, max: 100, integer: true }, + windowHours: { min: 1, max: 168, integer: true }, + durationThresholdMs: { min: 1000, max: 3_600_000, integer: true }, + latencySpikePercent: { min: 10, max: 1000, integer: true }, + costThresholdCredits: { min: 2, max: 200_000 }, + errorCountThreshold: { min: 1, max: 1000, integer: true }, + inactivityHours: { min: 1, max: 168, integer: true }, +} as const + +function parseBoundedNumber( + value: unknown, + fallback: number, + bounds: { min: number; max: number; integer?: boolean } +): number { + const parsed = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) return fallback + const clamped = Math.min(Math.max(parsed, bounds.min), bounds.max) + return bounds.integer ? Math.round(clamped) : clamped +} + +/** + * Parses a webhook row's providerConfig into a typed subscription config. + * Returns null when the config has no recognizable event type. + */ +export function parseSubscriptionConfig(providerConfig: unknown): SimSubscriptionConfig | null { + const config = + providerConfig && typeof providerConfig === 'object' && !Array.isArray(providerConfig) + ? (providerConfig as Record) + : {} + + const eventType = config.eventType + if ( + typeof eventType !== 'string' || + !(SIM_EVENT_TYPES as readonly string[]).includes(eventType) + ) { + return null + } + + return { + eventType: eventType as SimEventType, + workflowIds: parseStringArray(config.workflowIds), + consecutiveFailures: parseBoundedNumber( + config.consecutiveFailures, + SIM_RULE_DEFAULTS.consecutiveFailures, + SIM_RULE_BOUNDS.consecutiveFailures + ), + failureRatePercent: parseBoundedNumber( + config.failureRatePercent, + SIM_RULE_DEFAULTS.failureRatePercent, + SIM_RULE_BOUNDS.failureRatePercent + ), + windowHours: parseBoundedNumber( + config.windowHours, + SIM_RULE_DEFAULTS.windowHours, + SIM_RULE_BOUNDS.windowHours + ), + durationThresholdMs: parseBoundedNumber( + config.durationThresholdMs, + SIM_RULE_DEFAULTS.durationThresholdMs, + SIM_RULE_BOUNDS.durationThresholdMs + ), + latencySpikePercent: parseBoundedNumber( + config.latencySpikePercent, + SIM_RULE_DEFAULTS.latencySpikePercent, + SIM_RULE_BOUNDS.latencySpikePercent + ), + costThresholdCredits: parseBoundedNumber( + config.costThresholdCredits, + SIM_RULE_DEFAULTS.costThresholdCredits, + SIM_RULE_BOUNDS.costThresholdCredits + ), + errorCountThreshold: parseBoundedNumber( + config.errorCountThreshold, + SIM_RULE_DEFAULTS.errorCountThreshold, + SIM_RULE_BOUNDS.errorCountThreshold + ), + inactivityHours: parseBoundedNumber( + config.inactivityHours, + SIM_RULE_DEFAULTS.inactivityHours, + SIM_RULE_BOUNDS.inactivityHours + ), + } +} diff --git a/apps/sim/lib/workspace-events/types.ts b/apps/sim/lib/workspace-events/types.ts new file mode 100644 index 0000000000..427894b772 --- /dev/null +++ b/apps/sim/lib/workspace-events/types.ts @@ -0,0 +1,64 @@ +import type { webhook, workflow } from '@sim/db/schema' +import type { SimEventPayloadFieldKey, SimEventType } from '@/lib/workspace-events/constants' + +/** A deployed Sim-trigger block subscribed to workspace events. */ +export interface SimSubscription { + webhook: typeof webhook.$inferSelect + workflow: typeof workflow.$inferSelect +} + +/** + * Parsed, coerced subscription configuration. Provider config values arrive as + * raw subblock values (numbers as strings, arrays sometimes serialized), so all + * consumers go through the parser in subscriptions.ts rather than reading + * providerConfig directly. + */ +export interface SimSubscriptionConfig { + eventType: SimEventType + /** Source workflows to watch. Empty means every workflow in the workspace. */ + workflowIds: string[] + consecutiveFailures: number + failureRatePercent: number + windowHours: number + durationThresholdMs: number + latencySpikePercent: number + costThresholdCredits: number + errorCountThreshold: number + inactivityHours: number +} + +/** Facts a completed run contributes to event matching and rule evaluation. */ +export interface ExecutionEventContext { + workflowId: string + executionId: string + status: 'success' | 'error' + durationMs: number + /** Run cost in dollars (the storage unit); converted to credits at the payload boundary. */ + cost: number + finalOutput: unknown +} + +/** Summary of the run behind an event, in user-facing units (cost in credits). */ +export interface SimRunSummary { + runId: string + durationMs: number + cost: number + finalOutput: unknown +} + +/** + * Wire payload delivered to a Sim trigger workflow. Keys must align with + * SIM_EVENT_PAYLOAD_FIELDS — enforced by tests on the payload builders. + */ +export type SimEventPayload = Record & { + event: SimEventType + timestamp: string + workflowId: string + workflowName: string + runId: string | null + durationMs: number | null + cost: number | null + finalOutput: unknown + triggeringRun: SimRunSummary | null + version: number | null +} diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts index 070b9c4ff2..1013b2280f 100644 --- a/apps/sim/lib/workspaces/lifecycle.test.ts +++ b/apps/sim/lib/workspaces/lifecycle.test.ts @@ -78,7 +78,7 @@ describe('workspace lifecycle', () => { expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', { requestId: 'req-1', }) - expect(tx.update).toHaveBeenCalledTimes(11) + expect(tx.update).toHaveBeenCalledTimes(10) expect(tx.delete).toHaveBeenCalledTimes(1) }) diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts index b0a2b0d616..59162b4268 100644 --- a/apps/sim/lib/workspaces/lifecycle.ts +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -12,7 +12,6 @@ import { workflowSchedule, workspace, workspaceFiles, - workspaceNotificationSubscription, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' @@ -107,14 +106,6 @@ export async function archiveWorkspace( }) .where(and(eq(workspaceFiles.workspaceId, workspaceId), isNull(workspaceFiles.deletedAt))) - await tx - .update(workspaceNotificationSubscription) - .set({ - active: false, - updatedAt: now, - }) - .where(eq(workspaceNotificationSubscription.workspaceId, workspaceId)) - await tx .update(invitation) .set({ diff --git a/apps/sim/tools/logs/get_run_details.ts b/apps/sim/tools/logs/get_run_details.ts new file mode 100644 index 0000000000..6326e3afcb --- /dev/null +++ b/apps/sim/tools/logs/get_run_details.ts @@ -0,0 +1,75 @@ +import type { WorkflowLogDetail } from '@/lib/api/contracts/logs' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import type { LogsGetRunDetailsParams, LogsGetRunDetailsResponse } from '@/tools/logs/types' +import type { ToolConfig } from '@/tools/types' + +export const logsGetRunDetailsTool: ToolConfig = + { + id: 'logs_get_run_details', + name: 'Get Run Details', + description: + 'Fetch details for a single workflow run by its run ID, including the full trace spans.', + version: '1.0.0', + + params: { + runId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The run ID to fetch details for', + }, + }, + + request: { + url: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + const qs = new URLSearchParams({ workspaceId }) + return `/api/logs/by-execution/${encodeURIComponent(params.runId)}?${qs.toString()}` + }, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response): Promise => { + const result = await response.json() + if (!response.ok) { + throw new Error(result?.error || `Request failed with status ${response.status}`) + } + const detail: WorkflowLogDetail = result.data + + return { + success: true, + output: { + runId: detail.executionId ?? '', + workflowId: detail.workflowId ?? null, + workflowName: detail.workflow?.name ?? null, + status: detail.status ?? detail.level, + trigger: detail.trigger ?? null, + startedAt: detail.createdAt, + durationMs: detail.executionData?.totalDuration ?? null, + // Costs are stored in dollars; credits are the user-facing unit. + cost: detail.cost?.total != null ? dollarsToCredits(detail.cost.total) : null, + traceSpans: detail.executionData?.traceSpans ?? [], + finalOutput: detail.executionData?.finalOutput ?? null, + }, + } + }, + + outputs: { + runId: { type: 'string', description: 'The run ID' }, + workflowId: { type: 'string', description: 'Workflow ID this run belongs to' }, + workflowName: { type: 'string', description: 'Workflow name' }, + status: { type: 'string', description: 'Run status' }, + trigger: { type: 'string', description: 'How the run was triggered' }, + startedAt: { type: 'string', description: 'Run start time (ISO 8601)' }, + durationMs: { type: 'number', description: 'Run duration in milliseconds' }, + cost: { type: 'number', description: 'Run cost in credits' }, + traceSpans: { type: 'array', description: 'Full trace spans for the run' }, + finalOutput: { type: 'json', description: 'Final output of the run' }, + }, + } diff --git a/apps/sim/tools/logs/index.ts b/apps/sim/tools/logs/index.ts index 109d223c8b..680d9f9e17 100644 --- a/apps/sim/tools/logs/index.ts +++ b/apps/sim/tools/logs/index.ts @@ -1,3 +1,5 @@ export { logsGetExecutionTool } from '@/tools/logs/get_execution' export { logsGetTool } from '@/tools/logs/get_log' +export { logsGetRunDetailsTool } from '@/tools/logs/get_run_details' export { logsQueryTool } from '@/tools/logs/query' +export { logsQueryRunsTool } from '@/tools/logs/query_runs' diff --git a/apps/sim/tools/logs/query_runs.ts b/apps/sim/tools/logs/query_runs.ts new file mode 100644 index 0000000000..6c134a317c --- /dev/null +++ b/apps/sim/tools/logs/query_runs.ts @@ -0,0 +1,163 @@ +import { creditsToDollars } from '@/lib/billing/credits/conversion' +import type { LogsQueryRunsParams, LogsQueryRunsResponse } from '@/tools/logs/types' +import type { ToolConfig } from '@/tools/types' + +export const logsQueryRunsTool: ToolConfig = { + id: 'logs_query_runs', + name: 'Query Logs', + description: + 'Query workflow run logs in the current workspace with the full Logs-page filter set. Returns matching run IDs.', + version: '1.0.0', + + params: { + workflowIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated workflow IDs to filter by', + }, + folderIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated folder IDs to filter by (descendants included)', + }, + level: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "Comma-separated statuses: 'info', 'error', 'running', 'pending', 'cancelled'. Omit for all.", + }, + triggers: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated trigger types (api, webhook, schedule, manual, chat, mcp, a2a, workflow, sim, …)', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 timestamp; only runs at or after this time', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 timestamp; only runs at or before this time', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Free-text search across log fields', + }, + costOperator: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Cost comparison operator: '=', '>', '<', '>=', '<=', '!='", + }, + costValue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Cost threshold in credits, compared using costOperator', + }, + durationOperator: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Duration comparison operator: '=', '>', '<', '>=', '<=', '!='", + }, + durationValue: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Duration threshold in milliseconds, compared using durationOperator', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max run IDs to return (default 100, max 200)', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Sort field: 'date' (default), 'duration', 'cost', 'status'", + }, + sortOrder: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Sort order: 'desc' (default) or 'asc'", + }, + }, + + request: { + url: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + const qs = new URLSearchParams({ workspaceId }) + if (params.workflowIds) qs.set('workflowIds', params.workflowIds) + if (params.folderIds) qs.set('folderIds', params.folderIds) + if (params.level && params.level !== 'all') qs.set('level', params.level) + if (params.triggers) qs.set('triggers', params.triggers) + if (params.startDate) qs.set('startDate', params.startDate) + if (params.endDate) qs.set('endDate', params.endDate) + if (params.search) qs.set('search', params.search) + if (params.costOperator && params.costValue !== undefined && params.costValue !== null) { + qs.set('costOperator', params.costOperator) + // Costs are credit-denominated for users; the API filters in dollars. + qs.set('costValue', String(creditsToDollars(params.costValue))) + } + if ( + params.durationOperator && + params.durationValue !== undefined && + params.durationValue !== null + ) { + qs.set('durationOperator', params.durationOperator) + qs.set('durationValue', String(params.durationValue)) + } + if (params.limit !== undefined && params.limit !== null) { + qs.set('limit', String(params.limit)) + } + if (params.sortBy) qs.set('sortBy', params.sortBy) + if (params.sortOrder) qs.set('sortOrder', params.sortOrder) + return `/api/logs?${qs.toString()}` + }, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response): Promise => { + const result = await response.json() + if (!response.ok) { + throw new Error(result?.error || `Request failed with status ${response.status}`) + } + const rows: Array<{ executionId?: string | null }> = result.data || [] + return { + success: true, + output: { + runIds: rows + .map((row) => row.executionId) + .filter((runId): runId is string => Boolean(runId)), + }, + } + }, + + outputs: { + runIds: { + type: 'array', + description: 'IDs of the runs matching the filters', + }, + }, +} diff --git a/apps/sim/tools/logs/types.ts b/apps/sim/tools/logs/types.ts index 3053059b1f..b8c6f8f074 100644 --- a/apps/sim/tools/logs/types.ts +++ b/apps/sim/tools/logs/types.ts @@ -30,6 +30,31 @@ export interface LogsGetExecutionParams { _context?: WorkflowToolExecutionContext } +export type LogsComparisonOperator = '=' | '>' | '<' | '>=' | '<=' | '!=' + +export interface LogsQueryRunsParams { + workflowIds?: string + folderIds?: string + level?: string + triggers?: string + startDate?: string + endDate?: string + search?: string + costOperator?: LogsComparisonOperator + costValue?: number + durationOperator?: LogsComparisonOperator + durationValue?: number + limit?: number + sortBy?: 'date' | 'duration' | 'cost' | 'status' + sortOrder?: 'asc' | 'desc' + _context?: WorkflowToolExecutionContext +} + +export interface LogsGetRunDetailsParams { + runId: string + _context?: WorkflowToolExecutionContext +} + export interface LogsQueryResponse extends ToolResponse { output: { logs: WorkflowLogSummary[] @@ -37,6 +62,27 @@ export interface LogsQueryResponse extends ToolResponse { } } +export interface LogsQueryRunsResponse extends ToolResponse { + output: { + runIds: string[] + } +} + +export interface LogsGetRunDetailsResponse extends ToolResponse { + output: { + runId: string + workflowId: string | null + workflowName: string | null + status: string + trigger: string | null + startedAt: string + durationMs: number | null + cost: number | null + traceSpans: unknown[] + finalOutput: unknown + } +} + export interface LogsGetResponse extends ToolResponse { output: { log: WorkflowLogDetail diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 740202ee7f..c5f425da89 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1818,7 +1818,13 @@ import { linqUpdateWebhookSubscriptionTool, } from '@/tools/linq' import { llmChatTool } from '@/tools/llm' -import { logsGetExecutionTool, logsGetTool, logsQueryTool } from '@/tools/logs' +import { + logsGetExecutionTool, + logsGetRunDetailsTool, + logsGetTool, + logsQueryRunsTool, + logsQueryTool, +} from '@/tools/logs' import { loopsCreateContactPropertyTool, loopsCreateContactTool, @@ -3758,8 +3764,10 @@ export const tools: Record = { linq_update_contact_card: linqUpdateContactCardTool, linq_update_webhook_subscription: linqUpdateWebhookSubscriptionTool, logs_query: logsQueryTool, + logs_query_runs: logsQueryRunsTool, logs_get: logsGetTool, logs_get_execution: logsGetExecutionTool, + logs_get_run_details: logsGetRunDetailsTool, loops_create_contact: loopsCreateContactTool, loops_create_contact_property: loopsCreateContactPropertyTool, loops_update_contact: loopsUpdateContactTool, diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index 57eab0b775..bd1ca98de0 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -56,3 +56,15 @@ export const POLLING_PROVIDERS = new Set([ export function isPollingWebhookProvider(provider: string): boolean { return POLLING_PROVIDERS.has(provider) } + +/** + * Providers whose triggers fire internally (table row events, Sim workspace + * events) rather than via external HTTP webhooks. Their webhook rows still + * register a path, so the public trigger route must reject deliveries to + * them — otherwise anyone with the block ID could forge events. + */ +export const INTERNAL_TRIGGER_PROVIDERS = new Set(['sim', 'table']) + +export function isInternalTriggerProvider(provider: string | null): boolean { + return provider !== null && INTERNAL_TRIGGER_PROVIDERS.has(provider) +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index d6c0c40b25..d388c8ab30 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -301,6 +301,7 @@ import { servicenowIncidentUpdatedTrigger, servicenowWebhookTrigger, } from '@/triggers/servicenow' +import { simWorkspaceEventTrigger } from '@/triggers/sim' import { slackWebhookTrigger } from '@/triggers/slack' import { stripeWebhookTrigger } from '@/triggers/stripe' import { tableNewRowTrigger } from '@/triggers/table' @@ -564,6 +565,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { servicenow_change_request_created: servicenowChangeRequestCreatedTrigger, servicenow_change_request_updated: servicenowChangeRequestUpdatedTrigger, servicenow_webhook: servicenowWebhookTrigger, + sim_workspace_event: simWorkspaceEventTrigger, stripe_webhook: stripeWebhookTrigger, table_new_row: tableNewRowTrigger, telegram_webhook: telegramWebhookTrigger, diff --git a/apps/sim/triggers/sim/index.ts b/apps/sim/triggers/sim/index.ts new file mode 100644 index 0000000000..964fe9475e --- /dev/null +++ b/apps/sim/triggers/sim/index.ts @@ -0,0 +1 @@ +export { simWorkspaceEventTrigger } from '@/triggers/sim/workspace-event' diff --git a/apps/sim/triggers/sim/workspace-event.test.ts b/apps/sim/triggers/sim/workspace-event.test.ts new file mode 100644 index 0000000000..9d65c95b67 --- /dev/null +++ b/apps/sim/triggers/sim/workspace-event.test.ts @@ -0,0 +1,255 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + SIM_EVENT_PAYLOAD_FIELDS, + SIM_EVENT_TYPES, + SIM_RULE_DEFAULTS, + SIM_RULE_EVENT_TYPES, + SIM_TRIGGER_PROVIDER, + SIM_WORKSPACE_EVENT_TRIGGER_ID, +} from '@/lib/workspace-events/constants' +import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event' +import { TRIGGER_REGISTRY } from '@/triggers/registry' +import { simWorkspaceEventTrigger } from '@/triggers/sim' + +describe('sim workspace event trigger registration', () => { + it('is registered in the trigger registry under its trigger ID', () => { + expect(TRIGGER_REGISTRY[SIM_WORKSPACE_EVENT_TRIGGER_ID]).toBe(simWorkspaceEventTrigger) + }) + + it('uses the sim provider and is purely internal (no webhook, no polling)', () => { + expect(simWorkspaceEventTrigger.provider).toBe(SIM_TRIGGER_PROVIDER) + expect(simWorkspaceEventTrigger.webhook).toBeUndefined() + expect(simWorkspaceEventTrigger.polling).toBeUndefined() + }) + + it('block type equals the trigger ID so deploy-time trigger resolution works', () => { + expect(SimWorkspaceEventBlock.type).toBe(SIM_WORKSPACE_EVENT_TRIGGER_ID) + expect(SimWorkspaceEventBlock.category).toBe('triggers') + expect(SimWorkspaceEventBlock.triggers).toEqual({ + enabled: true, + available: [SIM_WORKSPACE_EVENT_TRIGGER_ID], + }) + }) + + it('is named Sim', () => { + expect(SimWorkspaceEventBlock.name).toBe('Sim') + expect(simWorkspaceEventTrigger.name).toBe('Sim') + }) +}) + +describe('sim workspace event subblocks', () => { + it('all subblocks are trigger-mode with unique IDs', () => { + const ids = simWorkspaceEventTrigger.subBlocks.map((subBlock) => subBlock.id) + expect(new Set(ids).size).toBe(ids.length) + for (const subBlock of simWorkspaceEventTrigger.subBlocks) { + expect(subBlock.mode, `subblock ${subBlock.id} must be trigger-mode`).toBe('trigger') + } + }) + + it('the eventType dropdown covers every event type exactly once', () => { + const eventTypeSubBlock = simWorkspaceEventTrigger.subBlocks.find( + (subBlock) => subBlock.id === 'eventType' + ) + expect(eventTypeSubBlock).toBeDefined() + const optionIds = (eventTypeSubBlock!.options as Array<{ id: string; label: string }>).map( + (option) => option.id + ) + expect(optionIds.sort()).toEqual([...SIM_EVENT_TYPES].sort()) + }) + + it('the workflow multi-select is always visible and optional (empty = all workflows)', () => { + const workflowIds = simWorkspaceEventTrigger.subBlocks.find((sb) => sb.id === 'workflowIds') + expect(workflowIds).toBeDefined() + expect(workflowIds!.condition).toBeUndefined() + expect(workflowIds!.required).toBe(false) + expect(workflowIds!.multiSelect).toBe(true) + expect(workflowIds!.placeholder).toBe('All workflows') + }) + + it('has no source-trigger filter or finalOutput toggle', () => { + const ids = simWorkspaceEventTrigger.subBlocks.map((sb) => sb.id) + expect(ids).not.toContain('triggerFilter') + expect(ids).not.toContain('includeFinalOutput') + expect(ids).not.toContain('allWorkflows') + }) + + it('every rule event type has a config subblock gated to it with the ported default', () => { + const expectations: Array<{ id: string; eventType: string; defaultValue: string }> = [ + { + id: 'consecutiveFailures', + eventType: 'consecutive_failures', + defaultValue: String(SIM_RULE_DEFAULTS.consecutiveFailures), + }, + { + id: 'failureRatePercent', + eventType: 'failure_rate', + defaultValue: String(SIM_RULE_DEFAULTS.failureRatePercent), + }, + { + id: 'durationThresholdMs', + eventType: 'latency_threshold', + defaultValue: String(SIM_RULE_DEFAULTS.durationThresholdMs), + }, + { + id: 'latencySpikePercent', + eventType: 'latency_spike', + defaultValue: String(SIM_RULE_DEFAULTS.latencySpikePercent), + }, + { + id: 'costThresholdCredits', + eventType: 'cost_threshold', + defaultValue: String(SIM_RULE_DEFAULTS.costThresholdCredits), + }, + { + id: 'errorCountThreshold', + eventType: 'error_count', + defaultValue: String(SIM_RULE_DEFAULTS.errorCountThreshold), + }, + { + id: 'inactivityHours', + eventType: 'no_activity', + defaultValue: String(SIM_RULE_DEFAULTS.inactivityHours), + }, + ] + + for (const expectation of expectations) { + const subBlock = simWorkspaceEventTrigger.subBlocks.find((sb) => sb.id === expectation.id) + expect(subBlock, `missing config subblock ${expectation.id}`).toBeDefined() + expect(subBlock!.defaultValue).toBe(expectation.defaultValue) + const condition = subBlock!.condition as { field: string; value: string } + expect(condition.field).toBe('eventType') + expect(condition.value).toBe(expectation.eventType) + } + + const windowHours = simWorkspaceEventTrigger.subBlocks.find((sb) => sb.id === 'windowHours') + expect(windowHours).toBeDefined() + const windowCondition = windowHours!.condition as { field: string; value: string[] } + expect(windowCondition.field).toBe('eventType') + expect(windowCondition.value.sort()).toEqual( + ['error_count', 'failure_rate', 'latency_spike'].sort() + ) + }) + + it('rule config subblocks are gated only to rule event types', () => { + const ruleConfigIds = [ + 'consecutiveFailures', + 'failureRatePercent', + 'durationThresholdMs', + 'latencySpikePercent', + 'costThresholdCredits', + 'errorCountThreshold', + 'inactivityHours', + 'windowHours', + ] + for (const id of ruleConfigIds) { + const subBlock = simWorkspaceEventTrigger.subBlocks.find((sb) => sb.id === id) + const condition = subBlock!.condition as { field: string; value: string | string[] } + const gatedTo = Array.isArray(condition.value) ? condition.value : [condition.value] + for (const eventType of gatedTo) { + expect( + (SIM_RULE_EVENT_TYPES as readonly string[]).includes(eventType), + `${id} is gated to non-rule event type ${eventType}` + ).toBe(true) + } + } + }) +}) + +describe('sim workspace event outputs', () => { + const EXECUTION_BACKED = [ + 'execution_success', + 'execution_error', + 'consecutive_failures', + 'failure_rate', + 'latency_threshold', + 'latency_spike', + 'cost_threshold', + 'error_count', + ] + + /** Output keys expected in the tag dropdown for a given event type. */ + function visibleOutputsFor(eventType: string): string[] { + return Object.entries(simWorkspaceEventTrigger.outputs) + .filter(([, definition]) => { + const condition = (definition as { condition?: { field: string; value: unknown } }) + .condition + if (!condition) return true + const values = Array.isArray(condition.value) ? condition.value : [condition.value] + return values.includes(eventType) + }) + .map(([key]) => key) + .sort() + } + + it('trigger outputs align key-for-key with the shared payload field constants', () => { + const outputKeys = Object.keys(simWorkspaceEventTrigger.outputs).sort() + const payloadKeys = Object.keys(SIM_EVENT_PAYLOAD_FIELDS).sort() + expect(outputKeys).toEqual(payloadKeys) + }) + + it('output conditions only reference the eventType field with valid event types', () => { + for (const [key, definition] of Object.entries(simWorkspaceEventTrigger.outputs)) { + const condition = (definition as { condition?: { field: string; value: unknown } }).condition + if (!condition) continue + expect(condition.field, `${key} condition must gate on eventType`).toBe('eventType') + const values = Array.isArray(condition.value) ? condition.value : [condition.value] + for (const value of values) { + expect( + (SIM_EVENT_TYPES as readonly string[]).includes(value as string), + `${key} condition references unknown event type ${value}` + ).toBe(true) + } + } + }) + + it('plain run events expose the base fields plus the top-level run summary', () => { + for (const eventType of ['execution_success', 'execution_error']) { + expect(visibleOutputsFor(eventType)).toEqual( + [ + 'cost', + 'durationMs', + 'event', + 'finalOutput', + 'runId', + 'timestamp', + 'workflowId', + 'workflowName', + ].sort() + ) + } + }) + + it('run-backed rule events expose the base fields plus the nested triggeringRun', () => { + for (const eventType of EXECUTION_BACKED.filter( + (type) => type !== 'execution_success' && type !== 'execution_error' + )) { + expect(visibleOutputsFor(eventType)).toEqual( + ['event', 'timestamp', 'triggeringRun', 'workflowId', 'workflowName'].sort() + ) + } + }) + + it('triggeringRun nests the same run summary fields as plain run events', () => { + const triggeringRun = simWorkspaceEventTrigger.outputs.triggeringRun as { + properties?: Record + } + expect(Object.keys(triggeringRun.properties ?? {}).sort()).toEqual( + ['cost', 'durationMs', 'finalOutput', 'runId'].sort() + ) + }) + + it('workflow_deployed exposes the base fields plus the version', () => { + expect(visibleOutputsFor('workflow_deployed')).toEqual( + ['event', 'timestamp', 'version', 'workflowId', 'workflowName'].sort() + ) + }) + + it('no_activity exposes only the base fields', () => { + expect(visibleOutputsFor('no_activity')).toEqual( + ['event', 'timestamp', 'workflowId', 'workflowName'].sort() + ) + }) +}) diff --git a/apps/sim/triggers/sim/workspace-event.ts b/apps/sim/triggers/sim/workspace-event.ts new file mode 100644 index 0000000000..1f8d9d43ea --- /dev/null +++ b/apps/sim/triggers/sim/workspace-event.ts @@ -0,0 +1,172 @@ +import { SimTriggerIcon } from '@/components/icons' +import { fetchWorkspaceWorkflowOptions } from '@/lib/workflows/subblocks/options' +import { + SIM_EVENT_PAYLOAD_FIELDS, + SIM_RULE_DEFAULTS, + SIM_TRIGGER_PROVIDER, + SIM_WORKSPACE_EVENT_TRIGGER_ID, +} from '@/lib/workspace-events/constants' +import type { TriggerConfig } from '@/triggers/types' + +export const simWorkspaceEventTrigger: TriggerConfig = { + id: SIM_WORKSPACE_EVENT_TRIGGER_ID, + name: 'Sim', + provider: SIM_TRIGGER_PROVIDER, + description: + 'Triggers when workspace events occur: execution errors or successes, deployments, and alert conditions like latency or cost spikes', + version: '1.0.0', + icon: SimTriggerIcon, + + subBlocks: [ + { + id: 'eventType', + title: 'Event', + type: 'dropdown', + options: [ + { id: 'execution_error', label: 'Execution Error', group: 'Events' }, + { id: 'execution_success', label: 'Execution Success', group: 'Events' }, + { id: 'workflow_deployed', label: 'Workflow Deployed', group: 'Events' }, + { id: 'consecutive_failures', label: 'Consecutive Failures', group: 'Alert Conditions' }, + { id: 'failure_rate', label: 'Failure Rate', group: 'Alert Conditions' }, + { id: 'latency_threshold', label: 'Latency Threshold', group: 'Alert Conditions' }, + { id: 'latency_spike', label: 'Latency Spike', group: 'Alert Conditions' }, + { id: 'cost_threshold', label: 'Cost Threshold', group: 'Alert Conditions' }, + { id: 'error_count', label: 'Error Count', group: 'Alert Conditions' }, + { id: 'no_activity', label: 'No Activity', group: 'Alert Conditions' }, + ], + defaultValue: 'execution_error', + description: 'The workspace event or alert condition to trigger on.', + required: true, + mode: 'trigger', + }, + { + id: 'workflowIds', + title: 'Workflows', + type: 'dropdown', + multiSelect: true, + options: [], + placeholder: 'All workflows', + description: 'Only fire for these workflows. Leave empty to watch every workflow.', + required: false, + mode: 'trigger', + // A subscriber never receives events about itself, so exclude it. + fetchOptions: () => fetchWorkspaceWorkflowOptions({ excludeActiveWorkflow: true }), + }, + { + id: 'consecutiveFailures', + title: 'Consecutive Failures', + type: 'short-input', + placeholder: String(SIM_RULE_DEFAULTS.consecutiveFailures), + defaultValue: String(SIM_RULE_DEFAULTS.consecutiveFailures), + description: 'Fire after this many consecutive failed executions.', + required: { field: 'eventType', value: 'consecutive_failures' }, + mode: 'trigger', + condition: { field: 'eventType', value: 'consecutive_failures' }, + }, + { + id: 'failureRatePercent', + title: 'Failure Rate (%)', + type: 'short-input', + placeholder: String(SIM_RULE_DEFAULTS.failureRatePercent), + defaultValue: String(SIM_RULE_DEFAULTS.failureRatePercent), + description: + 'Fire when the failure rate meets or exceeds this percentage over the time window.', + required: { field: 'eventType', value: 'failure_rate' }, + mode: 'trigger', + condition: { field: 'eventType', value: 'failure_rate' }, + }, + { + id: 'durationThresholdMs', + title: 'Duration Threshold (ms)', + type: 'short-input', + placeholder: String(SIM_RULE_DEFAULTS.durationThresholdMs), + defaultValue: String(SIM_RULE_DEFAULTS.durationThresholdMs), + description: 'Fire when an execution takes longer than this many milliseconds.', + required: { field: 'eventType', value: 'latency_threshold' }, + mode: 'trigger', + condition: { field: 'eventType', value: 'latency_threshold' }, + }, + { + id: 'latencySpikePercent', + title: 'Latency Spike (%)', + type: 'short-input', + placeholder: String(SIM_RULE_DEFAULTS.latencySpikePercent), + defaultValue: String(SIM_RULE_DEFAULTS.latencySpikePercent), + description: + 'Fire when an execution is this much slower than the average over the time window.', + required: { field: 'eventType', value: 'latency_spike' }, + mode: 'trigger', + condition: { field: 'eventType', value: 'latency_spike' }, + }, + { + id: 'costThresholdCredits', + title: 'Cost Threshold (credits)', + type: 'short-input', + placeholder: String(SIM_RULE_DEFAULTS.costThresholdCredits), + defaultValue: String(SIM_RULE_DEFAULTS.costThresholdCredits), + description: 'Fire when a run costs more than this many credits.', + required: { field: 'eventType', value: 'cost_threshold' }, + mode: 'trigger', + condition: { field: 'eventType', value: 'cost_threshold' }, + }, + { + id: 'errorCountThreshold', + title: 'Error Count', + type: 'short-input', + placeholder: String(SIM_RULE_DEFAULTS.errorCountThreshold), + defaultValue: String(SIM_RULE_DEFAULTS.errorCountThreshold), + description: 'Fire when at least this many errors occur within the time window.', + required: { field: 'eventType', value: 'error_count' }, + mode: 'trigger', + condition: { field: 'eventType', value: 'error_count' }, + }, + { + id: 'windowHours', + title: 'Time Window (hours)', + type: 'short-input', + placeholder: String(SIM_RULE_DEFAULTS.windowHours), + defaultValue: String(SIM_RULE_DEFAULTS.windowHours), + description: 'The rolling time window used to evaluate this condition.', + required: { + field: 'eventType', + value: ['failure_rate', 'latency_spike', 'error_count'], + }, + mode: 'trigger', + condition: { + field: 'eventType', + value: ['failure_rate', 'latency_spike', 'error_count'], + }, + }, + { + id: 'inactivityHours', + title: 'Inactivity Window (hours)', + type: 'short-input', + placeholder: String(SIM_RULE_DEFAULTS.inactivityHours), + defaultValue: String(SIM_RULE_DEFAULTS.inactivityHours), + description: 'Fire when a watched workflow has no executions for this many hours.', + required: { field: 'eventType', value: 'no_activity' }, + mode: 'trigger', + condition: { field: 'eventType', value: 'no_activity' }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: [ + 'Choose the workspace event or alert condition to react to', + 'Optionally narrow it to specific workflows — leaving the selection empty watches every workflow (this workflow is always excluded; it never triggers itself)', + 'Deploy this workflow — events only fire for deployed workflows', + 'Executions started by this trigger never emit workspace events, so chains and loops are not possible', + ] + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join(''), + mode: 'trigger', + }, + ], + + outputs: SIM_EVENT_PAYLOAD_FIELDS, +} diff --git a/apps/sim/triggers/types.ts b/apps/sim/triggers/types.ts index 65f0821d35..eb9277484e 100644 --- a/apps/sim/triggers/types.ts +++ b/apps/sim/triggers/types.ts @@ -1,9 +1,11 @@ -import type { SubBlockConfig } from '@/blocks/types' +import type { OutputCondition, SubBlockConfig } from '@/blocks/types' export interface TriggerOutput { type?: string description?: string | TriggerOutput - [key: string]: TriggerOutput | string | undefined + /** Restricts which trigger configurations surface this output in the tag dropdown. */ + condition?: OutputCondition + [key: string]: TriggerOutput | OutputCondition | string | undefined } export interface TriggerConfig { diff --git a/bun.lock b/bun.lock index 7498ac72ab..6ccdd5bd67 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/helm/sim/README.md b/helm/sim/README.md index 6bb45abc8d..4514f0cde7 100644 --- a/helm/sim/README.md +++ b/helm/sim/README.md @@ -41,7 +41,7 @@ This chart deploys the Sim platform on a Kubernetes cluster using the Helm packa * **`realtime`** — the WebSocket service for live workflow updates (Deployment). * **`postgresql`** — an in-cluster `pgvector/pgvector` Postgres (StatefulSet, with a headless Service for stable per-pod DNS). * **`migrations`** — a Job that applies database migrations on install/upgrade. -* **`cronjobs`** — scheduled jobs for workflow schedule execution, inbox/calendar/drive polling (Gmail, Outlook, Calendar, Drive, Sheets, IMAP, RSS), inactivity alerts, subscription renewal, data drains, and connector syncs. +* **`cronjobs`** — scheduled jobs for workflow schedule execution, inbox/calendar/drive polling (Gmail, Outlook, Calendar, Drive, Sheets, IMAP, RSS), workspace event polling, subscription renewal, data drains, and connector syncs. * **`serviceaccount`** — a dedicated ServiceAccount with `automountServiceAccountToken: false`. Optional components (off by default): diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 654549e4af..4898f925fe 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -1192,11 +1192,11 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 - inactivityAlertPoll: + workspaceEventsPoll: enabled: true - name: inactivity-alert-poll + name: workspace-events-poll schedule: "*/15 * * * *" - path: "/api/notifications/poll" + path: "/api/workspace-events/poll" concurrencyPolicy: Forbid successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 9f23872314..2757f2988f 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -102,11 +102,6 @@ export const AuditAction = { MEMBER_REMOVED: 'member.removed', MEMBER_ROLE_CHANGED: 'member.role_changed', - // Notifications - NOTIFICATION_CREATED: 'notification.created', - NOTIFICATION_UPDATED: 'notification.updated', - NOTIFICATION_DELETED: 'notification.deleted', - // OAuth / Credentials OAUTH_DISCONNECTED: 'oauth.disconnected', CREDENTIAL_CREATED: 'credential.created', @@ -207,7 +202,6 @@ export const AuditResourceType = { FOLDER: 'folder', KNOWLEDGE_BASE: 'knowledge_base', MCP_SERVER: 'mcp_server', - NOTIFICATION: 'notification', OAUTH: 'oauth', ORGANIZATION: 'organization', PASSWORD: 'password', diff --git a/packages/db/migrations/0231_sim_trigger_workspace_events.sql b/packages/db/migrations/0231_sim_trigger_workspace_events.sql new file mode 100644 index 0000000000..57fac45a87 --- /dev/null +++ b/packages/db/migrations/0231_sim_trigger_workspace_events.sql @@ -0,0 +1,14 @@ +CREATE TABLE "sim_trigger_state" ( + "workflow_id" text NOT NULL, + "block_id" text NOT NULL, + "scope_key" text DEFAULT '' NOT NULL, + "last_fired_at" timestamp, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "sim_trigger_state_workflow_id_block_id_scope_key_pk" PRIMARY KEY("workflow_id","block_id","scope_key") +); +--> statement-breakpoint +DROP TABLE "workspace_notification_delivery" CASCADE;--> statement-breakpoint +DROP TABLE "workspace_notification_subscription" CASCADE;--> statement-breakpoint +ALTER TABLE "sim_trigger_state" ADD CONSTRAINT "sim_trigger_state_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +DROP TYPE "public"."notification_delivery_status";--> statement-breakpoint +DROP TYPE "public"."notification_type"; \ No newline at end of file diff --git a/packages/db/migrations/meta/0231_snapshot.json b/packages/db/migrations/meta/0231_snapshot.json new file mode 100644 index 0000000000..9e1ba095a4 --- /dev/null +++ b/packages/db/migrations/meta/0231_snapshot.json @@ -0,0 +1,16266 @@ +{ + "id": "4c727ad1-ff15-463f-a929-61b0c74b4e4d", + "prevId": "b6675160-a7cc-4e1b-bbc3-4b050284b789", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "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": 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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_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": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_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": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_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": "no action", + "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 + }, + "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 + }, + "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 + }, + "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()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "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_workspace_name_unique": { + "name": "permission_group_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": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "workspace_id": { + "name": "workspace_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_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_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.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.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 + }, + "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_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 + }, + "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_a2a_executions": { + "name": "total_a2a_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 + } + }, + "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 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "import_status": { + "name": "import_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_error": { + "name": "import_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_rows_processed": { + "name": "import_rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "import_started_at": { + "name": "import_started_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_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": 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": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_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": "'{}'" + }, + "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 + }, + "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 + }, + "created_at": { + "name": "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": {} + } + }, + "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" + } + }, + "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 + }, + "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_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_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 55ca91ceaf..3743536a18 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1611,6 +1611,13 @@ "when": 1781027249389, "tag": "0230_thick_stranger", "breakpoints": true + }, + { + "idx": 231, + "version": "7", + "when": 1781053629977, + "tag": "0231_sim_trigger_workspace_events", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index a24c15d8c7..a9f4a447c8 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -767,91 +767,27 @@ export const webhook = pgTable( } ) -export const notificationTypeEnum = pgEnum('notification_type', ['webhook', 'email', 'slack']) - -export const notificationDeliveryStatusEnum = pgEnum('notification_delivery_status', [ - 'pending', - 'in_progress', - 'success', - 'failed', -]) - -export const workspaceNotificationSubscription = pgTable( - 'workspace_notification_subscription', - { - id: text('id').primaryKey(), - workspaceId: text('workspace_id') - .notNull() - .references(() => workspace.id, { onDelete: 'cascade' }), - notificationType: notificationTypeEnum('notification_type').notNull(), - workflowIds: text('workflow_ids').array().notNull().default(sql`'{}'::text[]`), - allWorkflows: boolean('all_workflows').notNull().default(false), - levelFilter: text('level_filter') - .array() - .notNull() - .default(sql`ARRAY['info', 'error']::text[]`), - triggerFilter: text('trigger_filter') - .array() - .notNull() - .default(sql`ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]`), - includeFinalOutput: boolean('include_final_output').notNull().default(false), - includeTraceSpans: boolean('include_trace_spans').notNull().default(false), - includeRateLimits: boolean('include_rate_limits').notNull().default(false), - includeUsageData: boolean('include_usage_data').notNull().default(false), - - // Channel-specific configuration - webhookConfig: jsonb('webhook_config'), - emailRecipients: text('email_recipients').array(), - slackConfig: jsonb('slack_config'), - - // Alert rule configuration (if null, sends on every execution) - alertConfig: jsonb('alert_config'), - lastAlertAt: timestamp('last_alert_at'), - - active: boolean('active').notNull().default(true), - createdBy: text('created_by') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - }, - (table) => ({ - workspaceIdIdx: index('workspace_notification_workspace_id_idx').on(table.workspaceId), - activeIdx: index('workspace_notification_active_idx').on(table.active), - typeIdx: index('workspace_notification_type_idx').on(table.notificationType), - }) -) - -export const workspaceNotificationDelivery = pgTable( - 'workspace_notification_delivery', +/** + * Cooldown state for Sim workspace-event trigger subscriptions. + * + * Keyed by (workflowId, blockId, scopeKey) rather than the webhook row because + * webhook rows are recreated per deployment version — state stored there would + * reset on every redeploy. `scopeKey` is '' for subscription-level cooldowns + * and the source workflow ID for per-source-workflow rules (no_activity). + */ +export const simTriggerState = pgTable( + 'sim_trigger_state', { - id: text('id').primaryKey(), - subscriptionId: text('subscription_id') - .notNull() - .references(() => workspaceNotificationSubscription.id, { onDelete: 'cascade' }), workflowId: text('workflow_id') .notNull() .references(() => workflow.id, { onDelete: 'cascade' }), - executionId: text('execution_id').notNull(), - status: notificationDeliveryStatusEnum('status').notNull().default('pending'), - attempts: integer('attempts').notNull().default(0), - lastAttemptAt: timestamp('last_attempt_at'), - nextAttemptAt: timestamp('next_attempt_at'), - responseStatus: integer('response_status'), - responseBody: text('response_body'), - errorMessage: text('error_message'), - createdAt: timestamp('created_at').notNull().defaultNow(), + blockId: text('block_id').notNull(), + scopeKey: text('scope_key').notNull().default(''), + lastFiredAt: timestamp('last_fired_at'), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ - subscriptionIdIdx: index('workspace_notification_delivery_subscription_id_idx').on( - table.subscriptionId - ), - executionIdIdx: index('workspace_notification_delivery_execution_id_idx').on(table.executionId), - statusIdx: index('workspace_notification_delivery_status_idx').on(table.status), - nextAttemptIdx: index('workspace_notification_delivery_next_attempt_idx').on( - table.nextAttemptAt - ), + pk: primaryKey({ columns: [table.workflowId, table.blockId, table.scopeKey] }), }) ) diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 3cd9d624af..30c2a71bb2 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -101,9 +101,6 @@ export const auditMock = { MEMBER_INVITED: 'member.invited', MEMBER_REMOVED: 'member.removed', MEMBER_ROLE_CHANGED: 'member.role_changed', - NOTIFICATION_CREATED: 'notification.created', - NOTIFICATION_UPDATED: 'notification.updated', - NOTIFICATION_DELETED: 'notification.deleted', OAUTH_DISCONNECTED: 'oauth.disconnected', PASSWORD_RESET: 'password.reset', PASSWORD_RESET_REQUESTED: 'password.reset_requested', @@ -171,7 +168,6 @@ export const auditMock = { FOLDER: 'folder', KNOWLEDGE_BASE: 'knowledge_base', MCP_SERVER: 'mcp_server', - NOTIFICATION: 'notification', OAUTH: 'oauth', ORGANIZATION: 'organization', PASSWORD: 'password', diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index f4de1f54b4..f055746753 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -42,6 +42,10 @@ export function createMockSqlOperators() { lt: vi.fn((a, b) => ({ type: 'lt', left: a, right: b })), lte: vi.fn((a, b) => ({ type: 'lte', left: a, right: b })), count: vi.fn((column) => ({ type: 'count', column })), + avg: vi.fn((column) => ({ type: 'avg', column })), + sum: vi.fn((column) => ({ type: 'sum', column })), + min: vi.fn((column) => ({ type: 'min', column })), + max: vi.fn((column) => ({ type: 'max', column })), and: vi.fn((...conditions) => ({ type: 'and', conditions })), or: vi.fn((...conditions) => ({ type: 'or', conditions })), not: vi.fn((condition) => ({ type: 'not', condition })), diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 5836a65285..ec12dc2d26 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -307,43 +307,11 @@ export const schemaMock = { createdAt: 'createdAt', updatedAt: 'updatedAt', }, - notificationTypeEnum: 'notificationTypeEnum', - notificationDeliveryStatusEnum: 'notificationDeliveryStatusEnum', - workspaceNotificationSubscription: { - id: 'id', - workspaceId: 'workspaceId', - notificationType: 'notificationType', - workflowIds: 'workflowIds', - allWorkflows: 'allWorkflows', - levelFilter: 'levelFilter', - triggerFilter: 'triggerFilter', - includeFinalOutput: 'includeFinalOutput', - includeTraceSpans: 'includeTraceSpans', - includeRateLimits: 'includeRateLimits', - includeUsageData: 'includeUsageData', - webhookConfig: 'webhookConfig', - emailRecipients: 'emailRecipients', - slackConfig: 'slackConfig', - alertConfig: 'alertConfig', - lastAlertAt: 'lastAlertAt', - active: 'active', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - }, - workspaceNotificationDelivery: { - id: 'id', - subscriptionId: 'subscriptionId', + simTriggerState: { workflowId: 'workflowId', - executionId: 'executionId', - status: 'status', - attempts: 'attempts', - lastAttemptAt: 'lastAttemptAt', - nextAttemptAt: 'nextAttemptAt', - responseStatus: 'responseStatus', - responseBody: 'responseBody', - errorMessage: 'errorMessage', - createdAt: 'createdAt', + blockId: 'blockId', + scopeKey: 'scopeKey', + lastFiredAt: 'lastFiredAt', updatedAt: 'updatedAt', }, apiKey: { diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 60a4099479..aad84b8f8d 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -33,7 +33,7 @@ const TRIGGERS_PATH = path.join(rootDir, 'apps/sim/triggers') const TRIGGER_DOCS_OUTPUT_PATH = path.join(rootDir, 'apps/docs/content/docs/en/triggers') /** Trigger doc pages that are hand-written and must never be overwritten. */ -const HANDWRITTEN_TRIGGER_DOCS = new Set(['index', 'start', 'schedule', 'webhook', 'rss']) +const HANDWRITTEN_TRIGGER_DOCS = new Set(['index', 'start', 'schedule', 'webhook', 'rss', 'sim']) /** Providers whose docs are already covered by hand-written pages. */ const SKIP_TRIGGER_PROVIDERS = new Set(['generic', 'rss']) @@ -1041,7 +1041,7 @@ function isIntegrationBlock(config: { category?: string; hideFromToolbar?: boole * generators, vision, and STT/TTS — is excluded from the integrations icon * map, matching {@link isIntegrationBlock}. */ -const ICON_MAP_BLOCK_CATEGORY_ALLOWLIST = new Set(['memory', 'knowledge']) +const ICON_MAP_BLOCK_CATEGORY_ALLOWLIST = new Set(['memory', 'knowledge', 'enrichment']) /** * Block types that never belong in the integrations icon map regardless of From 3cedac8e8295206eaba261d2acc4728f485c55ba Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 10 Jun 2026 11:38:58 -0700 Subject: [PATCH 06/10] fix(security): authz, IDOR, and abuse-prevention fixes (#4944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(knowledge): require write access for batch chunk operations The PATCH /api/knowledge/[id]/documents/[documentId]/chunks handler performs enable/disable/delete operations but authorized callers with only read-level access (checkDocumentAccess). This let read-only workspace members destroy or disable indexed chunks. Switch to checkDocumentWriteAccess (write/admin required), matching the sibling POST/PUT/DELETE chunk mutation endpoints. * fix(env): restrict decrypted workspace env vars to secret admins GET /api/workspaces/:id/environment returned decrypted workspace environment variables to any member, including read-only collaborators, leaking API tokens, database URLs, and other secrets. Mask workspace variable values for non-admin viewers while preserving the variable names, so editor autocomplete and conflict detection keep working. A value is revealed only when the caller is a credential admin of that key, or — for legacy keys with no per-secret ACL — holds workspace admin permission. This mirrors the per-key edit gating already enforced by PUT/DELETE: if you can administer a secret, you can read it. Personal variables and execution-time resolution are unchanged. * fix(files): block cross-tenant deletion via client-controlled context POST /api/files/delete trusted a client-supplied `context`, letting any authenticated user delete another tenant's file by naming an arbitrary key with `context: "og-images"`. verifyFileAccess() short-circuited the three public contexts (profile-pictures, og-images, workspace-logos) to `true` before any ownership/requireWrite check. - Derive the storage context strictly from the trusted key prefix in the delete route; reject a supplied `context` that disagrees with the key. - Gate the public-context short-circuit to reads only. Destructive ops (requireWrite) now prove ownership via verifyPublicAssetWriteAccess: workspace-logos require write/admin on the bound workspace, profile-pictures require an exact owner match, og-images always deny. Reads of public assets are unchanged. * fix(telegram): verify X-Telegram-Bot-Api-Secret-Token on inbound webhooks Telegram triggers accepted any forged update from anyone who knew the webhook URL path: verifyAuth was a no-op that always returned null, and setWebhook registered no secret_token. Generate a per-webhook secret in createSubscription, register it with Telegram as secret_token, and persist it to providerConfig. verifyAuth now fails closed — rejects when no token is configured, when the X-Telegram-Bot-Api-Secret-Token header is absent, or when it does not match via constant-time safeCompare. * fix(security): pin DNS for Agiloft directExecution and Grafana update tools The Agiloft directExecution tools (read/create/search/update/delete/lock/ saved_search/select/get_choice_line_id/remove_attachment/attachment_info) and the Grafana update_dashboard/update_alert_rule postProcess hooks issued outbound HTTP to a fully user-controlled host (instanceUrl/baseUrl) via the global fetch(), guarded only by the synchronous validateExternalUrl() — which never resolves DNS, so a hostname resolving to an internal/reserved IP passed validation (SSRF). Route all of these through the codebase's standard SSRF-safe path: - Agiloft: moved executeAgiloftRequest into utils.server.ts where the existing pinned helpers live. It now resolves+validates the instance URL once and pins every hop (login, operation, logout) to that IP via secureFetchWithPinnedIP. The 11 tool configs now import it from utils.server; URL builders stay in the client-safe utils.ts. - Grafana: the postProcess POST/PUT now uses validateUrlWithDNS + secureFetchWithPinnedIP, matching the already-pinned initial GET. This completes the Agiloft SSRF pinning started in #4639 (which covered the attach/retrieve API routes) by closing the directExecution path, and extends the same guard to the Grafana update tools. * fix(api): enforce workspace allowPersonalApiKeys policy on v1 surface The external v1 API authenticated API keys without evaluating the per-workspace allowPersonalApiKeys setting, so a personal API key could read and mutate a workspace's resources (workflows, tables, files, knowledge, logs) even when the workspace had explicitly disabled personal keys. The same control is already enforced on the workflow-execution surface. Enforce the policy in checkWorkspaceScope (covering validateWorkspaceAccess too): reject personal keys with 403 when the workspace has allowPersonalApiKeys=false. checkWorkspaceScope becomes async; all v1 route callsites updated to await it. * fix(billing): close usage-cap admission race with atomic reservation The server-side usage-limit gate read already-recorded cost, but cost is only written when an execution finishes. A burst of concurrent executions all observed the same pre-burst usage, all passed the cap, and all ran — collectively spending far past the limit before any cost landed in the ledger (free-tier abuse / hard-cap defeat). manual/chat triggers also skip rate limiting, removing the only throttle. Add an atomic check-then-reserve admission step (Redis Lua) that bounds in-flight, un-costed executions per billing entity by both a per-plan concurrency cap and remaining usage headroom, so recordedUsage + reservedSlots * estimate <= limit always holds. The slot is released at execution completion via LoggingSession (skipped on pause; TTL self-heals crashes). Runs for all trigger types, covering the previously-unthrottled manual/chat paths. Fails open when billing is disabled or Redis is unavailable, matching the rate limiter — a Redis blip can't turn into an execution outage, and the recorded-usage gate still runs. * fix(workflows): validate folderId belongs to workflow's workspace on create/update/reorder Reject a folderId that references a folder in a different workspace (or an archived/non-existent folder) before writing it to workflow.folderId. Previously create, update, and reorder only checked workspace permission on the workflow and the folder's lock status, never that the folder lived in the workflow's own workspace, allowing a dangling cross-workspace folder reference. Adds isFolderInWorkspace/assertFolderInWorkspace + FolderNotFoundError to @sim/workflow-authz (mirroring assertTargetFolderMutable in the duplicate path), enforced in performCreateWorkflow, performUpdateWorkflow, and the reorder route. Invalid folders now return 400. * fix(folders): validate parentId against workspace on create/update/reorder Folder write endpoints accepted a caller-supplied parentId and persisted it without verifying the parent existed in the same workspace, and the create and reorder paths had no cycle guard. A workspace member with write access could reparent a folder to a foreign-workspace folder, a non-existent id, or (via reorder) into a cycle, hiding the folder and its workflows from all members. - performCreateFolder: reject self-parenting and validate the parent exists in the workspace and is not archived (mirrors the duplicate route). - performUpdateFolder: add the same workspace/archived parent check alongside the existing circular-reference guard. - folders/reorder: validate every target parent against the workspace, detect cycles in the resulting parent graph (catches batch cycles), and normalize falsy parentId to null to prevent orphaning. Adds tests for cross-workspace parent rejection and batch-cycle rejection. * chore(knowledge): drop non-TSDoc inline comments from chunks route * fix(webhooks): fail closed when HMAC signing secret is not configured Inbound webhook signature verification failed open for HMAC providers (GitHub, Intercom, Jira, JSM, Confluence, Cal.com, Notion, Greenhouse, Typeform, Fireflies, Circleback): when no signing secret was stored, verifyAuth returned null and the workflow executed on a fully attacker-controlled body. Reject these deliveries with 401 instead, matching the fail-closed Stripe/WhatsApp/Vercel providers. Run provider reachability/verification handshakes (Notion verification_token, Grain/Intercom ping) ahead of auth so the pre-secret setup handshake still completes — those return a canned 200 without executing the workflow, and real event payloads fall through to fail-closed verification. Update the trigger secret-field copy to state the secret is required for deliveries to be accepted (was misleadingly marked optional). * style(files): trim verbose inline comments on delete authorization fix * fix(auth): close account-enumeration oracle on email sign-up The custom before-hook pre-check threw a distinguishing 422/USER_ALREADY_EXISTS for already-registered emails, letting an unauthenticated attacker enumerate accounts — defeating better-auth's own OWASP enumeration protection (active under requireEmailVerification). Remove the pre-check and rely on better-auth's generic duplicate-sign-up response, wiring: - onExistingUserSignUp: notify the real account owner out-of-band, mirroring the privacy-preserving forget-password flow. - customSyntheticUser: include admin (role/banned/banReason/banExpires) and Stripe (stripeCustomerId, billing-gated) user fields so the fake response shape is byte-identical to a real new-user response. Adds an ExistingAccountEmail template + 'existing-account' subject. * style(tools): drop non-TSDoc inline comments from Grafana/Agiloft SSRF tools * chore(api): trim extraneous inline comments in v1 logs/files routes Remove a redundant size annotation and two verbose multi-line materialization comments whose intent is already clear from the code. Load-bearing comments (race-condition and key-translation notes) kept. * fix(billing): exclude table-cell dispatch from admission reservation Table-cell dispatch is row-bounded, async rate-limited, and already surfaces a graceful usage state. Applying the in-flight concurrency reservation there turned its 429 into a hard cell error on a normal >15-concurrent-cell run (only 402 was handled gracefully). Skip the reservation for that surface via a new skipConcurrencyReservation option (the usage-cost cap is still enforced), and tidy the reservation comments to TSDoc. * fix(chat): rate-limit and constant-time password auth for public chats Password-protected public chat (POST /api/chat/[identifier]) had no throttling on the password check and compared with a non-constant-time !==, allowing unlimited brute-force and per-character timing leaks. - Add per-IP rate limiting (10 / 15min) to the password branch of validateChatAuth, mirroring the OTP/SSO endpoints; return 429 with Retry-After. Only explicit unlock attempts consume tokens — message sends carry no password and ride the auth cookie. - Replace password !== decrypted with safeCompare. - Fails open on rate-limiter storage errors; no availability regression. * fix(security): cap JSON request body size and gate public chat endpoint The shared parseJsonBody helper (behind parseRequest, used by nearly every contract route) read request bodies with no size limit, buffering the full body into memory before validation. The unauthenticated public deployed-chat endpoint reached this sink with no admission gate, enabling an anonymous memory-exhaustion DoS. - parseRequest/parseJsonBody now enforce a byte cap via a size-limited stream read (content-length precheck + streamed cap), returning 413. Default is API_MAX_JSON_BODY_BYTES (50 MB), overridable per route via maxBodyBytes. Decoding uses TextDecoder to match request.json() BOM handling. - Public chat POST is wrapped with the admission gate (tryAdmit) and passes an explicit CHAT_MAX_REQUEST_BYTES (20 MB) cap. - Chat body contract gains .max() bounds on input, password, conversationId, file data/name/type, and files array length. - Admin bulk workspace import opts into a higher 100 MB cap to avoid regressing large multi-workflow imports. * fix(chat): rate-limit and constant-time password auth for public chats Password-protected public chat (POST /api/chat/[identifier]) had no throttling on the password check and compared with a non-constant-time !==, allowing unlimited brute-force and per-character timing leaks. - Add per-IP rate limiting (10 / 15min) to the password branch of validateChatAuth, mirroring the OTP/SSO endpoints; return 429 with Retry-After. Only explicit unlock attempts consume tokens — message sends carry no password and ride the auth cookie. - Replace password !== decrypted with safeCompare. - Fails open on rate-limiter storage errors; no availability regression. Reinstates the fix reverted by an intervening commit. * fix(billing): never block a lone execution on usage headroom The admission reservation tapered allowed concurrency by remaining usage headroom. With under one credit of headroom left (but not yet over the cap), floor(headroom / estimate) hit zero and rejected even a single, zero-concurrency execution — stricter than the recorded-usage gate, which would have allowed that last run, and with a misleading "too many concurrent executions" message. Floor the headroom term at 1 so a lone execution is governed only by the cost gate; concurrency above the first slot still tapers with headroom. * refactor(env): document workspace env masking, drop inline comments Extract the workspace-env value masking into a TSDoc-documented maskWorkspaceEnvForViewer helper and remove the redundant inline comments from the GET handler and its test. No behavior change. * refactor(env): convert PUT/DELETE authz comments to TSDoc Move the tiered-authorization rationale for the workspace env upsert and delete handlers into TSDoc blocks and drop the inline comments. No behavior change. * fix(telegram): keep legacy webhooks working via Telegram source-IP fallback The secret-token check rejected every webhook registered before secret_token support, breaking live triggers until re-saved. Fall back to verifying the request originates from Telegram's published webhook IP ranges when no secret is configured, so existing triggers keep firing with no re-save or migration while forged updates from arbitrary hosts are still rejected. Webhooks with a registered secret continue to use strict constant-time token verification. * fix(chat): restore constant-time password auth and IP rate limit A billing commit (ac565253a4) reverted the public-chat auth hardening as collateral, leaving HEAD with a timing-oracle password comparison (password !== decrypted) and no per-IP brute-force rate limit. Restore safeCompare and the password-attempt rate limiter, and re-add the 429 test. * revert(webhooks): undo trigger auth hardening pending compat plan Reverts the Telegram inbound-token verification (3ed97a440b, 41f133a9d7) and the HMAC fail-closed change (5b6cae9120). Production data shows ~79 live webhooks have no signing secret configured (63 GitHub, 9 Fireflies, 3 Jira, 2 Circleback, 1 Confluence, 1 Cal.com), so failing closed would 401 them. Restoring fail-open behavior until a backwards-compatible rollout (grandfather existing secretless webhooks / migration) is designed. Other security fixes on this branch are unaffected. * test(chat): make RateLimiter mock a constructable class The arrow-function mockImplementation form was not reliably constructable in the full suite run (`new RateLimiter()` threw "is not a constructor"), though it passed in isolation. Switch to the class-based mock used by the sibling OTP/speech route tests. * fix(billing): release admission slot on pre-execution aborts; cluster-safe release Addresses PR review on the usage-cap admission reservation: - Slot leak: the reservation taken at the end of preprocessing was only released when the LoggingSession finalized. The execute route's pre-execution exits (client cancel, workspace/API-key guards) returned without finalizing a session, leaking the slot until its TTL and wrongly throttling later runs. Release explicitly on those paths; executions that start are still released via session finalization. - Release is now cluster-safe: replaced the Lua script that rebuilt the in-flight key from the pointer value (a key not declared in KEYS, which silently breaks Redis Cluster slot routing) with discrete single-key GETDEL + ZREM commands. * improvement(files): log missing owner metadata distinctly on profile-picture delete deny Per PR review: when a profile-picture delete is denied, distinguish a missing owner record (no userId metadata) from a genuine ownership mismatch so the fail-closed denial is diagnosable. Behavior unchanged — both still deny. * fix(billing): release admission slot when async enqueue fails If queueing the background workflow job throws, no job runs and no LoggingSession finalizes, so the admission slot reserved during preprocessing would leak until its TTL. Release it before returning 500. * fix(api): make body-size caps NaN-safe and raise chat input/attachment limits - DEFAULT_MAX_JSON_BODY_BYTES and CHAT_MAX_REQUEST_BYTES now fall back to hardcoded defaults (50 MB / 220 MB) when the env value is missing or non-numeric, so a misconfig can't silently produce a NaN cap that never rejects. - Raise CHAT_MAX_REQUEST_BYTES default to 220 MB to cover 15 base64 file attachments, and MAX_CHAT_INPUT_CHARS to 1,000,000. - Minor: tidy use-inline-rename onSave type; drop two redundant test comments. * fix(hooks): restore void return in useInlineRename onSave type A prior commit changed onSave's return type from `void | Promise` to `undefined | Promise`, which broke the build: callbacks that return nothing (table-grid column rename, table header rename) infer a `void` return, which is not assignable to `undefined`. Restore the `void` union so both fire-and-forget and Promise-returning callbacks type-check. * fix(billing,api): release chat reservation slot on early exit; preserve 413 on oversized import - Chat route: preprocessExecution reserves a billing concurrency slot, but the post-preprocess early exits (missing workspaceId, execution-setup failure) returned without releasing it, leaking the slot until TTL and wrongly throttling later runs. Release explicitly on those paths (idempotent), mirroring the workflows execute route. - Admin import route: an oversized JSON body now returns the real 413 from parseJsonBody instead of being remapped to a 400; invalid JSON still 400s. * fix(icons): make Infisical icon black for contrast; regenerate docs The Infisical mark rendered near-white on its yellow block background and was barely visible; switch its fill from currentColor to #000000 (matching the hardcoded-fill pattern of sibling brand icons). Sync the docs icon copy and pick up a stale servicenow doc regeneration. * fix(billing): release reserved slot on execute-route 503 and setup throw After preprocessExecution reserves a billing concurrency slot, the streaming path could exit without releasing it: the 503 return when initializeExecutionStreamMeta fails, and any throw during stream setup (caught by the outer handler, which only returned 500). Both left the slot held until TTL, wrongly throttling unrelated runs. Release on the 503 path and in the outer catch (executionId hoisted so the catch can see it; release is idempotent and a no-op when no slot was reserved). * fix(icons): make Linkup icon black for contrast The Linkup mark rendered with currentColor (near-white on its block background); switch its fill to #000000 for legibility, matching the Infisical fix. Docs icon copy synced via generate-docs. * fix(billing): release reserved slot if inline async job never starts In the inline (single-process) async path, if jobQueue.startJob threw before executeWorkflowJob ran, no LoggingSession finalized and the reserved billing slot was held until TTL. Release it in the fire-and-forget catch (idempotent; a no-op when the job already finalized and released). The queued-worker path and all in-job outcomes already release via the job's LoggingSession finalize. --- apps/docs/components/icons.tsx | 4 +- apps/sim/app/api/chat/[identifier]/route.ts | 28 ++- apps/sim/app/api/chat/utils.test.ts | 35 +++ apps/sim/app/api/chat/utils.ts | 37 ++- apps/sim/app/api/files/authorization.test.ts | 69 +++++- apps/sim/app/api/files/authorization.ts | 79 ++++++- apps/sim/app/api/files/delete/route.test.ts | 15 ++ apps/sim/app/api/files/delete/route.ts | 23 +- .../sim/app/api/folders/reorder/route.test.ts | 124 +++++++++++ apps/sim/app/api/folders/reorder/route.ts | 61 ++++- apps/sim/app/api/folders/route.test.ts | 23 ++ .../documents/[documentId]/chunks/route.ts | 9 +- .../v1/admin/workspaces/[id]/import/route.ts | 13 +- apps/sim/app/api/v1/files/route.ts | 4 +- apps/sim/app/api/v1/logs/route.ts | 7 +- apps/sim/app/api/v1/middleware.ts | 28 ++- .../api/v1/tables/[tableId]/columns/route.ts | 6 +- apps/sim/app/api/v1/tables/[tableId]/route.ts | 4 +- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 6 +- .../app/api/v1/tables/[tableId]/rows/route.ts | 10 +- .../v1/tables/[tableId]/rows/upsert/route.ts | 2 +- .../app/api/workflows/[id]/execute/route.ts | 28 ++- apps/sim/app/api/workflows/[id]/route.ts | 8 +- apps/sim/app/api/workflows/reorder/route.ts | 9 +- apps/sim/app/api/workflows/route.ts | 3 +- .../workspaces/[id]/environment/route.test.ts | 143 ++++++++++++ .../api/workspaces/[id]/environment/route.ts | 69 +++++- .../background/workflow-column-execution.ts | 1 + .../emails/auth/existing-account-email.tsx | 46 ++++ apps/sim/components/emails/auth/index.ts | 1 + apps/sim/components/emails/render.ts | 5 + apps/sim/components/emails/subjects.ts | 3 + apps/sim/components/icons.tsx | 4 +- apps/sim/lib/api/contracts/chats.ts | 27 ++- apps/sim/lib/api/server/validation.ts | 76 ++++++- apps/sim/lib/auth/auth.ts | 78 +++++-- .../calculations/usage-reservation.test.ts | 152 +++++++++++++ .../billing/calculations/usage-reservation.ts | 210 ++++++++++++++++++ apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/execution/preprocessing.ts | 69 ++++++ .../sim/lib/logs/execution/logging-session.ts | 13 ++ .../orchestration/folder-lifecycle.ts | 43 ++++ .../orchestration/workflow-lifecycle.ts | 12 + apps/sim/tools/agiloft/attachment_info.ts | 3 +- apps/sim/tools/agiloft/create_record.ts | 3 +- apps/sim/tools/agiloft/delete_record.ts | 3 +- apps/sim/tools/agiloft/get_choice_line_id.ts | 3 +- apps/sim/tools/agiloft/lock_record.ts | 3 +- apps/sim/tools/agiloft/read_record.ts | 3 +- apps/sim/tools/agiloft/remove_attachment.ts | 3 +- apps/sim/tools/agiloft/saved_search.ts | 3 +- apps/sim/tools/agiloft/search_records.ts | 3 +- apps/sim/tools/agiloft/select_records.ts | 3 +- apps/sim/tools/agiloft/update_record.ts | 3 +- .../{utils.test.ts => utils.server.test.ts} | 96 +++++--- apps/sim/tools/agiloft/utils.server.ts | 53 +++++ apps/sim/tools/agiloft/utils.ts | 105 +-------- apps/sim/tools/grafana/update_alert_rule.ts | 30 +-- apps/sim/tools/grafana/update_dashboard.ts | 26 +-- .../testing/src/mocks/workflow-authz.mock.ts | 19 ++ packages/workflow-authz/src/index.ts | 46 ++++ 61 files changed, 1702 insertions(+), 295 deletions(-) create mode 100644 apps/sim/app/api/folders/reorder/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/environment/route.test.ts create mode 100644 apps/sim/components/emails/auth/existing-account-email.tsx create mode 100644 apps/sim/lib/billing/calculations/usage-reservation.test.ts create mode 100644 apps/sim/lib/billing/calculations/usage-reservation.ts rename apps/sim/tools/agiloft/{utils.test.ts => utils.server.test.ts} (51%) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index cdd2ab9401..106c6bc95e 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2492,7 +2492,7 @@ export function LinkupIcon(props: SVGProps) { ) @@ -4967,7 +4967,7 @@ export function InfisicalIcon(props: SVGProps) { ) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 7a4a12d575..330ece6885 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -6,6 +6,9 @@ import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deployedChatPostContract } from '@/lib/api/contracts/chats' import { parseRequest } from '@/lib/api/server' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' +import { env } from '@/lib/core/config/env' import { validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -40,13 +43,21 @@ function toChatConfigResponse(deployment: ChatConfigSource) { export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024 + export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { const { identifier } = await context.params const requestId = generateRequestId() + const ticket = tryAdmit() + if (!ticket) { + return admissionRejectedResponse() + } + try { const parsed = await parseRequest(deployedChatPostContract, request, context, { + maxBodyBytes: CHAT_MAX_REQUEST_BYTES, validationErrorResponse: (err) => { const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ') return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR') @@ -125,7 +136,14 @@ export const POST = withRouteHandler( const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) if (!authResult.authorized) { - return createErrorResponse(authResult.error || 'Authentication required', 401) + const response = createErrorResponse( + authResult.error || 'Authentication required', + authResult.status || 401 + ) + if (authResult.status === 429 && authResult.retryAfterMs !== undefined) { + response.headers.set('Retry-After', String(Math.ceil(authResult.retryAfterMs / 1000))) + } + return response } const { input, password, email, conversationId, files } = parsedBody @@ -177,6 +195,9 @@ export const POST = withRouteHandler( const workspaceId = workflowRecord?.workspaceId if (!workspaceId) { logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) + // preprocessExecution reserved a billing concurrency slot; release it on + // this early exit since no LoggingSession will finalize to free it. + await releaseExecutionSlot(executionId) return createErrorResponse('Workflow has no associated workspace', 500) } @@ -283,11 +304,16 @@ export const POST = withRouteHandler( return streamResponse } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) + // Setup failed before the workflow stream took over slot release; + // free the reserved billing slot (idempotent if already released). + await releaseExecutionSlot(executionId) return createErrorResponse(error.message || 'Failed to process request', 500) } } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) return createErrorResponse(error.message || 'Failed to process request', 500) + } finally { + ticket.release() } } ) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 86e46340a9..337310d630 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -19,6 +19,7 @@ const { mockSetDeploymentAuthCookie, mockIsEmailAllowed, mockGetSession, + mockCheckRateLimitDirect, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), mockMergeSubBlockValues: vi.fn().mockReturnValue({}), @@ -26,6 +27,13 @@ const { mockSetDeploymentAuthCookie: vi.fn(), mockIsEmailAllowed: vi.fn(), mockGetSession: vi.fn(), + mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }), +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockCheckRateLimitDirect + }, })) vi.mock('@/lib/auth', () => ({ @@ -149,6 +157,7 @@ describe('Chat API Utils', () => { describe('Chat auth validation', () => { beforeEach(() => { mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' }) + mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) }) it('should allow access to public chats', async () => { @@ -235,6 +244,32 @@ describe('Chat API Utils', () => { expect(result.error).toBe('Invalid password') }) + it('should return 429 when the password attempt rate limit is exceeded', async () => { + mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 60_000 }) + + const deployment = { + id: 'chat-id', + authType: 'password', + password: 'encrypted-password', + } + + const mockRequest = { + method: 'POST', + cookies: { + get: vi.fn().mockReturnValue(null), + }, + } as any + + const result = await validateChatAuth('request-id', deployment, mockRequest, { + password: 'any-guess', + }) + + expect(result.authorized).toBe(false) + expect(result.status).toBe(429) + expect(result.retryAfterMs).toBe(60_000) + expect(decryptSecret).not.toHaveBeenCalled() + }) + it('should request email auth for email-protected chats', async () => { const deployment = { id: 'chat-id', diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 5a3d0750e8..70e4a657ac 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,18 +1,34 @@ import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' import { isEmailAllowed, setDeploymentAuthCookie, validateAuthToken, } from '@/lib/core/security/deployment' import { decryptSecret } from '@/lib/core/security/encryption' +import { getClientIp } from '@/lib/core/utils/request' const logger = createLogger('ChatAuthUtils') +const rateLimiter = new RateLimiter() + +/** + * Throttles unauthenticated password guesses per client IP against a single + * deployment, mirroring the OTP/SSO IP limits. + */ +const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 15 * 60_000, +} + export function setChatAuthCookie( response: NextResponse, chatId: string, @@ -88,7 +104,7 @@ export async function validateChatAuth( deployment: any, request: NextRequest, parsedBody?: any -): Promise<{ authorized: boolean; error?: string }> { +): Promise<{ authorized: boolean; error?: string; status?: number; retryAfterMs?: number }> { const authType = deployment.authType || 'public' if (authType === 'public') { @@ -129,8 +145,25 @@ export async function validateChatAuth( return { authorized: false, error: 'Authentication configuration error' } } + const ip = getClientIp(request) + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `chat-password:ip:${deployment.id}:${ip}`, + PASSWORD_IP_RATE_LIMIT + ) + if (!ipRateLimit.allowed) { + logger.warn( + `[${requestId}] Password attempt IP rate limit exceeded for chat ${deployment.id} from ${ip}` + ) + return { + authorized: false, + error: 'Too many attempts. Please try again later.', + status: 429, + retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs, + } + } + const { decrypted } = await decryptSecret(deployment.password) - if (password !== decrypted) { + if (!safeCompare(password, decrypted)) { return { authorized: false, error: 'Invalid password' } } diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts index 463a1bdf0c..4409108eac 100644 --- a/apps/sim/app/api/files/authorization.test.ts +++ b/apps/sim/app/api/files/authorization.test.ts @@ -12,15 +12,18 @@ import { dbChainMock, dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetFileMetadataByKey, mockGetUserEntityPermissions } = vi.hoisted(() => ({ - mockGetFileMetadataByKey: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), -})) +const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetadata } = vi.hoisted( + () => ({ + mockGetFileMetadataByKey: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetFileMetadata: vi.fn(), + }) +) vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/uploads', () => ({ - getFileMetadata: vi.fn(), + getFileMetadata: mockGetFileMetadata, })) vi.mock('@/lib/uploads/config', () => ({ @@ -151,3 +154,59 @@ describe('verifyKBFileWriteAccess (binding-only delete authorization)', () => { await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false) }) }) + +describe('public-context access (profile-pictures / og-images / workspace-logos)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function read(cloudKey: string, context: 'profile-pictures' | 'og-images' | 'workspace-logos') { + return verifyFileAccess(cloudKey, USER_ID, undefined, context, false) + } + + function write(cloudKey: string, context: 'profile-pictures' | 'og-images' | 'workspace-logos') { + return verifyFileAccess(cloudKey, USER_ID, undefined, context, false, { requireWrite: true }) + } + + it('grants public reads without any ownership check', async () => { + await expect(read('og-images/banner.png', 'og-images')).resolves.toBe(true) + await expect(read('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(true) + await expect(read('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(true) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + expect(mockGetFileMetadata).not.toHaveBeenCalled() + }) + + it('denies a cross-tenant delete that names a workspace key under a public context', async () => { + await expect(write('workspace/victim-ws/123-report.pdf', 'og-images')).resolves.toBe(false) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) + + it('grants a profile-picture delete only to the owning user', async () => { + mockGetFileMetadata.mockResolvedValue({ userId: USER_ID }) + await expect(write('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(true) + }) + + it('denies a profile-picture delete for a non-owner', async () => { + mockGetFileMetadata.mockResolvedValue({ userId: 'other-user' }) + await expect(write('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(false) + }) + + it('grants a workspace-logo delete to write/admin on the owning workspace', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1' }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(true) + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER_ID, 'workspace', 'ws-1') + }) + + it('denies a workspace-logo delete for a non-member of the owning workspace', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'victim-ws' }) + mockGetUserEntityPermissions.mockResolvedValue(null) + await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(false) + }) + + it('denies a workspace-logo delete when no ownership binding exists', async () => { + mockGetFileMetadataByKey.mockResolvedValue(null) + await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(false) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index b2cc73c429..7b96031baf 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -144,12 +144,15 @@ export async function verifyFileAccess( // Infer context from key if not explicitly provided const inferredContext = context || inferContextFromKey(cloudKey) - // 0. Public contexts: profile pictures, OG images, and workspace logos are publicly accessible + // 0. Public contexts: profile pictures, OG images, and workspace logos are world-readable, so reads short-circuit; writes require proof of ownership if ( inferredContext === 'profile-pictures' || inferredContext === 'og-images' || inferredContext === 'workspace-logos' ) { + if (requireWrite) { + return await verifyPublicAssetWriteAccess(cloudKey, userId, inferredContext, customConfig) + } logger.info('Public file access allowed', { cloudKey, context: inferredContext }) return true } @@ -267,6 +270,80 @@ async function verifyWorkspaceFileAccess( } } +/** + * Authorize a destructive operation (delete) on a "public" asset context: + * `profile-pictures`, `workspace-logos`, or `og-images`. These contexts are + * world-readable, so {@link verifyFileAccess} short-circuits reads — but a write + * must prove ownership of the user/workspace the object belongs to and never + * short-circuit to `true`. + * + * - `workspace-logos` carry a trusted `workspace_files` binding written at upload + * time; require write/admin on the owning workspace. + * - `profile-pictures` are owned by a single user, recorded in the storage + * object's `userId` metadata at upload time; require an exact owner match. + * - `og-images` are platform/blog assets with no per-user or per-workspace owner + * and no user-facing delete path; always deny. + */ +async function verifyPublicAssetWriteAccess( + cloudKey: string, + userId: string, + context: 'profile-pictures' | 'og-images' | 'workspace-logos', + customConfig?: StorageConfig +): Promise { + try { + if (context === 'workspace-logos') { + const binding = await getFileMetadataByKey(cloudKey, 'workspace-logos') + if (!binding?.workspaceId) { + logger.warn('workspace-logos delete denied: no ownership binding', { userId, cloudKey }) + return false + } + const permission = await getUserEntityPermissions(userId, 'workspace', binding.workspaceId) + if (!workspacePermissionSatisfies(permission, true)) { + logger.warn('workspace-logos delete denied: write/admin required on owner workspace', { + userId, + workspaceId: binding.workspaceId, + cloudKey, + }) + return false + } + return true + } + + if (context === 'profile-pictures') { + const config: StorageConfig = customConfig || {} + const metadata = await getFileMetadata(cloudKey, config) + if (metadata.userId && metadata.userId === userId) { + return true + } + // Fail closed when the owner cannot be established. Distinguish a missing + // owner record (no `userId` metadata — e.g. an object predating owner + // tagging) from a genuine ownership mismatch so the denial is diagnosable. + if (!metadata.userId) { + logger.warn( + 'profile-pictures delete denied: file has no owner metadata to verify against', + { + userId, + cloudKey, + } + ) + } else { + logger.warn('profile-pictures delete denied: caller does not own the file', { + userId, + fileUserId: metadata.userId, + cloudKey, + }) + } + return false + } + + logger.warn('og-images delete denied: no user-facing delete path', { userId, cloudKey }) + return false + } catch (error) { + logger.error('Error verifying public asset write access', { cloudKey, userId, error }) + return false + } +} + /** * Verify access to execution files * Modern format: execution/workspace_id/workflow_id/execution_id/filename diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index eed0774858..d843b57365 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -198,4 +198,19 @@ describe('File Delete API Route', () => { expect(data).toHaveProperty('error', 'InvalidRequestError') expect(data).toHaveProperty('message', 'No file path provided') }) + + it('rejects a client context that disagrees with the key prefix', async () => { + const req = createMockRequest('POST', { + filePath: '/api/files/serve/s3/workspace/victim-ws/1234-report.pdf', + context: 'og-images', + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toHaveProperty('error', 'InvalidRequestError') + expect(mocks.mockVerifyFileAccess).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockDeleteFile).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index c483faa3c6..34903aa22e 100644 --- a/apps/sim/app/api/files/delete/route.ts +++ b/apps/sim/app/api/files/delete/route.ts @@ -62,23 +62,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const key = extractStorageKeyFromPath(filePath) - const storageContext: StorageContext = context || inferContextFromKey(key) + // Derive context from the trusted key prefix, never the client-supplied value; a supplied context must agree with the key. + const storageContext: StorageContext = inferContextFromKey(key) + if (context && context !== storageContext) { + logger.warn('File delete context mismatch', { key, context, inferred: storageContext }) + throw new InvalidRequestError(`Provided context "${context}" does not match the file key`) + } - // Deletes require write/admin on the owning workspace (owner-scoped files - // like copilot/regular uploads still authorize by ownership). KB deletes - // are binding-only and never use the transitional read fallback that file - // serving allows. + // Deletes require write/admin on the owning workspace; KB deletes are binding-only with no read fallback. const hasAccess = storageContext === 'knowledge-base' ? await verifyKBFileWriteAccess(key, userId) - : await verifyFileAccess( - key, - userId, - undefined, // customConfig - storageContext, // context - !hasCloudStorage(), // isLocal - { requireWrite: true } - ) + : await verifyFileAccess(key, userId, undefined, storageContext, !hasCloudStorage(), { + requireWrite: true, + }) if (!hasAccess) { logger.warn('Unauthorized file delete attempt', { userId, key, context: storageContext }) diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts new file mode 100644 index 0000000000..61b56e6fa5 --- /dev/null +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -0,0 +1,124 @@ +/** + * Tests for the folder reorder API route. + * + * @vitest-environment node + */ +import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing' +import { drizzleOrmMock } from '@sim/testing/mocks' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + }, +})) + +const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions + +vi.mock('drizzle-orm', () => drizzleOrmMock) +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue(mockLogger), + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) + +import { db } from '@sim/db' +import { PUT } from '@/app/api/folders/reorder/route' + +const mockDb = db as any + +describe('PUT /api/folders/reorder', () => { + const mockFrom = vi.fn() + const mockWhere = vi.fn() + const mockTxUpdate = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + + mockDb.select.mockReturnValue({ from: mockFrom }) + mockFrom.mockReturnValue({ where: mockWhere }) + + mockTxUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), + }) + mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => + cb({ update: mockTxUpdate }) + ) + }) + + it('reorders folders when updates are valid', async () => { + mockWhere + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + .mockReturnValueOnce([{ id: 'folder-1', parentId: null }]) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data).toMatchObject({ success: true, updated: 1 }) + }) + + it('rejects a parentId that belongs to another workspace', async () => { + mockWhere + .mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }]) + .mockReturnValueOnce([{ id: 'foreign', workspaceId: 'workspace-OTHER', archivedAt: null }]) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [{ id: 'folder-1', sortOrder: 0, parentId: 'foreign' }], + }) + + const response = await PUT(req) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Parent folder not found') + expect(mockDb.transaction).not.toHaveBeenCalled() + }) + + it('rejects a batch that would form a cycle', async () => { + mockWhere + .mockReturnValueOnce([ + { id: 'A', workspaceId: 'workspace-123' }, + { id: 'B', workspaceId: 'workspace-123' }, + ]) + .mockReturnValueOnce([ + { id: 'A', workspaceId: 'workspace-123', archivedAt: null }, + { id: 'B', workspaceId: 'workspace-123', archivedAt: null }, + ]) + .mockReturnValueOnce([ + { id: 'A', parentId: null }, + { id: 'B', parentId: null }, + ]) + + const req = createMockRequest('PUT', { + workspaceId: 'workspace-123', + updates: [ + { id: 'A', sortOrder: 0, parentId: 'B' }, + { id: 'B', sortOrder: 0, parentId: 'A' }, + ], + }) + + const response = await PUT(req) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Cannot create circular folder reference') + expect(mockDb.transaction).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 87222b75b8..274e2bc778 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -51,6 +51,65 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 }) } + const targetParentIds = Array.from( + new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id))) + ) + + if (targetParentIds.length > 0) { + const parentFolders = await db + .select({ + id: workflowFolder.id, + workspaceId: workflowFolder.workspaceId, + archivedAt: workflowFolder.archivedAt, + }) + .from(workflowFolder) + .where(inArray(workflowFolder.id, targetParentIds)) + + const validParentIds = new Set( + parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id) + ) + + for (const update of validUpdates) { + if (!update.parentId) continue + if (update.parentId === update.id) { + return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) + } + if (!validParentIds.has(update.parentId)) { + return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 }) + } + } + } + + const workspaceFolders = await db + .select({ id: workflowFolder.id, parentId: workflowFolder.parentId }) + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, workspaceId)) + + const parentById = new Map() + for (const folder of workspaceFolders) { + parentById.set(folder.id, folder.parentId) + } + for (const update of validUpdates) { + if (update.parentId !== undefined) { + parentById.set(update.id, update.parentId || null) + } + } + + for (const update of validUpdates) { + const visited = new Set() + let cursor: string | null = update.id + while (cursor) { + if (visited.has(cursor)) { + return NextResponse.json( + { error: 'Cannot create circular folder reference' }, + { status: 400 } + ) + } + visited.add(cursor) + cursor = parentById.get(cursor) ?? null + } + } + for (const update of validUpdates) { await assertFolderMutable(update.id) if (update.parentId !== undefined) { @@ -65,7 +124,7 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { updatedAt: new Date(), } if (update.parentId !== undefined) { - updateData.parentId = update.parentId + updateData.parentId = update.parentId || null } await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id)) } diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index a2e145bf1e..a72c0a7bbf 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -127,6 +127,7 @@ describe('Folders API Route', () => { const mockSelect = mockDb.select const mockFrom = vi.fn() const mockWhere = vi.fn() + const mockLimit = vi.fn() const mockOrderBy = vi.fn() const mockInsert = mockDb.insert const mockValues = vi.fn() @@ -152,9 +153,12 @@ describe('Folders API Route', () => { mockFrom.mockReturnValue({ where: mockWhere }) const defaultWhereResult = [] as Array> & { orderBy: typeof mockOrderBy + limit: typeof mockLimit } defaultWhereResult.orderBy = mockOrderBy + defaultWhereResult.limit = mockLimit mockWhere.mockReturnValue(defaultWhereResult) + mockLimit.mockReturnValue([]) mockOrderBy.mockReturnValue(mockFolders) mockInsert.mockReturnValue({ values: mockValues }) @@ -367,6 +371,7 @@ describe('Folders API Route', () => { insertResult: [{ ...mockFolders[1] }], }) ) + mockLimit.mockReturnValueOnce([{ ...mockFolders[0] }]) mockReturning.mockReturnValueOnce([{ ...mockFolders[1] }]) const req = createMockRequest('POST', { @@ -385,6 +390,24 @@ describe('Folders API Route', () => { }) }) + it('should reject a parentId that does not resolve to a folder in the workspace', async () => { + mockAuthenticatedUser() + + mockLimit.mockReturnValueOnce([]) + + const req = createMockRequest('POST', { + name: 'Subfolder', + workspaceId: 'workspace-123', + parentId: 'folder-in-other-workspace', + }) + + const response = await POST(req) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Parent folder not found') + }) + it('should return 401 for unauthenticated requests', async () => { mockUnauthenticated() diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index c977a8c5ed..8e80e41f6e 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -160,8 +160,6 @@ export const POST = withRouteHandler( ) } - // Allow manual chunk creation even if document is not fully processed - // but it should exist and not be in failed state if (doc.processingStatus === 'failed') { logger.warn(`[${requestId}] Document ${documentId} is in failed state, cannot add chunks`) return NextResponse.json({ error: 'Cannot add chunks to failed document' }, { status: 400 }) @@ -171,7 +169,6 @@ export const POST = withRouteHandler( const validatedData = createChunkBodySchema.parse(searchParams) const docTags = { - // Text tags (7 slots) tag1: doc.tag1 ?? null, tag2: doc.tag2 ?? null, tag3: doc.tag3 ?? null, @@ -179,16 +176,13 @@ export const POST = withRouteHandler( tag5: doc.tag5 ?? null, tag6: doc.tag6 ?? null, tag7: doc.tag7 ?? null, - // Number tags (5 slots) number1: doc.number1 ?? null, number2: doc.number2 ?? null, number3: doc.number3 ?? null, number4: doc.number4 ?? null, number5: doc.number5 ?? null, - // Date tags (2 slots) date1: doc.date1 ?? null, date2: doc.date2 ?? null, - // Boolean tags (3 slots) boolean1: doc.boolean1 ?? null, boolean2: doc.boolean2 ?? null, boolean3: doc.boolean3 ?? null, @@ -215,7 +209,6 @@ export const POST = withRouteHandler( logger.warn(`[${requestId}] Failed to calculate cost for chunk upload`, { error: getErrorMessage(error, 'Unknown error'), }) - // Continue without cost information rather than failing the upload } return NextResponse.json({ @@ -274,7 +267,7 @@ export const PATCH = withRouteHandler( } const userId = auth.userId - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) + const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) if (!accessCheck.hasAccess) { if (accessCheck.notFound) { diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index ae73a27074..7bbf9a5ea0 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -59,6 +59,12 @@ import type { const logger = createLogger('AdminWorkspaceImportAPI') +/** + * Body cap for admin bulk workflow imports, which can carry many serialized + * workflows and legitimately exceed the default contract-route limit. + */ +const ADMIN_IMPORT_MAX_BODY_BYTES = 100 * 1024 * 1024 + interface RouteParams { id: string } @@ -88,10 +94,13 @@ export const POST = withRouteHandler( let workflowsToImport: ParsedWorkflow[] = [] if (contentType.includes('application/json')) { - const rawBody = await parseJsonBody(request) + const rawBody = await parseJsonBody(request, 'response', ADMIN_IMPORT_MAX_BODY_BYTES) if (!rawBody.success) { - return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }') + // Preserve the 413 for an oversized body; only invalid JSON maps to 400. + return rawBody.reason === 'too_large' + ? rawBody.response + : badRequestResponse('Invalid JSON body. Expected { workflows: [...] }') } const validation = adminV1WorkspaceImportBodySchema.safeParse(rawBody.data) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 06d965ac02..9e573a126f 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -29,7 +29,7 @@ const logger = createLogger('V1FilesAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB +const MAX_FILE_SIZE = 100 * 1024 * 1024 const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 /** GET /api/v1/files — List all files in a workspace. */ @@ -117,7 +117,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const { workspaceId } = formFieldsResult.data - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError if (!file) { diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index c85031e3cc..dd20072dc0 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -68,7 +68,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const params = parsed.data.query - const scopeError = checkWorkspaceScope(rateLimit, params.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, params.workspaceId) if (scopeError) return scopeError logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, { @@ -147,8 +147,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - // Only materialize externalized execution data when the response actually - // needs it (details=full + finalOutput/traceSpans requested). const needsMaterialize = params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) @@ -179,9 +177,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return result } - // Only run the bounded-concurrency materialization when the response actually - // needs object-storage reads; otherwise a plain synchronous map avoids the - // per-row worker/promise overhead. const formattedLogs = needsMaterialize ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { const result = buildBase(log) diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 92aa72eb34..6472cbb1f7 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -5,6 +5,7 @@ import type { SubscriptionPlan } from '@/lib/core/rate-limiter' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' import { generateRequestId } from '@/lib/core/utils/request' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' import { authenticateV1Request } from '@/app/api/v1/auth' const logger = createLogger('V1Middleware') @@ -152,11 +153,19 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { ) } -/** Verify that a workspace-scoped API key is only used for its own workspace. */ -export function checkWorkspaceScope( +/** + * Verify that the API key is allowed to access the requested workspace. + * + * Enforces two policies: + * - A workspace-scoped key may only target its own workspace. + * - A personal key is rejected when the workspace has disabled personal API + * keys (`allowPersonalApiKeys = false`), matching the workflow-execution + * surface in `app/api/workflows/middleware.ts`. + */ +export async function checkWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string -): NextResponse | null { +): Promise { if ( rateLimit.keyType === 'workspace' && rateLimit.workspaceId && @@ -167,6 +176,17 @@ export function checkWorkspaceScope( { status: 403 } ) } + + if (rateLimit.keyType === 'personal') { + const settings = await getWorkspaceBillingSettings(requestedWorkspaceId) + if (!settings?.allowPersonalApiKeys) { + return NextResponse.json( + { error: 'Personal API keys are not allowed for this workspace' }, + { status: 403 } + ) + } + } + return null } @@ -180,7 +200,7 @@ export async function validateWorkspaceAccess( workspaceId: string, level: 'read' | 'write' = 'read' ): Promise { - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index 657b448720..0eeebfb99c 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -49,7 +49,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'write') @@ -116,7 +116,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'write') @@ -224,7 +224,7 @@ export const DELETE = withRouteHandler( const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'write') diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index bf9c38b406..a64831c857 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -51,7 +51,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const { workspaceId } = parsed.data.query - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'read') @@ -123,7 +123,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab const { tableId } = parsed.data.params const { workspaceId } = parsed.data.query - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'write') diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index 122603b713..4aa1d85f93 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -55,7 +55,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou const { tableId, rowId } = parsed.data.params const { workspaceId } = parsed.data.query - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'read') @@ -124,7 +124,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR const { tableId, rowId } = parsed.data.params const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'write') @@ -221,7 +221,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row const { tableId, rowId } = parsed.data.params const { workspaceId } = parsed.data.query - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'write') diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 28c4e209cd..ecceb41b1e 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -149,7 +149,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const validated = parsed.data.query - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const accessResult = await checkAccess(tableId, userId, 'read') @@ -229,14 +229,14 @@ export const POST = withRouteHandler( const { tableId } = parsed.data.params if ('rows' in parsed.data.body) { const batchValidated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, batchValidated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId) if (scopeError) return scopeError return handleBatchInsert(requestId, tableId, batchValidated, userId) } const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const accessResult = await checkAccess(tableId, userId, 'write') @@ -321,7 +321,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const accessResult = await checkAccess(tableId, userId, 'write') @@ -416,7 +416,7 @@ export const DELETE = withRouteHandler( const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const accessResult = await checkAccess(tableId, userId, 'write') diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 2258754602..285cd6d1e3 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -45,7 +45,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser const { tableId } = parsed.data.params const validated = parsed.data.body - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return scopeError const result = await checkAccess(tableId, userId, 'write') diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 800d4bd687..c8e89c259b 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { executeWorkflowBodySchema } from '@/lib/api/contracts/workflows' import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { @@ -303,6 +304,11 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise { for (const update of validUpdates) { await assertWorkflowMutable(update.id) if (update.folderId !== undefined) { + await assertFolderInWorkspace(update.folderId, workspaceId) await assertFolderMutable(update.folderId) } } @@ -82,7 +85,11 @@ export const PUT = withRouteHandler(async (req: NextRequest) => { return NextResponse.json({ success: true, updated: validUpdates.length }) } catch (error) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + if ( + error instanceof WorkflowLockedError || + error instanceof FolderLockedError || + error instanceof FolderNotFoundError + ) { return NextResponse.json({ error: error.message }, { status: error.status }) } diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index b1437de78a..6a8738869f 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -190,7 +190,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) if (!result.success || !result.workflow) { - const status = result.errorCode === 'conflict' ? 409 : 500 + const status = + result.errorCode === 'conflict' ? 409 : result.errorCode === 'validation' ? 400 : 500 return NextResponse.json({ error: result.error }, { status }) } diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts new file mode 100644 index 0000000000..6ad6192159 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockGetWorkspaceById, + mockGetUserEntityPermissions, + mockGetPersonalAndWorkspaceEnv, + mockGetWorkspaceEnvKeyAdminAccess, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetWorkspaceById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetPersonalAndWorkspaceEnv: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceById: mockGetWorkspaceById, + getUserEntityPermissions: mockGetUserEntityPermissions, +})) + +vi.mock('@/lib/environment/utils', () => ({ + getPersonalAndWorkspaceEnv: mockGetPersonalAndWorkspaceEnv, + invalidateEffectiveDecryptedEnvCache: vi.fn(), +})) + +vi.mock('@/lib/credentials/environment', () => ({ + getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, + createWorkspaceEnvCredentials: vi.fn(), + deleteWorkspaceEnvCredentials: vi.fn(), +})) + +import { GET } from '@/app/api/workspaces/[id]/environment/route' + +const WORKSPACE_ID = 'ws-1' + +function buildParams() { + return { params: Promise.resolve({ id: WORKSPACE_ID }) } +} + +async function callGet() { + const request = createMockRequest('GET') + const response = await GET(request, buildParams()) + return { status: response.status, body: await response.json() } +} + +describe('GET /api/workspaces/[id]/environment', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'u-1' } }) + mockGetWorkspaceById.mockResolvedValue({ id: WORKSPACE_ID }) + mockGetPersonalAndWorkspaceEnv.mockResolvedValue({ + workspaceDecrypted: { OPENAI_API_KEY: 'sk-secret', DATABASE_URL: 'postgres://secret' }, + personalDecrypted: { PERSONAL: { value: 'p' } }, + conflicts: [], + }) + }) + + it('returns 401 when the caller has no workspace permission', async () => { + mockGetUserEntityPermissions.mockResolvedValue(null) + + const { status, body } = await callGet() + + expect(status).toBe(401) + expect(body.error).toBe('Unauthorized') + expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled() + }) + + it('masks workspace secret values for a read-only member', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']), + }) + + const { status, body } = await callGet() + + expect(status).toBe(200) + expect(Object.keys(body.data.workspace).sort()).toEqual(['DATABASE_URL', 'OPENAI_API_KEY']) + expect(body.data.workspace.OPENAI_API_KEY).toBe('') + expect(body.data.workspace.DATABASE_URL).toBe('') + }) + + it('reveals only the workspace values the caller is a credential admin of', async () => { + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(['OPENAI_API_KEY']), + knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']), + }) + + const { body } = await callGet() + + expect(body.data.workspace.OPENAI_API_KEY).toBe('sk-secret') + expect(body.data.workspace.DATABASE_URL).toBe('') + }) + + it('reveals legacy keys (no per-secret ACL) only to workspace admins', async () => { + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(), + }) + + const { body } = await callGet() + + expect(body.data.workspace.OPENAI_API_KEY).toBe('sk-secret') + expect(body.data.workspace.DATABASE_URL).toBe('postgres://secret') + }) + + it('does not reveal legacy keys to a non-admin member', async () => { + mockGetUserEntityPermissions.mockResolvedValue('write') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(), + }) + + const { body } = await callGet() + + expect(body.data.workspace.OPENAI_API_KEY).toBe('') + expect(body.data.workspace.DATABASE_URL).toBe('') + }) + + it('always returns personal values untouched', async () => { + mockGetUserEntityPermissions.mockResolvedValue('read') + mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ + adminKeys: new Set(), + knownKeys: new Set(['OPENAI_API_KEY', 'DATABASE_URL']), + }) + + const { body } = await callGet() + + expect(body.data.personal).toEqual({ PERSONAL: { value: 'p' } }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index cfcdf8a77d..c32065c49d 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -25,10 +25,49 @@ import { invalidateEffectiveDecryptedEnvCache, } from '@/lib/environment/utils' import { captureServerEvent } from '@/lib/posthog/server' -import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' +import { + getUserEntityPermissions, + getWorkspaceById, + type PermissionType, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceEnvironmentAPI') +/** + * Restricts decrypted workspace env values to administrators. Members (including + * read-only) receive the variable names with empty values so editor autocomplete + * and conflict detection keep working without leaking secret values. A value is + * revealed when the caller is a credential admin of that key, or — for legacy + * keys predating per-secret ACLs — when they hold workspace `admin` permission. + * Mirrors the per-key edit gating in PUT/DELETE: if you can administer a secret, + * you can read it. + */ +async function maskWorkspaceEnvForViewer({ + workspaceDecrypted, + workspaceId, + userId, + permission, +}: { + workspaceDecrypted: Record + workspaceId: string + userId: string + permission: PermissionType +}): Promise> { + const workspaceKeys = Object.keys(workspaceDecrypted) + const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({ + workspaceId, + envKeys: workspaceKeys, + userId, + }) + + const masked: Record = {} + for (const key of workspaceKeys) { + const canViewValue = adminKeys.has(key) || (!knownKeys.has(key) && permission === 'admin') + masked[key] = canViewValue ? workspaceDecrypted[key] : '' + } + return masked +} + export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -43,13 +82,11 @@ export const GET = withRouteHandler( const userId = session.user.id - // Validate workspace exists const ws = await getWorkspaceById(workspaceId) if (!ws) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } - // Require any permission to read const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permission) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -60,10 +97,17 @@ export const GET = withRouteHandler( workspaceId ) + const workspace = await maskWorkspaceEnvForViewer({ + workspaceDecrypted, + workspaceId, + userId, + permission, + }) + return NextResponse.json( { data: { - workspace: workspaceDecrypted, + workspace, personal: personalDecrypted, conflicts, }, @@ -80,6 +124,12 @@ export const GET = withRouteHandler( } ) +/** + * Upserts workspace environment variables under tiered authorization: the caller + * needs some workspace permission, editing an existing secret requires + * credential-admin on that key, and adding a brand-new key requires workspace + * write/admin. + */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -98,9 +148,6 @@ export const PUT = withRouteHandler( if (!parsed.success) return parsed.response const { variables } = parsed.data.body - // Caller must have workspace access at all (blocks non-member writes); - // per-key gating below then requires credential-admin to edit existing - // secrets and write/admin to add brand-new keys. const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permission) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) @@ -221,6 +268,11 @@ export const PUT = withRouteHandler( } ) +/** + * Removes workspace environment variables. Deleting an existing secret requires + * credential-admin on that key; a key with no credential yet (legacy) falls back + * to workspace write/admin. + */ export const DELETE = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -239,9 +291,6 @@ export const DELETE = withRouteHandler( if (!parsed.success) return parsed.response const { keys } = parsed.data.body - // Caller must have workspace access at all; deleting an existing secret then - // requires being its credential admin, while a key with no credential yet - // (legacy) falls back to workspace write/admin. const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permission) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 44c87461d8..bba438c56d 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -511,6 +511,7 @@ async function runWorkflowAndWriteTerminal( triggerType: 'workflow', checkDeployment: false, checkRateLimit: false, + skipConcurrencyReservation: true, logPreprocessingErrors: false, }) if (!preprocess.success) { diff --git a/apps/sim/components/emails/auth/existing-account-email.tsx b/apps/sim/components/emails/auth/existing-account-email.tsx new file mode 100644 index 0000000000..0fc8f7e752 --- /dev/null +++ b/apps/sim/components/emails/auth/existing-account-email.tsx @@ -0,0 +1,46 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface ExistingAccountEmailProps { + username?: string +} + +/** + * Sent out-of-band when someone attempts to sign up with an email that already + * has an account. The sign-up endpoint itself returns a generic success + * response to avoid account enumeration, so this email is how the real account + * owner learns of the attempt. + */ +export function ExistingAccountEmail({ username = '' }: ExistingAccountEmailProps) { + const brand = getBrandConfig() + const loginLink = `${getBaseUrl()}/login` + + return ( + + Hello {username}, + + Someone just tried to create a {brand.name} account using this email address, but an account + already exists. If this was you, sign in instead — or reset your password if you've + forgotten it. + + + + Sign In + + +
+ + + If this wasn't you, no action is needed — no account was created or changed. + + + ) +} + +export default ExistingAccountEmail diff --git a/apps/sim/components/emails/auth/index.ts b/apps/sim/components/emails/auth/index.ts index 3d5438dd69..de9eff40e9 100644 --- a/apps/sim/components/emails/auth/index.ts +++ b/apps/sim/components/emails/auth/index.ts @@ -1,3 +1,4 @@ +export { ExistingAccountEmail } from './existing-account-email' export { OnboardingFollowupEmail } from './onboarding-followup-email' export { OTPVerificationEmail } from './otp-verification-email' export { ResetPasswordEmail } from './reset-password-email' diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 2b3d416c51..16bfaf4af1 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -1,5 +1,6 @@ import { render } from '@react-email/render' import { + ExistingAccountEmail, OnboardingFollowupEmail, OTPVerificationEmail, ResetPasswordEmail, @@ -45,6 +46,10 @@ export async function renderOTPEmail( return await render(OTPVerificationEmail({ otp, email, type, chatTitle })) } +export async function renderExistingAccountEmail(username: string): Promise { + return await render(ExistingAccountEmail({ username })) +} + export async function renderPasswordResetEmail( username: string, resetLink: string diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index 347bf4074d..a1ddbd3ed3 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -7,6 +7,7 @@ export type EmailSubjectType = | 'change-email' | 'forget-password' | 'reset-password' + | 'existing-account' | 'invitation' | 'batch-invitation' | 'polling-group-invitation' @@ -41,6 +42,8 @@ export function getEmailSubject(type: EmailSubjectType): string { return `Reset your ${brandName} password` case 'reset-password': return `Reset your ${brandName} password` + case 'existing-account': + return `Sign-up attempt with your ${brandName} email` case 'invitation': return `You've been invited to join a team on ${brandName}` case 'batch-invitation': diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index cdd2ab9401..106c6bc95e 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2492,7 +2492,7 @@ export function LinkupIcon(props: SVGProps) { ) @@ -4967,7 +4967,7 @@ export function InfisicalIcon(props: SVGProps) { ) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index c3e908121b..d0840bca8a 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -105,25 +105,36 @@ export const deployedChatConfigSchema = z.object({ export type DeployedChatConfig = z.output export const deployedChatAuthBodySchema = z.object({ - password: z.string().optional(), + password: z.string().max(1024, 'Password is too long').optional(), email: z.string().email('Invalid email format').optional().or(z.literal('')), }) export type DeployedChatAuthBody = z.input +const MAX_CHAT_INPUT_CHARS = 1_000_000 +const MAX_CHAT_FILE_DATA_CHARS = 14 * 1024 * 1024 +const MAX_CHAT_FILES = 15 + export const deployedChatFileSchema = z.object({ - name: z.string().min(1, 'File name is required'), - type: z.string().min(1, 'File type is required'), + name: z.string().min(1, 'File name is required').max(255, 'File name is too long'), + type: z.string().min(1, 'File type is required').max(255, 'File type is too long'), size: z.number().positive('File size must be positive'), - data: z.string().min(1, 'File data is required'), + data: z + .string() + .min(1, 'File data is required') + .max(MAX_CHAT_FILE_DATA_CHARS, 'File data exceeds the maximum allowed size'), lastModified: z.number().optional(), }) export const deployedChatPostBodySchema = z.object({ - input: z.string().optional(), - password: z.string().optional(), + input: z.string().max(MAX_CHAT_INPUT_CHARS, 'Input is too long').optional(), + password: z.string().max(1024, 'Password is too long').optional(), email: z.string().email('Invalid email format').optional().or(z.literal('')), - conversationId: z.string().optional(), - files: z.array(deployedChatFileSchema).optional().default([]), + conversationId: z.string().max(256, 'Conversation ID is too long').optional(), + files: z + .array(deployedChatFileSchema) + .max(MAX_CHAT_FILES, `A maximum of ${MAX_CHAT_FILES} files is allowed`) + .optional() + .default([]), }) export type DeployedChatPostBody = z.input diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 4128efcfa9..75cd5cbce8 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -8,6 +8,23 @@ import type { ContractParams, ContractQuery, } from '@/lib/api/contracts' +import { env } from '@/lib/core/config/env' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' + +/** + * Default upper bound on the JSON request body that contract routes will read + * and parse into memory. Next.js App Router imposes no body cap, so without + * this an unauthenticated caller could buffer an arbitrarily large body before + * schema validation runs. Override per-route via `ParseRequestOptions.maxBodyBytes`. + * Falls back to 50 MB if the env value is missing or non-numeric so a misconfig + * can never silently disable the cap (a NaN limit would never reject). + */ +export const DEFAULT_MAX_JSON_BODY_BYTES = + Number.parseInt(env.API_MAX_JSON_BODY_BYTES, 10) || 50 * 1024 * 1024 export interface ValidationErrorBody { error: string @@ -35,6 +52,12 @@ export interface ParseRequestOptions { validationErrorResponse?: (error: z.ZodError) => NextResponse invalidJsonResponse?: () => NextResponse invalidJson?: 'response' | 'throw' + /** + * Maximum number of bytes to read for the JSON body before rejecting with a + * 413. Defaults to {@link DEFAULT_MAX_JSON_BODY_BYTES}. Raise this only for + * routes that legitimately accept large JSON payloads (e.g. inline file uploads). + */ + maxBodyBytes?: number } export function serializeZodIssues(error: z.ZodError): z.core.$ZodIssue[] { @@ -69,18 +92,61 @@ export function validationErrorResponseFromError( return validationErrorResponse(error, message, status) } +const REQUEST_BODY_LABEL = 'Request body' + +/** + * Reads the JSON body while enforcing a byte cap. The body is read through a + * size-limited stream so chunked/streamed bodies are bounded even when the + * `content-length` header is absent or lies about the true size. When no + * readable stream is available (e.g. a mocked request) the content-length guard + * is the only bound and parsing falls back to {@link Request.json}. Decoding + * uses {@link TextDecoder} so a leading UTF-8 BOM is stripped, matching the spec + * "UTF-8 decode" behavior of `request.json()`. + */ +async function readJsonBodyWithLimit(request: Request, maxBytes: number): Promise { + assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL) + + const stream = request.body + if (!stream) { + return request.json() + } + + const buffer = await readStreamToBufferWithLimit(stream, { + maxBytes, + label: REQUEST_BODY_LABEL, + }) + return JSON.parse(new TextDecoder().decode(buffer)) +} + export async function parseJsonBody( request: Request, - invalidJson: ParseRequestOptions['invalidJson'] = 'response' + invalidJson: ParseRequestOptions['invalidJson'] = 'response', + maxBytes: number = DEFAULT_MAX_JSON_BODY_BYTES ): Promise< - { success: true; data: unknown } | { success: false; response: NextResponse<{ error: string }> } + | { success: true; data: unknown } + | { + success: false + reason: 'too_large' | 'invalid_json' + response: NextResponse<{ error: string }> + } > { try { - return { success: true, data: await request.json() } + return { success: true, data: await readJsonBodyWithLimit(request, maxBytes) } } catch (error) { if (invalidJson === 'throw') throw error + if (isPayloadSizeLimitError(error)) { + return { + success: false, + reason: 'too_large', + response: NextResponse.json( + { error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` }, + { status: 413 } + ), + } + } return { success: false, + reason: 'invalid_json', response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }), } } @@ -133,9 +199,9 @@ export async function parseRequest( let body: unknown if (shouldReadJsonBody(contract)) { - const parsedBody = await parseJsonBody(request, options?.invalidJson) + const parsedBody = await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes) if (!parsedBody.success) { - return options?.invalidJsonResponse + return options?.invalidJsonResponse && parsedBody.reason === 'invalid_json' ? { success: false, response: options.invalidJsonResponse() } : parsedBody } diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 7a65dfa10e..5037e79565 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -7,7 +7,7 @@ import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { betterAuth } from 'better-auth' +import { betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { APIError, createAuthMiddleware } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' @@ -26,6 +26,7 @@ import { headers } from 'next/headers' import Stripe from 'stripe' import { getEmailSubject, + renderExistingAccountEmail, renderOTPEmail, renderPasswordResetEmail, renderWelcomeEmail, @@ -756,6 +757,65 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: isEmailVerificationEnabled, + /** + * When someone signs up with an already-registered email, better-auth returns a + * generic success response (OWASP enumeration protection) instead of leaking that + * the account exists. This callback notifies the real account owner out-of-band, + * mirroring the privacy-preserving forget-password flow. Errors are swallowed so the + * response is indistinguishable from a genuine new sign-up. + */ + onExistingUserSignUp: async ({ user }: { user: User }) => { + try { + const html = await renderExistingAccountEmail(user.name || '') + const result = await sendEmail({ + to: user.email, + subject: getEmailSubject('existing-account'), + html, + from: getFromEmailAddress(), + emailType: 'transactional', + }) + if (!result.success) { + logger.warn('[onExistingUserSignUp] Failed to send existing-account email', { + message: result.message, + }) + } + } catch (error) { + logger.error('[onExistingUserSignUp] Error sending existing-account email', { error }) + } + }, + /** + * The synthetic user returned for the generic duplicate-sign-up response must carry + * the exact same set of returned fields a real freshly-created user would, otherwise + * the differing response shape re-opens the enumeration oracle. The admin plugin + * (always loaded) adds role/banned/banReason/banExpires, and the Stripe plugin — loaded + * only when billing is enabled — adds stripeCustomerId (null on a new user). The + * harmony plugin's normalizedEmail is `returned: false`, so it is intentionally omitted. + */ + customSyntheticUser: ({ + coreFields, + additionalFields, + id, + }: { + coreFields: { + name: string + email: string + emailVerified: boolean + image: string | null + createdAt: Date + updatedAt: Date + } + additionalFields: Record + id: string + }) => ({ + ...coreFields, + role: 'user', + banned: false, + banReason: null, + banExpires: null, + ...(isBillingEnabled && stripeClient ? { stripeCustomerId: null } : {}), + ...additionalFields, + id, + }), sendResetPassword: async ({ user, url, token }, request) => { const username = user.name || '' @@ -849,22 +909,6 @@ export const auth = betterAuth({ } } - if (ctx.path === '/sign-up/email' && ctx.body?.email) { - const signupEmail = ctx.body.email.toLowerCase() - const [existingUser] = await db - .select({ id: schema.user.id }) - .from(schema.user) - .where(eq(schema.user.email, signupEmail)) - .limit(1) - - if (existingUser) { - throw new APIError('UNPROCESSABLE_ENTITY', { - message: 'User already exists', - code: 'USER_ALREADY_EXISTS', - }) - } - } - return }), }, diff --git a/apps/sim/lib/billing/calculations/usage-reservation.test.ts b/apps/sim/lib/billing/calculations/usage-reservation.test.ts new file mode 100644 index 0000000000..a6435aff69 --- /dev/null +++ b/apps/sim/lib/billing/calculations/usage-reservation.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFlags } = vi.hoisted(() => ({ + mockFlags: { isBillingEnabled: true }, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isBillingEnabled() { + return mockFlags.isBillingEnabled + }, + isHosted: true, +})) + +vi.mock('@/lib/core/config/redis', () => redisConfigMock) + +import { + releaseExecutionSlot, + reserveExecutionSlot, + resolveBillingEntityKey, +} from '@/lib/billing/calculations/usage-reservation' + +const evalMock = vi.fn() +const getdelMock = vi.fn() +const zremMock = vi.fn() +const fakeRedis = { eval: evalMock, getdel: getdelMock, zrem: zremMock } + +const baseParams = { + userId: 'user-1', + executionId: 'exec-1', + subscription: { plan: 'free' as const, referenceId: 'user-1' }, + currentUsage: 0, + limit: 5, +} + +describe('usage-reservation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFlags.isBillingEnabled = true + redisConfigMockFns.mockGetRedisClient.mockReturnValue(fakeRedis) + }) + + describe('resolveBillingEntityKey', () => { + it('keys personal subscriptions by user', () => { + expect(resolveBillingEntityKey('user-1', { referenceId: 'user-1' })).toBe('user:user-1') + }) + + it('keys org-scoped subscriptions by organization', () => { + expect(resolveBillingEntityKey('user-1', { referenceId: 'org-9' })).toBe('org:org-9') + }) + }) + + describe('reserveExecutionSlot', () => { + it('admits when the reservation script returns 1', async () => { + evalMock.mockResolvedValueOnce(1) + const result = await reserveExecutionSlot(baseParams) + expect(result.reserved).toBe(true) + expect(evalMock).toHaveBeenCalledTimes(1) + }) + + it('rejects when the reservation script returns 0 (slots full)', async () => { + evalMock.mockResolvedValueOnce(0) + const result = await reserveExecutionSlot(baseParams) + expect(result.reserved).toBe(false) + }) + + it('passes the free-tier concurrency cap and headroom slots to the script', async () => { + evalMock.mockResolvedValueOnce(1) + await reserveExecutionSlot(baseParams) + const args = evalMock.mock.calls[0] + // eval(script, numKeys, inflightKey, pointerKey, now, expiry, maxConc, headroomSlots, member, entityKey, pttl) + expect(args[2]).toBe('usage:inflight:user:user-1') + expect(args[3]).toBe('usage:reservation:exec-1') + expect(args[6]).toBe('15') + expect(args[7]).toBe('1000') + expect(args[8]).toBe('exec-1') + expect(args[9]).toBe('user:user-1') + }) + + it('reserves against the org entity for org-scoped subscriptions', async () => { + evalMock.mockResolvedValueOnce(1) + await reserveExecutionSlot({ + ...baseParams, + subscription: { plan: 'team', referenceId: 'org-9' }, + }) + const args = evalMock.mock.calls[0] + expect(args[2]).toBe('usage:inflight:org:org-9') + expect(args[6]).toBe('150') + }) + + it('clamps negative headroom to zero slots', async () => { + evalMock.mockResolvedValueOnce(0) + await reserveExecutionSlot({ ...baseParams, currentUsage: 10, limit: 5 }) + expect(evalMock.mock.calls[0][7]).toBe('0') + }) + + it('fails open (admits) when billing enforcement is disabled', async () => { + mockFlags.isBillingEnabled = false + const result = await reserveExecutionSlot(baseParams) + expect(result.reserved).toBe(true) + expect(evalMock).not.toHaveBeenCalled() + }) + + it('fails open (admits) when Redis is unavailable', async () => { + redisConfigMockFns.mockGetRedisClient.mockReturnValue(null) + const result = await reserveExecutionSlot(baseParams) + expect(result.reserved).toBe(true) + expect(evalMock).not.toHaveBeenCalled() + }) + + it('fails open (admits) when the reservation script throws', async () => { + evalMock.mockRejectedValueOnce(new Error('connection lost')) + const result = await reserveExecutionSlot(baseParams) + expect(result.reserved).toBe(true) + }) + }) + + describe('releaseExecutionSlot', () => { + it('reads the pointer then removes the slot from that entity set', async () => { + getdelMock.mockResolvedValueOnce('org:org-9') + await releaseExecutionSlot('exec-1') + expect(getdelMock).toHaveBeenCalledWith('usage:reservation:exec-1') + expect(zremMock).toHaveBeenCalledWith('usage:inflight:org:org-9', 'exec-1') + }) + + it('does not touch the in-flight set when the pointer is already gone', async () => { + getdelMock.mockResolvedValueOnce(null) + await releaseExecutionSlot('exec-1') + expect(zremMock).not.toHaveBeenCalled() + }) + + it('uses only single-key commands (cluster-safe; no key built inside Lua)', async () => { + getdelMock.mockResolvedValueOnce('user:user-1') + await releaseExecutionSlot('exec-1') + expect(evalMock).not.toHaveBeenCalled() + }) + + it('is a no-op when billing enforcement is disabled', async () => { + mockFlags.isBillingEnabled = false + await releaseExecutionSlot('exec-1') + expect(getdelMock).not.toHaveBeenCalled() + }) + + it('swallows release errors', async () => { + getdelMock.mockRejectedValueOnce(new Error('boom')) + await expect(releaseExecutionSlot('exec-1')).resolves.toBeUndefined() + }) + }) +}) diff --git a/apps/sim/lib/billing/calculations/usage-reservation.ts b/apps/sim/lib/billing/calculations/usage-reservation.ts new file mode 100644 index 0000000000..c448c88776 --- /dev/null +++ b/apps/sim/lib/billing/calculations/usage-reservation.ts @@ -0,0 +1,210 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' +import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { getRedisClient } from '@/lib/core/config/redis' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' + +const logger = createLogger('UsageReservation') + +/** + * Maximum number of simultaneously in-flight (admitted but not-yet-costed) + * executions a single billing entity may hold at once. + * + * The usage-cap admission gate reads already-recorded cost, but cost is only + * written when an execution finishes. Without a reservation, N parallel + * executions all read the same pre-burst usage, all pass the cap, and all run — + * collectively spending far past the cap before any cost lands in the ledger + * (free-tier abuse / hard-cap defeat). Bounding the number of in-flight + * executions per billing entity bounds the worst-case overshoot to roughly this + * many executions' worth of spend. + */ +const MAX_CONCURRENT_EXECUTIONS: Record = { + free: 15, + pro: 75, + team: 150, + enterprise: 300, +} + +/** + * Per-slot reserved cost estimate (dollars). The guaranteed-minimum charge + * every execution incurs, used to taper admission as recorded usage approaches + * the cap: an entity may hold at most `floor(headroom / estimate)` concurrent + * slots, keeping `recordedUsage + reservedSlots * estimate <= limit`. A lone + * execution is never blocked on headroom alone — the recorded-usage gate + * (`isExceeded`) governs the single-execution case, so the only residual + * overshoot is the one already inherent to admission (cost is unknown until the + * execution finishes). + */ +const SLOT_COST_ESTIMATE = BASE_EXECUTION_CHARGE + +/** Safety buffer added to the reservation TTL beyond the max execution timeout. */ +const RESERVATION_TTL_BUFFER_MS = 60_000 + +const INFLIGHT_KEY_PREFIX = 'usage:inflight:' +const POINTER_KEY_PREFIX = 'usage:reservation:' + +/** + * Atomically admit an execution only when both the per-entity concurrency cap + * and the remaining usage headroom permit it, then record the in-flight slot. + * + * Prune expired members (crash safety) -> `count = ZCARD` -> reject when + * `count >= min(maxConcurrency, max(1, headroomSlots))` -> otherwise `ZADD` the + * slot, refresh the set TTL, and write the per-execution pointer for release. + * The `max(1, ...)` floor guarantees a lone execution is never blocked on + * headroom alone; concurrency above the first slot still tapers with headroom. + */ +const RESERVE_SCRIPT = ` +local now = tonumber(ARGV[1]) +local expiryScore = tonumber(ARGV[2]) +local maxConcurrency = tonumber(ARGV[3]) +local headroomSlots = tonumber(ARGV[4]) +local pttl = tonumber(ARGV[7]) +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now) +local count = redis.call('ZCARD', KEYS[1]) +if headroomSlots < 1 then headroomSlots = 1 end +local allowed = maxConcurrency +if headroomSlots < allowed then allowed = headroomSlots end +if count >= allowed then + return 0 +end +redis.call('ZADD', KEYS[1], expiryScore, ARGV[5]) +redis.call('PEXPIRE', KEYS[1], pttl) +redis.call('SET', KEYS[2], ARGV[6], 'PX', pttl) +return 1 +` + +/** + * Stable per-entity reservation key. Org-scoped subscriptions reserve against + * the organization's pooled cap; everyone else against their personal cap — + * mirroring the entity the usage limit itself is enforced on. + */ +export function resolveBillingEntityKey( + userId: string, + subscription: { referenceId?: string | null } | null | undefined +): string { + if (isOrgScopedSubscription(subscription, userId) && subscription?.referenceId) { + return `org:${subscription.referenceId}` + } + return `user:${userId}` +} + +function getMaxConcurrentExecutions(plan: string | null | undefined): number { + return MAX_CONCURRENT_EXECUTIONS[getPlanTypeForLimits(plan) as SubscriptionPlan] +} + +export interface ReserveExecutionSlotParams { + userId: string + executionId: string + subscription: { plan?: string | null; referenceId?: string | null } | null | undefined + /** Recorded usage for the billing entity at admission time (dollars). */ + currentUsage: number + /** The entity's usage cap (dollars). */ + limit: number +} + +export interface ReserveExecutionSlotResult { + reserved: boolean +} + +/** + * Atomic admission reservation that closes the usage-cap check-then-use race. + * + * No-ops (admits) when billing enforcement is off or Redis is unavailable — + * the caller's recorded-usage check still runs in those cases, and failing open + * here matches the rate limiter rather than turning a Redis blip into a full + * execution outage. + */ +export async function reserveExecutionSlot( + params: ReserveExecutionSlotParams +): Promise { + if (!isBillingEnabled) { + return { reserved: true } + } + + const redis = getRedisClient() + if (!redis) { + return { reserved: true } + } + + const { userId, executionId, subscription, currentUsage, limit } = params + const entityKey = resolveBillingEntityKey(userId, subscription) + const maxConcurrency = getMaxConcurrentExecutions(subscription?.plan) + const headroom = Math.max(0, limit - currentUsage) + const headroomSlots = Math.floor(headroom / SLOT_COST_ESTIMATE) + const ttlMs = getMaxExecutionTimeout() + RESERVATION_TTL_BUFFER_MS + const now = Date.now() + const expiryScore = now + ttlMs + + try { + const result = await redis.eval( + RESERVE_SCRIPT, + 2, + `${INFLIGHT_KEY_PREFIX}${entityKey}`, + `${POINTER_KEY_PREFIX}${executionId}`, + now.toString(), + expiryScore.toString(), + maxConcurrency.toString(), + headroomSlots.toString(), + executionId, + entityKey, + ttlMs.toString() + ) + + const reserved = result === 1 + if (!reserved) { + logger.warn('Execution admission throttled — concurrency/usage reservation full', { + entityKey, + executionId, + maxConcurrency, + headroomSlots, + }) + } + return { reserved } + } catch (error) { + logger.error('Usage reservation error — failing open (admitting execution)', { + error: toError(error).message, + entityKey, + executionId, + }) + return { reserved: true } + } +} + +/** + * Release the in-flight reservation held for an execution. Best-effort and + * idempotent — safe to call for executions that never reserved (Redis down, + * billing disabled) or are released more than once. Must NOT be called for a + * paused execution that may still resume. + * + * Uses discrete single-key commands rather than a Lua script that rebuilds the + * in-flight key from the pointer value: the entity that owns the slot is only + * known after reading the pointer, and constructing a key inside Lua bypasses + * the `KEYS` declaration that Redis Cluster relies on for slot routing. + */ +export async function releaseExecutionSlot(executionId: string): Promise { + if (!isBillingEnabled) { + return + } + + const redis = getRedisClient() + if (!redis) { + return + } + + try { + const pointerKey = `${POINTER_KEY_PREFIX}${executionId}` + const entityKey = await redis.getdel(pointerKey) + if (entityKey) { + await redis.zrem(`${INFLIGHT_KEY_PREFIX}${entityKey}`, executionId) + } + } catch (error) { + logger.warn('Failed to release usage reservation', { + error: toError(error).message, + executionId, + }) + } +} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a4fd479f44..87af1a75f3 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -243,6 +243,8 @@ export const env = createEnv({ // Admission & Burst Protection ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod + API_MAX_JSON_BODY_BYTES: z.string().optional().default('52428800'),// Default max JSON request body size for contract routes (50 MB) + CHAT_MAX_REQUEST_BYTES: z.string().optional().default('230686720'),// Max request body size for the public deployed-chat endpoint (220 MB; covers 15 base64 file attachments) // Rate Limiting Configuration RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute) diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 833d7b9ab1..0ff0d9eba5 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -5,6 +5,7 @@ import { checkOrgMemberUsageLimit, checkServerSideUsageLimits, } from '@/lib/billing/calculations/usage-monitor' +import { reserveExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { @@ -38,6 +39,14 @@ export interface PreprocessExecutionOptions { checkRateLimit?: boolean // Default: false for manual/chat, true for others checkDeployment?: boolean // Default: true for non-manual triggers skipUsageLimits?: boolean // Default: false (only use for test mode) + /** + * Skip the atomic in-flight concurrency reservation while still enforcing the + * usage-cost cap. Default: false. Set by surfaces that already bound and pace + * their own fan-out (e.g. table-cell dispatch, which is row-bounded, async + * rate-limited, and surfaces a graceful "wait/upgrade" state) so the + * reservation's 429 can't surface as a hard error there. + */ + skipConcurrencyReservation?: boolean logPreprocessingErrors?: boolean // Default: true. When false, skip writing workflow_execution_logs error rows (caller surfaces failures itself, e.g. table cells) // Context information @@ -93,6 +102,7 @@ export async function preprocessExecution( checkRateLimit = triggerType !== 'manual' && triggerType !== 'chat', checkDeployment = triggerType !== 'manual', skipUsageLimits = false, + skipConcurrencyReservation = false, logPreprocessingErrors = true, workspaceId: providedWorkspaceId, loggingSession: providedLoggingSession, @@ -315,9 +325,12 @@ export async function preprocessExecution( const userSubscription = await getHighestPrioritySubscription(actorUserId) // ========== STEP 5: Check Usage Limits ========== + // Snapshot reused by the STEP 7 admission reservation. + let usageSnapshot: { currentUsage: number; limit: number } | null = null if (!skipUsageLimits) { try { const usageCheck = await checkServerSideUsageLimits(actorUserId, userSubscription) + usageSnapshot = { currentUsage: usageCheck.currentUsage, limit: usageCheck.limit } if (usageCheck.isExceeded) { logger.warn( `[${requestId}] User ${actorUserId} has exceeded usage limits. Blocking execution.`, @@ -496,6 +509,62 @@ export async function preprocessExecution( } } + /** + * STEP 7: Atomic admission reservation. Cost is only recorded once an + * execution finishes, so without this a burst of concurrent executions all + * observe the same pre-burst usage and all pass the gate above. Reserving + * bounds in-flight (un-costed) executions per billing entity. Done last so an + * earlier rejection never leaves a slot held; the slot is released at + * execution completion (see {@link LoggingSession}). + */ + if (!skipUsageLimits && !skipConcurrencyReservation && usageSnapshot) { + try { + const { reserved } = await reserveExecutionSlot({ + userId: actorUserId, + executionId, + subscription: userSubscription, + currentUsage: usageSnapshot.currentUsage, + limit: usageSnapshot.limit, + }) + + if (!reserved) { + logger.warn(`[${requestId}] Admission reservation full for user ${actorUserId}`, { + workflowId, + triggerType, + }) + + await recordPreprocessingError({ + workflowId, + executionId, + triggerType, + requestId, + userId: actorUserId, + workspaceId, + errorMessage: + 'Too many concurrent executions in flight for this account. Please wait for in-progress runs to finish and try again.', + loggingSession: providedLoggingSession, + triggerData, + }) + + return { + success: false, + error: { + message: + 'Too many concurrent executions in flight. Please wait for in-progress runs to finish and try again.', + statusCode: 429, + logCreated: true, + retryable: true, + }, + } + } + } catch (error) { + logger.error(`[${requestId}] Unexpected error reserving admission slot`, { + error, + actorUserId, + }) + } + } + // ========== SUCCESS: All Checks Passed ========== logger.info(`[${requestId}] All preprocessing checks passed`, { workflowId, diff --git a/apps/sim/lib/logs/execution/logging-session.ts b/apps/sim/lib/logs/execution/logging-session.ts index a0fd011dc7..a63aa3fb30 100644 --- a/apps/sim/lib/logs/execution/logging-session.ts +++ b/apps/sim/lib/logs/execution/logging-session.ts @@ -3,6 +3,7 @@ import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { describeError, toError } from '@sim/utils/errors' import { and, eq, sql } from 'drizzle-orm' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { executionLogger } from '@/lib/logs/execution/logger' import { @@ -266,6 +267,18 @@ export class LoggingSession { level: params.level, status: params.status, }) + + // Release the admission reservation from preprocessing. Skipped on pause: a + // paused execution keeps its slot until it terminates (or the TTL expires). + if (params.finalizationPath !== 'paused') { + try { + await releaseExecutionSlot(this.executionId) + } catch (error) { + logger.warn(`Failed to release admission reservation for ${this.executionId}:`, { + error: toError(error).message, + }) + } + } } async onBlockComplete( diff --git a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts index f5bc7fa668..9dc16615a0 100644 --- a/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/folder-lifecycle.ts @@ -54,6 +54,33 @@ export interface PerformUpdateFolderResult { folder?: typeof workflowFolder.$inferSelect } +/** + * Verifies that a prospective parent folder exists, belongs to the target + * workspace, and is not archived. Mirrors the validation in the duplicate + * route's `assertTargetParentFolderMutable` so a caller cannot reparent a + * folder to a non-existent id or to a folder in another workspace. Returns + * an error result when invalid, or `null` when the parent is acceptable. + */ +async function assertParentFolderInWorkspace( + parentId: string, + workspaceId: string +): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> { + const [parent] = await db + .select({ + workspaceId: workflowFolder.workspaceId, + archivedAt: workflowFolder.archivedAt, + }) + .from(workflowFolder) + .where(eq(workflowFolder.id, parentId)) + .limit(1) + + if (!parent || parent.workspaceId !== workspaceId || parent.archivedAt) { + return { error: 'Parent folder not found', errorCode: 'validation' } + } + + return null +} + async function nextFolderSortOrder( workspaceId: string, parentId: string | null | undefined @@ -93,6 +120,19 @@ export async function performCreateFolder( try { const folderId = params.id || generateId() const parentId = params.parentId || null + + if (parentId) { + if (parentId === folderId) { + return { + success: false, + error: 'Folder cannot be its own parent', + errorCode: 'validation', + } + } + const parentError = await assertParentFolderInWorkspace(parentId, params.workspaceId) + if (parentError) return { success: false, ...parentError } + } + const sortOrder = params.sortOrder !== undefined ? params.sortOrder @@ -146,6 +186,9 @@ export async function performUpdateFolder( } if (params.parentId) { + const parentError = await assertParentFolderInWorkspace(params.parentId, params.workspaceId) + if (parentError) return { success: false, ...parentError } + const wouldCreateCycle = await checkForCircularReference(params.folderId, params.parentId) if (wouldCreateCycle) { return { diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 7be2d04971..5f5524f284 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -4,6 +4,7 @@ import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isFolderInWorkspace } from '@sim/workflow-authz' import { and, eq, isNull, min, ne } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' @@ -182,6 +183,10 @@ export async function performCreateWorkflow( const folderId = params.folderId || null try { + if (!(await isFolderInWorkspace(folderId, params.workspaceId))) { + return { success: false, error: 'Target folder not found', errorCode: 'validation' } + } + const name = params.deduplicate ? await deduplicateWorkflowName(params.name, params.workspaceId, folderId) : params.name @@ -278,6 +283,13 @@ export async function performUpdateWorkflow( const targetFolderId = params.folderId !== undefined ? params.folderId || null : params.currentFolderId || null + if ( + params.folderId !== undefined && + !(await isFolderInWorkspace(targetFolderId, params.workspaceId)) + ) { + return { success: false, error: 'Target folder not found', errorCode: 'validation' } + } + if (params.name !== undefined || params.folderId !== undefined) { const duplicate = await workflowNameExistsInFolder({ workspaceId: params.workspaceId, diff --git a/apps/sim/tools/agiloft/attachment_info.ts b/apps/sim/tools/agiloft/attachment_info.ts index 07986303fe..549bbd665a 100644 --- a/apps/sim/tools/agiloft/attachment_info.ts +++ b/apps/sim/tools/agiloft/attachment_info.ts @@ -2,7 +2,8 @@ import type { AgiloftAttachmentInfoParams, AgiloftAttachmentInfoResponse, } from '@/tools/agiloft/types' -import { buildAttachmentInfoUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildAttachmentInfoUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftAttachmentInfoTool: ToolConfig< diff --git a/apps/sim/tools/agiloft/create_record.ts b/apps/sim/tools/agiloft/create_record.ts index f4763f55ba..c9b852659b 100644 --- a/apps/sim/tools/agiloft/create_record.ts +++ b/apps/sim/tools/agiloft/create_record.ts @@ -1,5 +1,6 @@ import type { AgiloftCreateRecordParams, AgiloftRecordResponse } from '@/tools/agiloft/types' -import { buildCreateRecordUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildCreateRecordUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftCreateRecordTool: ToolConfig = diff --git a/apps/sim/tools/agiloft/delete_record.ts b/apps/sim/tools/agiloft/delete_record.ts index 4253810496..3a6744b4f1 100644 --- a/apps/sim/tools/agiloft/delete_record.ts +++ b/apps/sim/tools/agiloft/delete_record.ts @@ -1,5 +1,6 @@ import type { AgiloftDeleteRecordParams, AgiloftDeleteResponse } from '@/tools/agiloft/types' -import { buildDeleteRecordUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildDeleteRecordUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftDeleteRecordTool: ToolConfig = diff --git a/apps/sim/tools/agiloft/get_choice_line_id.ts b/apps/sim/tools/agiloft/get_choice_line_id.ts index 11df104056..596cd8898a 100644 --- a/apps/sim/tools/agiloft/get_choice_line_id.ts +++ b/apps/sim/tools/agiloft/get_choice_line_id.ts @@ -2,7 +2,8 @@ import type { AgiloftGetChoiceLineIdParams, AgiloftGetChoiceLineIdResponse, } from '@/tools/agiloft/types' -import { buildGetChoiceLineIdUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildGetChoiceLineIdUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftGetChoiceLineIdTool: ToolConfig< diff --git a/apps/sim/tools/agiloft/lock_record.ts b/apps/sim/tools/agiloft/lock_record.ts index d79777b196..4c46d9cb75 100644 --- a/apps/sim/tools/agiloft/lock_record.ts +++ b/apps/sim/tools/agiloft/lock_record.ts @@ -1,5 +1,6 @@ import type { AgiloftLockRecordParams, AgiloftLockResponse } from '@/tools/agiloft/types' -import { buildLockRecordUrl, executeAgiloftRequest, getLockHttpMethod } from '@/tools/agiloft/utils' +import { buildLockRecordUrl, getLockHttpMethod } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftLockRecordTool: ToolConfig = { diff --git a/apps/sim/tools/agiloft/read_record.ts b/apps/sim/tools/agiloft/read_record.ts index 70b015c43b..ce59238e1f 100644 --- a/apps/sim/tools/agiloft/read_record.ts +++ b/apps/sim/tools/agiloft/read_record.ts @@ -1,5 +1,6 @@ import type { AgiloftReadRecordParams, AgiloftRecordResponse } from '@/tools/agiloft/types' -import { buildReadRecordUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildReadRecordUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftReadRecordTool: ToolConfig = { diff --git a/apps/sim/tools/agiloft/remove_attachment.ts b/apps/sim/tools/agiloft/remove_attachment.ts index 7e9a9d6f2d..7f90e8d6c8 100644 --- a/apps/sim/tools/agiloft/remove_attachment.ts +++ b/apps/sim/tools/agiloft/remove_attachment.ts @@ -2,7 +2,8 @@ import type { AgiloftRemoveAttachmentParams, AgiloftRemoveAttachmentResponse, } from '@/tools/agiloft/types' -import { buildRemoveAttachmentUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildRemoveAttachmentUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftRemoveAttachmentTool: ToolConfig< diff --git a/apps/sim/tools/agiloft/saved_search.ts b/apps/sim/tools/agiloft/saved_search.ts index 8d645d871d..86232d8822 100644 --- a/apps/sim/tools/agiloft/saved_search.ts +++ b/apps/sim/tools/agiloft/saved_search.ts @@ -1,5 +1,6 @@ import type { AgiloftSavedSearchParams, AgiloftSavedSearchResponse } from '@/tools/agiloft/types' -import { buildSavedSearchUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildSavedSearchUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftSavedSearchTool: ToolConfig< diff --git a/apps/sim/tools/agiloft/search_records.ts b/apps/sim/tools/agiloft/search_records.ts index b05465c0be..352124787f 100644 --- a/apps/sim/tools/agiloft/search_records.ts +++ b/apps/sim/tools/agiloft/search_records.ts @@ -1,5 +1,6 @@ import type { AgiloftSearchRecordsParams, AgiloftSearchResponse } from '@/tools/agiloft/types' -import { buildSearchRecordsUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildSearchRecordsUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftSearchRecordsTool: ToolConfig< diff --git a/apps/sim/tools/agiloft/select_records.ts b/apps/sim/tools/agiloft/select_records.ts index de4be3139c..5878551d44 100644 --- a/apps/sim/tools/agiloft/select_records.ts +++ b/apps/sim/tools/agiloft/select_records.ts @@ -1,5 +1,6 @@ import type { AgiloftSelectRecordsParams, AgiloftSelectResponse } from '@/tools/agiloft/types' -import { buildSelectRecordsUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildSelectRecordsUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftSelectRecordsTool: ToolConfig< diff --git a/apps/sim/tools/agiloft/update_record.ts b/apps/sim/tools/agiloft/update_record.ts index 661be1b3a8..a264b27290 100644 --- a/apps/sim/tools/agiloft/update_record.ts +++ b/apps/sim/tools/agiloft/update_record.ts @@ -1,5 +1,6 @@ import type { AgiloftRecordResponse, AgiloftUpdateRecordParams } from '@/tools/agiloft/types' -import { buildUpdateRecordUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import { buildUpdateRecordUrl } from '@/tools/agiloft/utils' +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' import type { ToolConfig } from '@/tools/types' export const agiloftUpdateRecordTool: ToolConfig = diff --git a/apps/sim/tools/agiloft/utils.test.ts b/apps/sim/tools/agiloft/utils.server.test.ts similarity index 51% rename from apps/sim/tools/agiloft/utils.test.ts rename to apps/sim/tools/agiloft/utils.server.test.ts index b80eb2a33b..9d207b0627 100644 --- a/apps/sim/tools/agiloft/utils.test.ts +++ b/apps/sim/tools/agiloft/utils.server.test.ts @@ -1,8 +1,19 @@ /** * @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { executeAgiloftRequest } from '@/tools/agiloft/utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockValidateUrlWithDNS, mockSecureFetch } = vi.hoisted(() => ({ + mockValidateUrlWithDNS: vi.fn(), + mockSecureFetch: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateUrlWithDNS: mockValidateUrlWithDNS, + secureFetchWithPinnedIP: mockSecureFetch, +})) + +import { executeAgiloftRequest } from '@/tools/agiloft/utils.server' const baseParams = { instanceUrl: 'https://example.agiloft.com', @@ -12,34 +23,31 @@ const baseParams = { table: 'contracts', } -function mockFetchResponse(body: { ok?: boolean; status?: number; json?: unknown; text?: string }) { +function mockResponse(body: { ok?: boolean; status?: number; json?: unknown; text?: string }) { return { ok: body.ok ?? true, status: body.status ?? 200, statusText: '', - headers: new Headers(), + headers: { get: () => null, getSetCookie: () => [], toRecord: () => ({}) }, + body: null, text: async () => body.text ?? '', json: async () => body.json ?? {}, - } as unknown as Response + arrayBuffer: async () => new ArrayBuffer(0), + } } -const fetchSpy = vi.fn() - beforeEach(() => { - fetchSpy.mockReset() - vi.stubGlobal('fetch', fetchSpy) -}) - -afterEach(() => { - vi.unstubAllGlobals() + mockValidateUrlWithDNS.mockReset() + mockSecureFetch.mockReset() + mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) }) describe('executeAgiloftRequest', () => { - it('logs in, runs the operation with the bearer token, then logs out', async () => { - fetchSpy - .mockResolvedValueOnce(mockFetchResponse({ json: { access_token: 'tok-1' } })) - .mockResolvedValueOnce(mockFetchResponse({ json: { id: 42, fields: { name: 'foo' } } })) - .mockResolvedValueOnce(mockFetchResponse({})) + it('resolves DNS once, logs in, runs the operation with the bearer token, then logs out — all pinned', async () => { + mockSecureFetch + .mockResolvedValueOnce(mockResponse({ json: { access_token: 'tok-1' } })) + .mockResolvedValueOnce(mockResponse({ json: { id: 42, fields: { name: 'foo' } } })) + .mockResolvedValueOnce(mockResponse({})) const result = await executeAgiloftRequest( baseParams, @@ -59,24 +67,33 @@ describe('executeAgiloftRequest', () => { expect(result).toEqual({ success: true, output: { id: '42', fields: { name: 'foo' } } }) - const calls = fetchSpy.mock.calls + expect(mockValidateUrlWithDNS).toHaveBeenCalledWith( + 'https://example.agiloft.com', + 'instanceUrl' + ) + + const calls = mockSecureFetch.mock.calls expect(calls).toHaveLength(3) expect(calls[0][0]).toBe( 'https://example.agiloft.com/ewws/EWLogin?$KB=demo&$login=admin&$password=secret' ) expect(calls[1][0]).toBe('https://example.agiloft.com/ewws/REST/demo/contracts/42') - expect(calls[1][1]).toMatchObject({ + expect(calls[2][0]).toBe('https://example.agiloft.com/ewws/EWLogout?$KB=demo') + + for (const call of calls) { + expect(call[1]).toBe('203.0.113.10') + } + expect(calls[1][2]).toMatchObject({ method: 'GET', headers: { Accept: 'application/json', Authorization: 'Bearer tok-1' }, }) - expect(calls[2][0]).toBe('https://example.agiloft.com/ewws/EWLogout?$KB=demo') }) - it('still calls logout when the operation throws', async () => { - fetchSpy - .mockResolvedValueOnce(mockFetchResponse({ json: { access_token: 'tok-2' } })) - .mockResolvedValueOnce(mockFetchResponse({ ok: false, status: 500 })) - .mockResolvedValueOnce(mockFetchResponse({})) + it('still logs out when the operation throws', async () => { + mockSecureFetch + .mockResolvedValueOnce(mockResponse({ json: { access_token: 'tok-2' } })) + .mockResolvedValueOnce(mockResponse({ ok: false, status: 500 })) + .mockResolvedValueOnce(mockResponse({})) await expect( executeAgiloftRequest( @@ -89,14 +106,14 @@ describe('executeAgiloftRequest', () => { ) ).rejects.toThrow('operation failed') - expect(fetchSpy).toHaveBeenCalledTimes(3) - expect(fetchSpy.mock.calls[2][0]).toContain('/ewws/EWLogout') + expect(mockSecureFetch).toHaveBeenCalledTimes(3) + expect(mockSecureFetch.mock.calls[2][0]).toContain('/ewws/EWLogout') }) it('swallows logout failures (best-effort)', async () => { - fetchSpy - .mockResolvedValueOnce(mockFetchResponse({ json: { access_token: 'tok-3' } })) - .mockResolvedValueOnce(mockFetchResponse({ json: { ok: true } })) + mockSecureFetch + .mockResolvedValueOnce(mockResponse({ json: { access_token: 'tok-3' } })) + .mockResolvedValueOnce(mockResponse({ json: { ok: true } })) .mockRejectedValueOnce(new Error('logout network error')) const result = await executeAgiloftRequest( @@ -109,7 +126,7 @@ describe('executeAgiloftRequest', () => { }) it('throws when login does not return an access token', async () => { - fetchSpy.mockResolvedValueOnce(mockFetchResponse({ json: {} })) + mockSecureFetch.mockResolvedValueOnce(mockResponse({ json: {} })) await expect( executeAgiloftRequest( @@ -119,18 +136,23 @@ describe('executeAgiloftRequest', () => { ) ).rejects.toThrow('Agiloft login did not return an access token') - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(mockSecureFetch).toHaveBeenCalledTimes(1) }) - it('rejects an instance URL that fails synchronous URL validation', async () => { + it('rejects an instance URL that resolves to a blocked IP without issuing any request', async () => { + mockValidateUrlWithDNS.mockResolvedValue({ + isValid: false, + error: 'instanceUrl resolves to a blocked IP address', + }) + await expect( executeAgiloftRequest( - { ...baseParams, instanceUrl: 'not-a-valid-url' }, + { ...baseParams, instanceUrl: 'https://internal.attacker.com' }, (base) => ({ url: `${base}/ewws/REST/demo/contracts/42`, method: 'GET' }), async () => ({ success: true, output: {} }) ) - ).rejects.toThrow(/Invalid Agiloft instance URL/) + ).rejects.toThrow(/blocked IP address/) - expect(fetchSpy).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/tools/agiloft/utils.server.ts b/apps/sim/tools/agiloft/utils.server.ts index 3aaa0c62b7..4e48db8525 100644 --- a/apps/sim/tools/agiloft/utils.server.ts +++ b/apps/sim/tools/agiloft/utils.server.ts @@ -5,9 +5,17 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import type { AgiloftBaseParams } from '@/tools/agiloft/types' +import type { HttpMethod, ToolResponse } from '@/tools/types' const logger = createLogger('AgiloftAuthServer') +interface AgiloftRequestConfig { + url: string + method: HttpMethod + headers?: Record + body?: string +} + /** * Validates the Agiloft instance URL and resolves its DNS once, returning the * resolved IP so subsequent requests can pin to it. This prevents DNS-rebinding @@ -76,4 +84,49 @@ export async function agiloftLogoutPinned( } } +/** + * Shared wrapper that handles the full Agiloft auth lifecycle behind the + * codebase's SSRF-safe fetch path. The instance URL is validated and resolved + * to a concrete IP once via `validateUrlWithDNS` (which rejects hostnames that + * resolve to private/reserved addresses), and every hop — login, the operation + * request, and logout — is issued through `secureFetchWithPinnedIP` so the + * connection is pinned to that validated IP. This defeats DNS-rebinding (TOCTOU) + * SSRF where a hostname could resolve to an internal address on a later lookup. + * + * 1. Validate + resolve the instance URL once. + * 2. Login to obtain a Bearer token. + * 3. Execute the operation request with the token. + * 4. Logout to clean up the session (best-effort). + * + * The `buildRequest` callback receives the base URL and returns the request + * config. The `transformResponse` callback converts the raw response into the + * tool's output format. + * + * Server-only — uses node:dns/promises and node:http(s) via the pinned fetch. + */ +export async function executeAgiloftRequest( + params: AgiloftBaseParams, + buildRequest: (base: string) => AgiloftRequestConfig, + transformResponse: (response: SecureFetchResponse) => Promise +): Promise { + const resolvedIP = await resolveAgiloftInstance(params.instanceUrl) + const token = await agiloftLoginPinned(params, resolvedIP) + const base = params.instanceUrl.replace(/\/$/, '') + + try { + const req = buildRequest(base) + const response = await secureFetchWithPinnedIP(req.url, resolvedIP, { + method: req.method, + headers: { + ...req.headers, + Authorization: `Bearer ${token}`, + }, + body: req.body, + }) + return await transformResponse(response) + } finally { + await agiloftLogoutPinned(params.instanceUrl, params.knowledgeBase, token, resolvedIP) + } +} + export type { SecureFetchResponse } diff --git a/apps/sim/tools/agiloft/utils.ts b/apps/sim/tools/agiloft/utils.ts index 811187ab83..34efd86efc 100644 --- a/apps/sim/tools/agiloft/utils.ts +++ b/apps/sim/tools/agiloft/utils.ts @@ -1,5 +1,3 @@ -import { createLogger } from '@sim/logger' -import { validateExternalUrl } from '@/lib/core/security/input-validation' import type { AgiloftAttachmentInfoParams, AgiloftBaseParams, @@ -13,108 +11,7 @@ import type { AgiloftSearchRecordsParams, AgiloftSelectRecordsParams, } from '@/tools/agiloft/types' -import type { HttpMethod, ToolResponse } from '@/tools/types' - -const logger = createLogger('AgiloftAuth') - -interface AgiloftRequestConfig { - url: string - method: HttpMethod - headers?: Record - body?: BodyInit -} - -/** - * Exchanges login/password for a short-lived Bearer token via EWLogin. - */ -async function agiloftLogin(params: AgiloftBaseParams): Promise { - const base = params.instanceUrl.replace(/\/$/, '') - - const urlValidation = validateExternalUrl(params.instanceUrl, 'instanceUrl') - if (!urlValidation.isValid) { - throw new Error(`Invalid Agiloft instance URL: ${urlValidation.error}`) - } - - const kb = encodeURIComponent(params.knowledgeBase) - const login = encodeURIComponent(params.login) - const password = encodeURIComponent(params.password) - - const url = `${base}/ewws/EWLogin?$KB=${kb}&$login=${login}&$password=${password}` - const response = await fetch(url, { method: 'POST' }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Agiloft login failed: ${response.status} - ${errorText}`) - } - - const data = (await response.json()) as { access_token?: string } - const token = data.access_token - - if (!token) { - throw new Error('Agiloft login did not return an access token') - } - - return token -} - -/** - * Cleans up the server session. Best-effort — failures are logged but not thrown. - */ -async function agiloftLogout( - instanceUrl: string, - knowledgeBase: string, - token: string -): Promise { - try { - const base = instanceUrl.replace(/\/$/, '') - const kb = encodeURIComponent(knowledgeBase) - await fetch(`${base}/ewws/EWLogout?$KB=${kb}`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }) - } catch (error) { - logger.warn('Agiloft logout failed (best-effort)', { error }) - } -} - -/** - * Shared wrapper that handles the full auth lifecycle: - * 1. Login to get Bearer token - * 2. Execute the request with the token - * 3. Logout to clean up the session - * - * The `buildRequest` callback receives the token and base URL, and returns - * the request config. The `transformResponse` callback converts the raw - * Response into the tool's output format. - */ -export async function executeAgiloftRequest( - params: AgiloftBaseParams, - buildRequest: (base: string) => AgiloftRequestConfig, - transformResponse: (response: Response) => Promise -): Promise { - const token = await agiloftLogin(params) - const base = params.instanceUrl.replace(/\/$/, '') - - try { - const req = buildRequest(base) - const response = await fetch(req.url, { - method: req.method, - headers: { - ...req.headers, - Authorization: `Bearer ${token}`, - }, - body: req.body, - }) - return await transformResponse(response) - } finally { - await agiloftLogout(params.instanceUrl, params.knowledgeBase, token) - } -} - -/** - * Login helper exported for use in the attach file API route. - */ -export { agiloftLogin, agiloftLogout } +import type { HttpMethod } from '@/tools/types' /** URL builders (credential-free -- auth is via Bearer token header) */ diff --git a/apps/sim/tools/grafana/update_alert_rule.ts b/apps/sim/tools/grafana/update_alert_rule.ts index 19f2bf8164..ba474e2ed9 100644 --- a/apps/sim/tools/grafana/update_alert_rule.ts +++ b/apps/sim/tools/grafana/update_alert_rule.ts @@ -1,9 +1,11 @@ -import { validateExternalUrl } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import { ALERT_RULE_OUTPUT_FIELDS, type GrafanaUpdateAlertRuleParams } from '@/tools/grafana/types' import { mapAlertRule } from '@/tools/grafana/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' -// Using ToolResponse for intermediate state since this tool fetches existing data first export const updateAlertRuleTool: ToolConfig = { id: 'grafana_update_alert_rule', name: 'Grafana Update Alert Rule', @@ -134,7 +136,6 @@ export const updateAlertRuleTool: ToolConfig `${params.baseUrl.replace(/\/$/, '')}/api/v1/provisioning/alert-rules/${params.alertRuleUid}`, method: 'GET', @@ -151,7 +152,6 @@ export const updateAlertRuleTool: ToolConfig { - // Store the existing rule data for postProcess to use const data = await response.json() return { success: true, @@ -162,7 +162,6 @@ export const updateAlertRuleTool: ToolConfig { - // Merge user changes with existing rule and PUT the complete object const existingRule = result.output._existingRule if (!existingRule || !existingRule.uid) { @@ -173,12 +172,10 @@ export const updateAlertRuleTool: ToolConfig = { ...existingRule, } - // Apply user's changes if (params.title) updatedRule.title = params.title if (params.folderUid) updatedRule.folderUID = params.folderUid if (params.ruleGroup) updatedRule.ruleGroup = params.ruleGroup @@ -258,7 +255,6 @@ export const updateAlertRuleTool: ToolConfig = { 'Content-Type': 'application/json', Authorization: `Bearer ${params.apiKey}`, @@ -270,8 +266,9 @@ export const updateAlertRuleTool: ToolConfig = { id: 'grafana_update_dashboard', name: 'Grafana Update Dashboard', @@ -87,7 +89,6 @@ export const updateDashboardTool: ToolConfig `${params.baseUrl.replace(/\/$/, '')}/api/dashboards/uid/${params.dashboardUid}`, method: 'GET', @@ -104,7 +105,6 @@ export const updateDashboardTool: ToolConfig { - // Store the existing dashboard data for postProcess to use const data = await response.json() return { success: true, @@ -116,7 +116,6 @@ export const updateDashboardTool: ToolConfig { - // Merge user changes with existing dashboard and POST the complete object const existingDashboard = result.output._existingDashboard const existingMeta = result.output._existingMeta @@ -128,12 +127,10 @@ export const updateDashboardTool: ToolConfig = { ...existingDashboard, } - // Apply user's changes if (params.title) updatedDashboard.title = params.title if (params.timezone) updatedDashboard.timezone = params.timezone if (params.refresh) updatedDashboard.refresh = params.refresh @@ -148,23 +145,18 @@ export const updateDashboardTool: ToolConfig = { dashboard: updatedDashboard, overwrite: params.overwrite === true, } - // Use existing folder if not specified if (params.folderUid) { body.folderUid = params.folderUid } else if (existingMeta?.folderUid) { @@ -175,7 +167,6 @@ export const updateDashboardTool: ToolConfig = { 'Content-Type': 'application/json', Authorization: `Bearer ${params.apiKey}`, @@ -184,8 +175,9 @@ export const updateDashboardTool: ToolConfig { + if (!folderId) return true + + const [folder] = await db + .select({ + workspaceId: workflowFolder.workspaceId, + archivedAt: workflowFolder.archivedAt, + }) + .from(workflowFolder) + .where(eq(workflowFolder.id, folderId)) + .limit(1) + + return Boolean(folder && folder.workspaceId === workspaceId && !folder.archivedAt) +} + +/** + * Throws {@link FolderNotFoundError} (HTTP 400) when `folderId` does not belong to + * `workspaceId` (or is archived/missing). No-op for a null/undefined folderId. + */ +export async function assertFolderInWorkspace( + folderId: string | null | undefined, + workspaceId: string +): Promise { + if (!(await isFolderInWorkspace(folderId, workspaceId))) { + throw new FolderNotFoundError() + } +} + export interface WorkflowWorkspaceAuthorizationResult { allowed: boolean status: number From 1ff445ae80e88e5ae0ffa0fad336eb60a2dd67dc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 10 Jun 2026 11:51:24 -0700 Subject: [PATCH 07/10] feat(codepipeline): add AWS CodePipeline integration with tools and block (#4945) * feat(codepipeline): add AWS CodePipeline integration with tools and block * fix(codepipeline): address review feedback on input coercion and error statuses * chore(hooks): restore use-inline-rename onSave type accidentally swept into previous commit --- apps/docs/components/icons.tsx | 27 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/tools/codepipeline.mdx | 237 +++++++ apps/docs/content/docs/en/tools/meta.json | 1 + .../get-pipeline-execution/route.ts | 87 +++ .../codepipeline/get-pipeline-state/route.ts | 84 +++ .../list-pipeline-executions/route.ts | 87 +++ .../codepipeline/list-pipelines/route.ts | 73 +++ .../codepipeline/put-approval-result/route.ts | 74 +++ .../retry-stage-execution/route.ts | 69 ++ .../codepipeline/start-execution/route.ts | 69 ++ .../codepipeline/stop-execution/route.ts | 65 ++ apps/sim/app/api/tools/codepipeline/utils.ts | 10 + apps/sim/blocks/blocks/codepipeline.ts | 608 ++++++++++++++++++ apps/sim/blocks/registry.ts | 3 + apps/sim/components/icons.tsx | 27 + .../codepipeline-get-pipeline-execution.ts | 70 ++ .../aws/codepipeline-get-pipeline-state.ts | 73 +++ .../codepipeline-list-pipeline-executions.ts | 74 +++ .../tools/aws/codepipeline-list-pipelines.ts | 57 ++ .../aws/codepipeline-put-approval-result.ts | 61 ++ .../aws/codepipeline-retry-stage-execution.ts | 52 ++ .../tools/aws/codepipeline-start-execution.ts | 62 ++ .../tools/aws/codepipeline-stop-execution.ts | 49 ++ apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/integrations/integrations.json | 51 ++ apps/sim/package.json | 1 + .../codepipeline/get_pipeline_execution.ts | 153 +++++ .../tools/codepipeline/get_pipeline_state.ts | 119 ++++ apps/sim/tools/codepipeline/index.ts | 19 + .../codepipeline/list_pipeline_executions.ts | 140 ++++ apps/sim/tools/codepipeline/list_pipelines.ts | 105 +++ .../tools/codepipeline/put_approval_result.ts | 120 ++++ .../codepipeline/retry_stage_execution.ts | 99 +++ .../sim/tools/codepipeline/start_execution.ts | 92 +++ apps/sim/tools/codepipeline/stop_execution.ts | 100 +++ apps/sim/tools/codepipeline/types.ts | 182 ++++++ apps/sim/tools/registry.ts | 18 + bun.lock | 3 + scripts/check-api-validation-contracts.ts | 4 +- 40 files changed, 3227 insertions(+), 2 deletions(-) create mode 100644 apps/docs/content/docs/en/tools/codepipeline.mdx create mode 100644 apps/sim/app/api/tools/codepipeline/get-pipeline-execution/route.ts create mode 100644 apps/sim/app/api/tools/codepipeline/get-pipeline-state/route.ts create mode 100644 apps/sim/app/api/tools/codepipeline/list-pipeline-executions/route.ts create mode 100644 apps/sim/app/api/tools/codepipeline/list-pipelines/route.ts create mode 100644 apps/sim/app/api/tools/codepipeline/put-approval-result/route.ts create mode 100644 apps/sim/app/api/tools/codepipeline/retry-stage-execution/route.ts create mode 100644 apps/sim/app/api/tools/codepipeline/start-execution/route.ts create mode 100644 apps/sim/app/api/tools/codepipeline/stop-execution/route.ts create mode 100644 apps/sim/app/api/tools/codepipeline/utils.ts create mode 100644 apps/sim/blocks/blocks/codepipeline.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/codepipeline-get-pipeline-execution.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/codepipeline-get-pipeline-state.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipelines.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/codepipeline-put-approval-result.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/codepipeline-retry-stage-execution.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/codepipeline-start-execution.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/codepipeline-stop-execution.ts create mode 100644 apps/sim/tools/codepipeline/get_pipeline_execution.ts create mode 100644 apps/sim/tools/codepipeline/get_pipeline_state.ts create mode 100644 apps/sim/tools/codepipeline/index.ts create mode 100644 apps/sim/tools/codepipeline/list_pipeline_executions.ts create mode 100644 apps/sim/tools/codepipeline/list_pipelines.ts create mode 100644 apps/sim/tools/codepipeline/put_approval_result.ts create mode 100644 apps/sim/tools/codepipeline/retry_stage_execution.ts create mode 100644 apps/sim/tools/codepipeline/start_execution.ts create mode 100644 apps/sim/tools/codepipeline/stop_execution.ts create mode 100644 apps/sim/tools/codepipeline/types.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 106c6bc95e..4750caa45d 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -5512,6 +5512,33 @@ export function CloudWatchIcon(props: SVGProps) { ) } +export function CodePipelineIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function TextractIcon(props: SVGProps) { return ( = { cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, + codepipeline: CodePipelineIcon, confluence: ConfluenceIcon, confluence_v2: ConfluenceIcon, crowdstrike: CrowdStrikeIcon, diff --git a/apps/docs/content/docs/en/tools/codepipeline.mdx b/apps/docs/content/docs/en/tools/codepipeline.mdx new file mode 100644 index 0000000000..29144ab1df --- /dev/null +++ b/apps/docs/content/docs/en/tools/codepipeline.mdx @@ -0,0 +1,237 @@ +--- +title: CodePipeline +description: Run, monitor, and approve AWS CodePipeline pipelines +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate AWS CodePipeline into workflows. Start, stop, and monitor pipeline executions, retry failed stages, and approve or reject manual approval actions. Requires AWS access key and secret access key. + + + +## Tools + +### `codepipeline_list_pipelines` + +List all CodePipeline pipelines in an AWS account and region + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `maxResults` | number | No | Maximum number of pipelines to return \(1-1000\) | +| `nextToken` | string | No | Pagination token from a previous call | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pipelines` | array | List of pipelines with name, version, type, and timestamps | +| ↳ `name` | string | Pipeline name | +| ↳ `version` | number | Pipeline version number | +| ↳ `pipelineType` | string | Pipeline type \(V1 or V2\) | +| ↳ `executionMode` | string | Execution mode \(QUEUED, SUPERSEDED, PARALLEL\) | +| ↳ `created` | number | Epoch ms when the pipeline was created | +| ↳ `updated` | number | Epoch ms when the pipeline was last updated | +| `nextToken` | string | Pagination token for the next page of results | + +### `codepipeline_get_pipeline_state` + +Get the current state of a CodePipeline pipeline, including stage and action status and pending approval tokens + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `pipelineName` | string | Yes | Name of the pipeline | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pipelineName` | string | Pipeline name | +| `pipelineVersion` | number | Pipeline version number | +| `created` | number | Epoch ms when the pipeline was created | +| `updated` | number | Epoch ms when the pipeline was last updated | +| `stageStates` | array | Per-stage state including latest execution status and action details | +| ↳ `stageName` | string | Stage name | +| ↳ `status` | string | Latest stage execution status \(InProgress, Succeeded, Failed, Stopped, Cancelled\) | +| ↳ `pipelineExecutionId` | string | Pipeline execution ID currently in the stage | +| ↳ `inboundTransitionEnabled` | boolean | Whether the inbound transition into the stage is enabled | +| ↳ `actionStates` | array | Per-action state with status, summary, error details, and approval token \(for pending manual approvals\) | + +### `codepipeline_get_pipeline_execution` + +Get details of a CodePipeline execution, including status, trigger, source revisions, and resolved variables + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `pipelineName` | string | Yes | Name of the pipeline | +| `pipelineExecutionId` | string | Yes | ID of the pipeline execution | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pipelineExecutionId` | string | Pipeline execution ID | +| `pipelineName` | string | Pipeline name | +| `pipelineVersion` | number | Pipeline version number | +| `status` | string | Execution status \(Cancelled, InProgress, Stopped, Stopping, Succeeded, Superseded, Failed\) | +| `statusSummary` | string | Status summary for the execution | +| `executionMode` | string | Execution mode \(QUEUED, SUPERSEDED, PARALLEL\) | +| `executionType` | string | Execution type \(STANDARD or ROLLBACK\) | +| `triggerType` | string | What triggered the execution \(e.g., Webhook, StartPipelineExecution\) | +| `triggerDetail` | string | Detail about the trigger \(e.g., user ARN\) | +| `artifactRevisions` | array | Source artifact revisions for the execution | +| ↳ `name` | string | Artifact name | +| ↳ `revisionId` | string | Revision ID \(e.g., commit SHA\) | +| ↳ `revisionSummary` | string | Revision summary \(e.g., commit message\) | +| ↳ `revisionUrl` | string | URL of the revision | +| ↳ `created` | number | Epoch ms when the revision was created | +| `variables` | array | Resolved pipeline variables for the execution | +| ↳ `name` | string | Variable name | +| ↳ `resolvedValue` | string | Resolved variable value | + +### `codepipeline_list_pipeline_executions` + +List recent executions of a CodePipeline pipeline with status and source revisions + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `pipelineName` | string | Yes | Name of the pipeline | +| `maxResults` | number | No | Maximum number of executions to return \(1-100, default 100\) | +| `nextToken` | string | No | Pagination token from a previous call | +| `succeededInStage` | string | No | Only return executions that succeeded in this stage | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `executions` | array | Pipeline execution summaries, most recent first | +| ↳ `pipelineExecutionId` | string | Pipeline execution ID | +| ↳ `status` | string | Execution status \(Cancelled, InProgress, Stopped, Stopping, Succeeded, Superseded, Failed\) | +| ↳ `statusSummary` | string | Status summary for the execution | +| ↳ `startTime` | number | Epoch ms when the execution started | +| ↳ `lastUpdateTime` | number | Epoch ms when the execution was last updated | +| ↳ `executionMode` | string | Execution mode \(QUEUED, SUPERSEDED, PARALLEL\) | +| ↳ `executionType` | string | Execution type \(STANDARD or ROLLBACK\) | +| ↳ `stopTriggerReason` | string | Reason the execution was stopped, if applicable | +| ↳ `triggerType` | string | What triggered the execution | +| ↳ `triggerDetail` | string | Detail about the trigger | +| ↳ `sourceRevisions` | array | Source revisions \(commit IDs, summaries, URLs\) for the execution | +| `nextToken` | string | Pagination token for the next page of results | + +### `codepipeline_start_execution` + +Start a CodePipeline pipeline execution, optionally overriding pipeline variables + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `pipelineName` | string | Yes | Name of the pipeline to start | +| `clientRequestToken` | string | No | Idempotency token to identify a unique execution request | +| `variables` | json | No | Pipeline variable overrides as an array of \{ name, value \} objects | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pipelineExecutionId` | string | ID of the pipeline execution that was started | + +### `codepipeline_stop_execution` + +Stop a CodePipeline pipeline execution, either finishing in-progress actions or abandoning them + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `pipelineName` | string | Yes | Name of the pipeline | +| `pipelineExecutionId` | string | Yes | ID of the pipeline execution to stop | +| `abandon` | boolean | No | Abandon in-progress actions instead of letting them finish \(default false\) | +| `reason` | string | No | Reason for stopping the execution \(max 200 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pipelineExecutionId` | string | ID of the pipeline execution that was stopped | + +### `codepipeline_retry_stage_execution` + +Retry the failed actions (or all actions) of a failed CodePipeline stage + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `pipelineName` | string | Yes | Name of the pipeline | +| `stageName` | string | Yes | Name of the failed stage to retry | +| `pipelineExecutionId` | string | Yes | ID of the pipeline execution in the failed stage | +| `retryMode` | string | Yes | Scope of the retry: FAILED_ACTIONS or ALL_ACTIONS | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pipelineExecutionId` | string | ID of the pipeline execution with the retried stage | + +### `codepipeline_put_approval_result` + +Approve or reject a pending CodePipeline manual approval action. The approval token is available from Get Pipeline State on the pending approval action + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `pipelineName` | string | Yes | Name of the pipeline | +| `stageName` | string | Yes | Name of the stage containing the approval action | +| `actionName` | string | Yes | Name of the manual approval action | +| `token` | string | Yes | Approval token from Get Pipeline State for the pending approval | +| `status` | string | Yes | Approval decision: Approved or Rejected | +| `summary` | string | Yes | Summary explaining the approval decision \(max 512 characters\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `approvedAt` | number | Epoch ms when the approval or rejection was submitted | +| `status` | string | The submitted approval decision \(Approved or Rejected\) | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index e0b089a1c5..710e833a85 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -30,6 +30,7 @@ "cloudflare", "cloudformation", "cloudwatch", + "codepipeline", "confluence", "crowdstrike", "cursor", diff --git a/apps/sim/app/api/tools/codepipeline/get-pipeline-execution/route.ts b/apps/sim/app/api/tools/codepipeline/get-pipeline-execution/route.ts new file mode 100644 index 0000000000..f56a815371 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/get-pipeline-execution/route.ts @@ -0,0 +1,87 @@ +import { CodePipelineClient, GetPipelineExecutionCommand } from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineGetPipelineExecutionContract } from '@/lib/api/contracts/tools/aws/codepipeline-get-pipeline-execution' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineGetPipelineExecution') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineGetPipelineExecutionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Getting CodePipeline pipeline execution') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new GetPipelineExecutionCommand({ + pipelineName: validatedData.pipelineName, + pipelineExecutionId: validatedData.pipelineExecutionId, + }) + + const response = await client.send(command) + const execution = response.pipelineExecution + + if (!execution) { + throw new Error('Pipeline execution not found in response') + } + + logger.info('Successfully got pipeline execution') + + return NextResponse.json({ + success: true, + output: { + pipelineExecutionId: execution.pipelineExecutionId ?? validatedData.pipelineExecutionId, + pipelineName: execution.pipelineName ?? validatedData.pipelineName, + pipelineVersion: execution.pipelineVersion, + status: execution.status ?? 'Unknown', + statusSummary: execution.statusSummary, + executionMode: execution.executionMode, + executionType: execution.executionType, + triggerType: execution.trigger?.triggerType, + triggerDetail: execution.trigger?.triggerDetail, + artifactRevisions: (execution.artifactRevisions ?? []).map((r) => ({ + name: r.name ?? '', + revisionId: r.revisionId, + revisionSummary: r.revisionSummary, + revisionUrl: r.revisionUrl, + created: r.created?.getTime(), + })), + variables: (execution.variables ?? []).map((v) => ({ + name: v.name ?? '', + resolvedValue: v.resolvedValue ?? '', + })), + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('GetPipelineExecution failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to get CodePipeline pipeline execution: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/get-pipeline-state/route.ts b/apps/sim/app/api/tools/codepipeline/get-pipeline-state/route.ts new file mode 100644 index 0000000000..8dcbc8dcf6 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/get-pipeline-state/route.ts @@ -0,0 +1,84 @@ +import { CodePipelineClient, GetPipelineStateCommand } from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineGetPipelineStateContract } from '@/lib/api/contracts/tools/aws/codepipeline-get-pipeline-state' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineGetPipelineState') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineGetPipelineStateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Getting CodePipeline pipeline state') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new GetPipelineStateCommand({ name: validatedData.pipelineName }) + const response = await client.send(command) + + const stageStates = (response.stageStates ?? []).map((stage) => ({ + stageName: stage.stageName ?? '', + status: stage.latestExecution?.status, + pipelineExecutionId: stage.latestExecution?.pipelineExecutionId, + inboundTransitionEnabled: stage.inboundTransitionState?.enabled, + actionStates: (stage.actionStates ?? []).map((action) => ({ + actionName: action.actionName ?? '', + status: action.latestExecution?.status, + summary: action.latestExecution?.summary, + lastStatusChange: action.latestExecution?.lastStatusChange?.getTime(), + externalExecutionId: action.latestExecution?.externalExecutionId, + externalExecutionUrl: action.latestExecution?.externalExecutionUrl, + errorCode: action.latestExecution?.errorDetails?.code, + errorMessage: action.latestExecution?.errorDetails?.message, + percentComplete: action.latestExecution?.percentComplete, + token: action.latestExecution?.token, + revisionId: action.currentRevision?.revisionId, + entityUrl: action.entityUrl, + })), + })) + + logger.info(`Successfully got pipeline state with ${stageStates.length} stages`) + + return NextResponse.json({ + success: true, + output: { + pipelineName: response.pipelineName ?? validatedData.pipelineName, + pipelineVersion: response.pipelineVersion, + created: response.created?.getTime(), + updated: response.updated?.getTime(), + stageStates, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('GetPipelineState failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to get CodePipeline pipeline state: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/list-pipeline-executions/route.ts b/apps/sim/app/api/tools/codepipeline/list-pipeline-executions/route.ts new file mode 100644 index 0000000000..2b8a579ca7 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/list-pipeline-executions/route.ts @@ -0,0 +1,87 @@ +import { CodePipelineClient, ListPipelineExecutionsCommand } from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineListPipelineExecutionsContract } from '@/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineListPipelineExecutions') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineListPipelineExecutionsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Listing CodePipeline pipeline executions') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new ListPipelineExecutionsCommand({ + pipelineName: validatedData.pipelineName, + ...(validatedData.maxResults !== undefined && { maxResults: validatedData.maxResults }), + ...(validatedData.nextToken && { nextToken: validatedData.nextToken }), + ...(validatedData.succeededInStage && { + filter: { succeededInStage: { stageName: validatedData.succeededInStage } }, + }), + }) + + const response = await client.send(command) + + const executions = (response.pipelineExecutionSummaries ?? []).map((e) => ({ + pipelineExecutionId: e.pipelineExecutionId ?? '', + status: e.status ?? 'Unknown', + statusSummary: e.statusSummary, + startTime: e.startTime?.getTime(), + lastUpdateTime: e.lastUpdateTime?.getTime(), + executionMode: e.executionMode, + executionType: e.executionType, + stopTriggerReason: e.stopTrigger?.reason, + triggerType: e.trigger?.triggerType, + triggerDetail: e.trigger?.triggerDetail, + sourceRevisions: (e.sourceRevisions ?? []).map((r) => ({ + actionName: r.actionName ?? '', + revisionId: r.revisionId, + revisionSummary: r.revisionSummary, + revisionUrl: r.revisionUrl, + })), + })) + + logger.info(`Successfully listed ${executions.length} pipeline executions`) + + return NextResponse.json({ + success: true, + output: { + executions, + ...(response.nextToken && { nextToken: response.nextToken }), + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('ListPipelineExecutions failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to list CodePipeline pipeline executions: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/list-pipelines/route.ts b/apps/sim/app/api/tools/codepipeline/list-pipelines/route.ts new file mode 100644 index 0000000000..bf3cb9e96d --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/list-pipelines/route.ts @@ -0,0 +1,73 @@ +import { CodePipelineClient, ListPipelinesCommand } from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineListPipelinesContract } from '@/lib/api/contracts/tools/aws/codepipeline-list-pipelines' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineListPipelines') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineListPipelinesContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Listing CodePipeline pipelines') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new ListPipelinesCommand({ + ...(validatedData.maxResults !== undefined && { maxResults: validatedData.maxResults }), + ...(validatedData.nextToken && { nextToken: validatedData.nextToken }), + }) + + const response = await client.send(command) + + const pipelines = (response.pipelines ?? []).map((p) => ({ + name: p.name ?? '', + version: p.version, + pipelineType: p.pipelineType, + executionMode: p.executionMode, + created: p.created?.getTime(), + updated: p.updated?.getTime(), + })) + + logger.info(`Successfully listed ${pipelines.length} pipelines`) + + return NextResponse.json({ + success: true, + output: { + pipelines, + ...(response.nextToken && { nextToken: response.nextToken }), + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('ListPipelines failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to list CodePipeline pipelines: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/put-approval-result/route.ts b/apps/sim/app/api/tools/codepipeline/put-approval-result/route.ts new file mode 100644 index 0000000000..c58d2c27dc --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/put-approval-result/route.ts @@ -0,0 +1,74 @@ +import { + type ApprovalStatus, + CodePipelineClient, + PutApprovalResultCommand, +} from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelinePutApprovalResultContract } from '@/lib/api/contracts/tools/aws/codepipeline-put-approval-result' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelinePutApprovalResult') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelinePutApprovalResultContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Submitting CodePipeline approval result') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new PutApprovalResultCommand({ + pipelineName: validatedData.pipelineName, + stageName: validatedData.stageName, + actionName: validatedData.actionName, + token: validatedData.token, + result: { + status: validatedData.status as ApprovalStatus, + summary: validatedData.summary, + }, + }) + + const response = await client.send(command) + + logger.info('Successfully submitted approval result') + + return NextResponse.json({ + success: true, + output: { + approvedAt: response.approvedAt?.getTime(), + status: validatedData.status, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('PutApprovalResult failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to submit CodePipeline approval result: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/retry-stage-execution/route.ts b/apps/sim/app/api/tools/codepipeline/retry-stage-execution/route.ts new file mode 100644 index 0000000000..0886a5409f --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/retry-stage-execution/route.ts @@ -0,0 +1,69 @@ +import { + CodePipelineClient, + RetryStageExecutionCommand, + type StageRetryMode, +} from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineRetryStageExecutionContract } from '@/lib/api/contracts/tools/aws/codepipeline-retry-stage-execution' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineRetryStageExecution') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineRetryStageExecutionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Retrying CodePipeline stage execution') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new RetryStageExecutionCommand({ + pipelineName: validatedData.pipelineName, + stageName: validatedData.stageName, + pipelineExecutionId: validatedData.pipelineExecutionId, + retryMode: validatedData.retryMode as StageRetryMode, + }) + + const response = await client.send(command) + + logger.info('Successfully retried stage execution') + + return NextResponse.json({ + success: true, + output: { + pipelineExecutionId: response.pipelineExecutionId ?? validatedData.pipelineExecutionId, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('RetryStageExecution failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to retry CodePipeline stage execution: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/start-execution/route.ts b/apps/sim/app/api/tools/codepipeline/start-execution/route.ts new file mode 100644 index 0000000000..6d438d1bd7 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/start-execution/route.ts @@ -0,0 +1,69 @@ +import { CodePipelineClient, StartPipelineExecutionCommand } from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineStartExecutionContract } from '@/lib/api/contracts/tools/aws/codepipeline-start-execution' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineStartExecution') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineStartExecutionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Starting CodePipeline pipeline execution') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new StartPipelineExecutionCommand({ + name: validatedData.pipelineName, + ...(validatedData.clientRequestToken && { + clientRequestToken: validatedData.clientRequestToken, + }), + ...(validatedData.variables && + validatedData.variables.length > 0 && { variables: validatedData.variables }), + }) + + const response = await client.send(command) + + if (!response.pipelineExecutionId) { + throw new Error('No pipeline execution ID returned') + } + + logger.info('Successfully started pipeline execution') + + return NextResponse.json({ + success: true, + output: { pipelineExecutionId: response.pipelineExecutionId }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('StartPipelineExecution failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to start CodePipeline pipeline execution: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/stop-execution/route.ts b/apps/sim/app/api/tools/codepipeline/stop-execution/route.ts new file mode 100644 index 0000000000..bf01f37945 --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/stop-execution/route.ts @@ -0,0 +1,65 @@ +import { CodePipelineClient, StopPipelineExecutionCommand } from '@aws-sdk/client-codepipeline' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCodepipelineStopExecutionContract } from '@/lib/api/contracts/tools/aws/codepipeline-stop-execution' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { awsErrorStatus } from '@/app/api/tools/codepipeline/utils' + +const logger = createLogger('CodePipelineStopExecution') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCodepipelineStopExecutionContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info('Stopping CodePipeline pipeline execution') + + const client = new CodePipelineClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new StopPipelineExecutionCommand({ + pipelineName: validatedData.pipelineName, + pipelineExecutionId: validatedData.pipelineExecutionId, + ...(validatedData.abandon !== undefined && { abandon: validatedData.abandon }), + ...(validatedData.reason && { reason: validatedData.reason }), + }) + + const response = await client.send(command) + + logger.info('Successfully stopped pipeline execution') + + return NextResponse.json({ + success: true, + output: { + pipelineExecutionId: response.pipelineExecutionId ?? validatedData.pipelineExecutionId, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('StopPipelineExecution failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to stop CodePipeline pipeline execution: ${toError(error).message}` }, + { status: awsErrorStatus(error) } + ) + } +}) diff --git a/apps/sim/app/api/tools/codepipeline/utils.ts b/apps/sim/app/api/tools/codepipeline/utils.ts new file mode 100644 index 0000000000..9b191bfdff --- /dev/null +++ b/apps/sim/app/api/tools/codepipeline/utils.ts @@ -0,0 +1,10 @@ +/** + * Maps an AWS SDK error to a response status. Client faults (e.g. + * PipelineNotFoundException, InvalidApprovalTokenException) keep the 4xx + * status AWS reports via `$metadata`; everything else maps to 500. + */ +export function awsErrorStatus(error: unknown): number { + const status = (error as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata + ?.httpStatusCode + return typeof status === 'number' && status >= 400 && status < 500 ? status : 500 +} diff --git a/apps/sim/blocks/blocks/codepipeline.ts b/apps/sim/blocks/blocks/codepipeline.ts new file mode 100644 index 0000000000..b3bd4c36ac --- /dev/null +++ b/apps/sim/blocks/blocks/codepipeline.ts @@ -0,0 +1,608 @@ +import { CodePipelineIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { + parseOptionalBooleanInput, + parseOptionalJsonInput, + parseOptionalNumberInput, +} from '@/blocks/utils' +import type { + CodePipelineGetPipelineExecutionResponse, + CodePipelineGetPipelineStateResponse, + CodePipelineListPipelineExecutionsResponse, + CodePipelineListPipelinesResponse, + CodePipelinePutApprovalResultResponse, + CodePipelineRetryStageExecutionResponse, + CodePipelineStartExecutionResponse, + CodePipelineStopExecutionResponse, +} from '@/tools/codepipeline/types' + +export const CodePipelineBlock: BlockConfig< + | CodePipelineListPipelinesResponse + | CodePipelineGetPipelineStateResponse + | CodePipelineGetPipelineExecutionResponse + | CodePipelineListPipelineExecutionsResponse + | CodePipelineStartExecutionResponse + | CodePipelineStopExecutionResponse + | CodePipelineRetryStageExecutionResponse + | CodePipelinePutApprovalResultResponse +> = { + type: 'codepipeline', + name: 'CodePipeline', + description: 'Run, monitor, and approve AWS CodePipeline pipelines', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate AWS CodePipeline into workflows. Start, stop, and monitor pipeline executions, retry failed stages, and approve or reject manual approval actions. Requires AWS access key and secret access key.', + docsLink: 'https://docs.sim.ai/tools/codepipeline', + category: 'tools', + integrationType: IntegrationType.DevOps, + bgColor: 'linear-gradient(45deg, #2E27AD 0%, #527FFF 100%)', + iconColor: '#527FFF', + icon: CodePipelineIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Start Execution', id: 'start_execution' }, + { label: 'Get Pipeline State', id: 'get_pipeline_state' }, + { label: 'List Pipelines', id: 'list_pipelines' }, + { label: 'List Executions', id: 'list_pipeline_executions' }, + { label: 'Get Execution', id: 'get_pipeline_execution' }, + { label: 'Stop Execution', id: 'stop_execution' }, + { label: 'Retry Stage', id: 'retry_stage_execution' }, + { label: 'Approve / Reject Approval', id: 'put_approval_result' }, + ], + value: () => 'start_execution', + }, + { + id: 'awsRegion', + title: 'AWS Region', + type: 'short-input', + placeholder: 'us-east-1', + required: true, + }, + { + id: 'awsAccessKeyId', + title: 'AWS Access Key ID', + type: 'short-input', + placeholder: 'AKIA...', + password: true, + required: true, + }, + { + id: 'awsSecretAccessKey', + title: 'AWS Secret Access Key', + type: 'short-input', + placeholder: 'Your secret access key', + password: true, + required: true, + }, + { + id: 'pipelineName', + title: 'Pipeline Name', + type: 'short-input', + placeholder: 'my-pipeline', + condition: { + field: 'operation', + value: [ + 'start_execution', + 'get_pipeline_state', + 'list_pipeline_executions', + 'get_pipeline_execution', + 'stop_execution', + 'retry_stage_execution', + 'put_approval_result', + ], + }, + required: { + field: 'operation', + value: [ + 'start_execution', + 'get_pipeline_state', + 'list_pipeline_executions', + 'get_pipeline_execution', + 'stop_execution', + 'retry_stage_execution', + 'put_approval_result', + ], + }, + }, + { + id: 'pipelineExecutionId', + title: 'Execution ID', + type: 'short-input', + placeholder: 'e.g., 3137f7cb-7cf7-4abc-9f1d-d7eu3471a2b1', + condition: { + field: 'operation', + value: ['get_pipeline_execution', 'stop_execution', 'retry_stage_execution'], + }, + required: { + field: 'operation', + value: ['get_pipeline_execution', 'stop_execution', 'retry_stage_execution'], + }, + }, + { + id: 'stageName', + title: 'Stage Name', + type: 'short-input', + placeholder: 'e.g., Deploy', + condition: { field: 'operation', value: ['retry_stage_execution', 'put_approval_result'] }, + required: { field: 'operation', value: ['retry_stage_execution', 'put_approval_result'] }, + }, + { + id: 'retryMode', + title: 'Retry Mode', + type: 'dropdown', + options: [ + { label: 'Failed Actions Only', id: 'FAILED_ACTIONS' }, + { label: 'All Actions', id: 'ALL_ACTIONS' }, + ], + value: () => 'FAILED_ACTIONS', + condition: { field: 'operation', value: 'retry_stage_execution' }, + required: { field: 'operation', value: 'retry_stage_execution' }, + }, + { + id: 'actionName', + title: 'Approval Action Name', + type: 'short-input', + placeholder: 'e.g., ManualApproval', + condition: { field: 'operation', value: 'put_approval_result' }, + required: { field: 'operation', value: 'put_approval_result' }, + }, + { + id: 'approvalToken', + title: 'Approval Token', + type: 'short-input', + placeholder: 'Token from Get Pipeline State', + condition: { field: 'operation', value: 'put_approval_result' }, + required: { field: 'operation', value: 'put_approval_result' }, + }, + { + id: 'approvalStatus', + title: 'Decision', + type: 'dropdown', + options: [ + { label: 'Approve', id: 'Approved' }, + { label: 'Reject', id: 'Rejected' }, + ], + value: () => 'Approved', + condition: { field: 'operation', value: 'put_approval_result' }, + required: { field: 'operation', value: 'put_approval_result' }, + }, + { + id: 'approvalSummary', + title: 'Summary', + type: 'short-input', + placeholder: 'Why the change is approved or rejected', + condition: { field: 'operation', value: 'put_approval_result' }, + required: { field: 'operation', value: 'put_approval_result' }, + }, + { + id: 'pipelineVariables', + title: 'Pipeline Variables', + type: 'table', + columns: ['name', 'value'], + condition: { field: 'operation', value: 'start_execution' }, + }, + { + id: 'clientRequestToken', + title: 'Client Request Token', + type: 'short-input', + placeholder: 'Idempotency token (letters, digits, hyphens)', + condition: { field: 'operation', value: 'start_execution' }, + mode: 'advanced', + }, + { + id: 'abandon', + title: 'Abandon In-Progress Actions', + type: 'switch', + condition: { field: 'operation', value: 'stop_execution' }, + mode: 'advanced', + }, + { + id: 'stopReason', + title: 'Stop Reason', + type: 'short-input', + placeholder: 'Why the execution is being stopped', + condition: { field: 'operation', value: 'stop_execution' }, + mode: 'advanced', + }, + { + id: 'succeededInStage', + title: 'Succeeded In Stage', + type: 'short-input', + placeholder: 'Only executions that succeeded in this stage', + condition: { field: 'operation', value: 'list_pipeline_executions' }, + mode: 'advanced', + }, + { + id: 'maxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: ['list_pipelines', 'list_pipeline_executions'] }, + mode: 'advanced', + }, + { + id: 'nextToken', + title: 'Next Token', + type: 'short-input', + placeholder: 'Pagination token from a previous call', + condition: { field: 'operation', value: ['list_pipelines', 'list_pipeline_executions'] }, + mode: 'advanced', + }, + ], + tools: { + access: [ + 'codepipeline_list_pipelines', + 'codepipeline_get_pipeline_state', + 'codepipeline_get_pipeline_execution', + 'codepipeline_list_pipeline_executions', + 'codepipeline_start_execution', + 'codepipeline_stop_execution', + 'codepipeline_retry_stage_execution', + 'codepipeline_put_approval_result', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'list_pipelines': + return 'codepipeline_list_pipelines' + case 'get_pipeline_state': + return 'codepipeline_get_pipeline_state' + case 'get_pipeline_execution': + return 'codepipeline_get_pipeline_execution' + case 'list_pipeline_executions': + return 'codepipeline_list_pipeline_executions' + case 'start_execution': + return 'codepipeline_start_execution' + case 'stop_execution': + return 'codepipeline_stop_execution' + case 'retry_stage_execution': + return 'codepipeline_retry_stage_execution' + case 'put_approval_result': + return 'codepipeline_put_approval_result' + default: + throw new Error(`Invalid CodePipeline operation: ${params.operation}`) + } + }, + params: (params) => { + const { operation, maxResults, ...rest } = params + + const awsRegion = rest.awsRegion + const awsAccessKeyId = rest.awsAccessKeyId + const awsSecretAccessKey = rest.awsSecretAccessKey + const parsedMaxResults = parseOptionalNumberInput(maxResults, 'Max results', { + integer: true, + min: 1, + }) + + switch (operation) { + case 'list_pipelines': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + ...(parsedMaxResults !== undefined && { maxResults: parsedMaxResults }), + ...(rest.nextToken && { nextToken: rest.nextToken }), + } + + case 'get_pipeline_state': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + } + + case 'get_pipeline_execution': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + pipelineExecutionId: rest.pipelineExecutionId, + } + + case 'list_pipeline_executions': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + ...(parsedMaxResults !== undefined && { maxResults: parsedMaxResults }), + ...(rest.nextToken && { nextToken: rest.nextToken }), + ...(rest.succeededInStage && { succeededInStage: rest.succeededInStage }), + } + + case 'start_execution': { + const rows = parseOptionalJsonInput(rest.pipelineVariables, 'Pipeline variables') + const variables = (() => { + if (rows === undefined) return undefined + if (!Array.isArray(rows)) { + throw new Error('Pipeline variables must be an array of { name, value } objects') + } + const entries = rows + .map((row) => ({ + name: row?.cells?.name ?? row?.name, + value: row?.cells?.value ?? row?.value, + })) + .filter((entry) => entry.name && entry.value !== undefined && entry.value !== '') + .map((entry) => ({ name: String(entry.name), value: String(entry.value) })) + return entries.length > 0 ? entries : undefined + })() + + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + ...(rest.clientRequestToken && { clientRequestToken: rest.clientRequestToken }), + ...(variables && { variables }), + } + } + + case 'stop_execution': { + const abandon = parseOptionalBooleanInput(rest.abandon) + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + pipelineExecutionId: rest.pipelineExecutionId, + ...(abandon !== undefined && { abandon }), + ...(rest.stopReason && { reason: rest.stopReason }), + } + } + + case 'retry_stage_execution': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + stageName: rest.stageName, + pipelineExecutionId: rest.pipelineExecutionId, + retryMode: rest.retryMode, + } + + case 'put_approval_result': + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + pipelineName: rest.pipelineName, + stageName: rest.stageName, + actionName: rest.actionName, + token: rest.approvalToken, + status: rest.approvalStatus, + summary: rest.approvalSummary, + } + + default: + throw new Error(`Invalid CodePipeline operation: ${operation}`) + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'CodePipeline operation to perform' }, + awsRegion: { type: 'string', description: 'AWS region' }, + awsAccessKeyId: { type: 'string', description: 'AWS access key ID' }, + awsSecretAccessKey: { type: 'string', description: 'AWS secret access key' }, + pipelineName: { type: 'string', description: 'Pipeline name' }, + pipelineExecutionId: { type: 'string', description: 'Pipeline execution ID' }, + stageName: { type: 'string', description: 'Stage name for retry or approval' }, + retryMode: { + type: 'string', + description: 'Retry scope: FAILED_ACTIONS or ALL_ACTIONS', + }, + actionName: { type: 'string', description: 'Manual approval action name' }, + approvalToken: { + type: 'string', + description: 'Approval token from Get Pipeline State for the pending approval', + }, + approvalStatus: { type: 'string', description: 'Approval decision: Approved or Rejected' }, + approvalSummary: { type: 'string', description: 'Summary explaining the approval decision' }, + pipelineVariables: { + type: 'json', + description: 'Pipeline variable overrides (name/value pairs)', + }, + clientRequestToken: { + type: 'string', + description: 'Idempotency token for starting an execution', + }, + abandon: { + type: 'boolean', + description: 'Abandon in-progress actions instead of letting them finish', + }, + stopReason: { type: 'string', description: 'Reason for stopping the execution' }, + succeededInStage: { + type: 'string', + description: 'Only list executions that succeeded in this stage', + }, + maxResults: { type: 'number', description: 'Maximum number of results' }, + nextToken: { type: 'string', description: 'Pagination token from a previous call' }, + }, + outputs: { + pipelines: { + type: 'array', + description: 'List of pipelines with name, version, type, and timestamps', + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of results', + }, + pipelineName: { + type: 'string', + description: 'Pipeline name', + }, + pipelineVersion: { + type: 'number', + description: 'Pipeline version number', + }, + created: { + type: 'number', + description: 'Epoch ms when the pipeline was created', + }, + updated: { + type: 'number', + description: 'Epoch ms when the pipeline was last updated', + }, + stageStates: { + type: 'array', + description: 'Per-stage state including action status and pending approval tokens', + }, + pipelineExecutionId: { + type: 'string', + description: 'Pipeline execution ID', + }, + status: { + type: 'string', + description: 'Execution status or submitted approval decision', + }, + statusSummary: { + type: 'string', + description: 'Status summary for the execution', + }, + executionMode: { + type: 'string', + description: 'Execution mode (QUEUED, SUPERSEDED, PARALLEL)', + }, + executionType: { + type: 'string', + description: 'Execution type (STANDARD or ROLLBACK)', + }, + triggerType: { + type: 'string', + description: 'What triggered the execution', + }, + triggerDetail: { + type: 'string', + description: 'Detail about the trigger', + }, + artifactRevisions: { + type: 'array', + description: 'Source artifact revisions for the execution', + }, + variables: { + type: 'array', + description: 'Resolved pipeline variables for the execution', + }, + executions: { + type: 'array', + description: 'Pipeline execution summaries, most recent first', + }, + approvedAt: { + type: 'number', + description: 'Epoch ms when the approval or rejection was submitted', + }, + }, +} + +export const CodePipelineBlockMeta = { + tags: ['cloud', 'ci-cd'], + templates: [ + { + icon: CodePipelineIcon, + title: 'CodePipeline deploy approver', + prompt: + 'Build a workflow that checks a CodePipeline pipeline for pending manual approvals, posts the change summary and source revisions to Slack, and approves or rejects the deployment based on the team lead reply.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'ci-cd', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: CodePipelineIcon, + title: 'CodePipeline failure triage', + prompt: + 'Create a scheduled workflow that polls CodePipeline executions every few minutes, and when one fails, pulls the pipeline state to find the failing stage and action error, opens a Linear issue, and alerts the on-call channel in Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring', 'automation'], + alsoIntegrations: ['linear', 'slack'], + }, + { + icon: CodePipelineIcon, + title: 'CodePipeline release train', + prompt: + 'Build a scheduled workflow that starts the release CodePipeline pipeline every weekday at 9am with the release version as a pipeline variable, then posts the execution ID and a link to Slack.', + modules: ['scheduled', 'workflows'], + category: 'engineering', + tags: ['devops', 'ci-cd', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: CodePipelineIcon, + title: 'CodePipeline deploy digest', + prompt: + 'Create a scheduled daily workflow that lists executions across the team CodePipeline pipelines, summarizes successes, failures, and rollbacks with their source revisions, and posts a digest to Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'reporting'], + alsoIntegrations: ['slack'], + }, + { + icon: CodePipelineIcon, + title: 'CodePipeline flaky-stage retrier', + prompt: + 'Build a scheduled workflow that finds failed CodePipeline executions, retries the failed stage once with failed-actions mode, and escalates to PagerDuty if the retry fails again.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'automation', 'monitoring'], + alsoIntegrations: ['pagerduty'], + }, + { + icon: CodePipelineIcon, + title: 'CodePipeline rollback brake', + prompt: + 'Create a workflow that watches CloudWatch alarms after a deployment, and when an error-rate alarm fires while a CodePipeline execution is in progress, stops the execution with a reason and notifies the release channel.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['devops', 'monitoring', 'automation'], + alsoIntegrations: ['cloudwatch', 'slack'], + }, + { + icon: CodePipelineIcon, + title: 'CodePipeline deployment audit log', + prompt: + 'Build a scheduled workflow that records every CodePipeline execution — pipeline, status, trigger, source revisions, and timing — into a table for compliance and deployment-frequency reporting.', + modules: ['scheduled', 'tables', 'workflows'], + category: 'operations', + tags: ['devops', 'enterprise', 'reporting'], + }, + ], + skills: [ + { + name: 'approve-pending-deployment', + description: + 'Find a pending CodePipeline manual approval, summarize the change, and approve or reject it.', + content: + '# Approve Pending CodePipeline Deployment\n\nHandle a manual approval gate in a pipeline.\n\n## Steps\n1. Get the pipeline state and locate the action awaiting approval (status InProgress on a manual approval action) and its approval token.\n2. Pull the execution details for the stage to summarize what is being deployed (source revisions, trigger).\n3. Submit the approval result with the token, the Approved or Rejected decision, and a summary explaining the decision.\n\n## Output\nThe decision that was submitted, the approval summary, and the pipeline/stage/action it applied to.', + }, + { + name: 'investigate-failed-pipeline', + description: + 'Find the failing stage and action of a CodePipeline execution and report the error details.', + content: + '# Investigate Failed CodePipeline Execution\n\nDiagnose why a pipeline run failed.\n\n## Steps\n1. List recent executions for the pipeline and identify the failed one (or use the provided execution ID).\n2. Get the pipeline state and find the stage and action with a Failed status.\n3. Capture the action error code, error message, and external execution URL, plus the source revisions that were being deployed.\n\n## Output\nThe failing stage and action, the error details, the commit/revision involved, and a link to the external execution.', + }, + { + name: 'trigger-pipeline-release', + description: + 'Start a CodePipeline execution, optionally with variable overrides, and report the execution ID.', + content: + '# Trigger CodePipeline Release\n\nKick off a pipeline run.\n\n## Steps\n1. Confirm the pipeline name (list pipelines if unsure).\n2. Start the execution, passing any pipeline variable overrides (e.g. version or environment) and an idempotency token if retries are possible.\n3. Optionally poll the pipeline state to confirm the execution entered the first stage.\n\n## Output\nThe pipeline execution ID that was started and the variables it ran with.', + }, + { + name: 'retry-failed-stage', + description: + 'Retry the failed actions of a CodePipeline stage and confirm the stage re-entered execution.', + content: + '# Retry Failed CodePipeline Stage\n\nRe-run a failed stage without restarting the whole pipeline.\n\n## Steps\n1. Get the pipeline state and identify the failed stage and the execution ID stuck in it.\n2. Retry the stage with FAILED_ACTIONS mode (or ALL_ACTIONS if the whole stage should re-run).\n3. Check the pipeline state again to confirm the stage is InProgress.\n\n## Output\nThe stage that was retried, the retry mode used, and the current stage status.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index c0a417e603..2ccc000300 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -34,6 +34,7 @@ import { ClickHouseBlock, ClickHouseBlockMeta } from '@/blocks/blocks/clickhouse import { CloudflareBlock, CloudflareBlockMeta } from '@/blocks/blocks/cloudflare' import { CloudFormationBlock, CloudFormationBlockMeta } from '@/blocks/blocks/cloudformation' import { CloudWatchBlock, CloudWatchBlockMeta } from '@/blocks/blocks/cloudwatch' +import { CodePipelineBlock, CodePipelineBlockMeta } from '@/blocks/blocks/codepipeline' import { ConditionBlock } from '@/blocks/blocks/condition' import { ConfluenceBlock, ConfluenceBlockMeta, ConfluenceV2Block } from '@/blocks/blocks/confluence' import { CredentialBlock } from '@/blocks/blocks/credential' @@ -352,6 +353,7 @@ const BLOCK_REGISTRY: Record = { cloudflare: CloudflareBlock, cloudformation: CloudFormationBlock, cloudwatch: CloudWatchBlock, + codepipeline: CodePipelineBlock, condition: ConditionBlock, confluence: ConfluenceBlock, confluence_v2: ConfluenceV2Block, @@ -642,6 +644,7 @@ const BLOCK_META_REGISTRY: Record = { cloudflare: CloudflareBlockMeta, cloudformation: CloudFormationBlockMeta, cloudwatch: CloudWatchBlockMeta, + codepipeline: CodePipelineBlockMeta, confluence: ConfluenceBlockMeta, crowdstrike: CrowdStrikeBlockMeta, cursor: CursorBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 106c6bc95e..4750caa45d 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -5512,6 +5512,33 @@ export function CloudWatchIcon(props: SVGProps) { ) } +export function CodePipelineIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function TextractIcon(props: SVGProps) { return ( validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + pipelineExecutionId: z.string().min(1, 'Pipeline execution ID is required'), +}) + +const GetPipelineExecutionResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelineExecutionId: z.string(), + pipelineName: z.string(), + pipelineVersion: z.number().optional(), + status: z.string(), + statusSummary: z.string().optional(), + executionMode: z.string().optional(), + executionType: z.string().optional(), + triggerType: z.string().optional(), + triggerDetail: z.string().optional(), + artifactRevisions: z.array( + z.object({ + name: z.string(), + revisionId: z.string().optional(), + revisionSummary: z.string().optional(), + revisionUrl: z.string().optional(), + created: z.number().optional(), + }) + ), + variables: z.array( + z.object({ + name: z.string(), + resolvedValue: z.string(), + }) + ), + }), +}) + +export const awsCodepipelineGetPipelineExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/get-pipeline-execution', + body: GetPipelineExecutionSchema, + response: { mode: 'json', schema: GetPipelineExecutionResponseSchema }, +}) +export type AwsCodepipelineGetPipelineExecutionRequest = ContractBodyInput< + typeof awsCodepipelineGetPipelineExecutionContract +> +export type AwsCodepipelineGetPipelineExecutionBody = ContractBody< + typeof awsCodepipelineGetPipelineExecutionContract +> +export type AwsCodepipelineGetPipelineExecutionResponse = ContractJsonResponse< + typeof awsCodepipelineGetPipelineExecutionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-get-pipeline-state.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-get-pipeline-state.ts new file mode 100644 index 0000000000..5e23204f86 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-get-pipeline-state.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetPipelineStateSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), +}) + +const ActionStateSchema = z.object({ + actionName: z.string(), + status: z.string().optional(), + summary: z.string().optional(), + lastStatusChange: z.number().optional(), + externalExecutionId: z.string().optional(), + externalExecutionUrl: z.string().optional(), + errorCode: z.string().optional(), + errorMessage: z.string().optional(), + percentComplete: z.number().optional(), + token: z.string().optional(), + revisionId: z.string().optional(), + entityUrl: z.string().optional(), +}) + +const StageStateSchema = z.object({ + stageName: z.string(), + status: z.string().optional(), + pipelineExecutionId: z.string().optional(), + inboundTransitionEnabled: z.boolean().optional(), + actionStates: z.array(ActionStateSchema), +}) + +const GetPipelineStateResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelineName: z.string(), + pipelineVersion: z.number().optional(), + created: z.number().optional(), + updated: z.number().optional(), + stageStates: z.array(StageStateSchema), + }), +}) + +export const awsCodepipelineGetPipelineStateContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/get-pipeline-state', + body: GetPipelineStateSchema, + response: { mode: 'json', schema: GetPipelineStateResponseSchema }, +}) +export type AwsCodepipelineGetPipelineStateRequest = ContractBodyInput< + typeof awsCodepipelineGetPipelineStateContract +> +export type AwsCodepipelineGetPipelineStateBody = ContractBody< + typeof awsCodepipelineGetPipelineStateContract +> +export type AwsCodepipelineGetPipelineStateResponse = ContractJsonResponse< + typeof awsCodepipelineGetPipelineStateContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions.ts new file mode 100644 index 0000000000..be2b912da1 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipeline-executions.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ListPipelineExecutionsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + maxResults: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).max(100).optional() + ), + nextToken: z.string().min(1).max(2048).optional(), + succeededInStage: z.string().min(1).max(100).optional(), +}) + +const PipelineExecutionSummarySchema = z.object({ + pipelineExecutionId: z.string(), + status: z.string(), + statusSummary: z.string().optional(), + startTime: z.number().optional(), + lastUpdateTime: z.number().optional(), + executionMode: z.string().optional(), + executionType: z.string().optional(), + stopTriggerReason: z.string().optional(), + triggerType: z.string().optional(), + triggerDetail: z.string().optional(), + sourceRevisions: z.array( + z.object({ + actionName: z.string(), + revisionId: z.string().optional(), + revisionSummary: z.string().optional(), + revisionUrl: z.string().optional(), + }) + ), +}) + +const ListPipelineExecutionsResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + executions: z.array(PipelineExecutionSummarySchema), + nextToken: z.string().optional(), + }), +}) + +export const awsCodepipelineListPipelineExecutionsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/list-pipeline-executions', + body: ListPipelineExecutionsSchema, + response: { mode: 'json', schema: ListPipelineExecutionsResponseSchema }, +}) +export type AwsCodepipelineListPipelineExecutionsRequest = ContractBodyInput< + typeof awsCodepipelineListPipelineExecutionsContract +> +export type AwsCodepipelineListPipelineExecutionsBody = ContractBody< + typeof awsCodepipelineListPipelineExecutionsContract +> +export type AwsCodepipelineListPipelineExecutionsResponse = ContractJsonResponse< + typeof awsCodepipelineListPipelineExecutionsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipelines.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipelines.ts new file mode 100644 index 0000000000..997891aee4 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-list-pipelines.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ListPipelinesSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + maxResults: z.preprocess( + (v) => (v === '' || v === undefined || v === null ? undefined : v), + z.coerce.number().int().min(1).max(1000).optional() + ), + nextToken: z.string().min(1).max(2048).optional(), +}) + +const ListPipelinesResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelines: z.array( + z.object({ + name: z.string(), + version: z.number().optional(), + pipelineType: z.string().optional(), + executionMode: z.string().optional(), + created: z.number().optional(), + updated: z.number().optional(), + }) + ), + nextToken: z.string().optional(), + }), +}) + +export const awsCodepipelineListPipelinesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/list-pipelines', + body: ListPipelinesSchema, + response: { mode: 'json', schema: ListPipelinesResponseSchema }, +}) +export type AwsCodepipelineListPipelinesRequest = ContractBodyInput< + typeof awsCodepipelineListPipelinesContract +> +export type AwsCodepipelineListPipelinesBody = ContractBody< + typeof awsCodepipelineListPipelinesContract +> +export type AwsCodepipelineListPipelinesResponse = ContractJsonResponse< + typeof awsCodepipelineListPipelinesContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-put-approval-result.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-put-approval-result.ts new file mode 100644 index 0000000000..8e50edb52b --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-put-approval-result.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const PutApprovalResultSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + stageName: z + .string() + .min(1, 'Stage name is required') + .max(100, 'Stage name must be at most 100 characters'), + actionName: z + .string() + .min(1, 'Action name is required') + .max(100, 'Action name must be at most 100 characters'), + token: z.string().min(1, 'Approval token is required'), + status: z.enum(['Approved', 'Rejected']), + summary: z + .string() + .min(1, 'Approval summary is required') + .max(512, 'Approval summary must be at most 512 characters'), +}) + +const PutApprovalResultResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + approvedAt: z.number().optional(), + status: z.string(), + }), +}) + +export const awsCodepipelinePutApprovalResultContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/put-approval-result', + body: PutApprovalResultSchema, + response: { mode: 'json', schema: PutApprovalResultResponseSchema }, +}) +export type AwsCodepipelinePutApprovalResultRequest = ContractBodyInput< + typeof awsCodepipelinePutApprovalResultContract +> +export type AwsCodepipelinePutApprovalResultBody = ContractBody< + typeof awsCodepipelinePutApprovalResultContract +> +export type AwsCodepipelinePutApprovalResultResponse = ContractJsonResponse< + typeof awsCodepipelinePutApprovalResultContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-retry-stage-execution.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-retry-stage-execution.ts new file mode 100644 index 0000000000..ad3c0c044a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-retry-stage-execution.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const RetryStageExecutionSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + stageName: z + .string() + .min(1, 'Stage name is required') + .max(100, 'Stage name must be at most 100 characters'), + pipelineExecutionId: z.string().min(1, 'Pipeline execution ID is required'), + retryMode: z.enum(['FAILED_ACTIONS', 'ALL_ACTIONS']), +}) + +const RetryStageExecutionResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelineExecutionId: z.string(), + }), +}) + +export const awsCodepipelineRetryStageExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/retry-stage-execution', + body: RetryStageExecutionSchema, + response: { mode: 'json', schema: RetryStageExecutionResponseSchema }, +}) +export type AwsCodepipelineRetryStageExecutionRequest = ContractBodyInput< + typeof awsCodepipelineRetryStageExecutionContract +> +export type AwsCodepipelineRetryStageExecutionBody = ContractBody< + typeof awsCodepipelineRetryStageExecutionContract +> +export type AwsCodepipelineRetryStageExecutionResponse = ContractJsonResponse< + typeof awsCodepipelineRetryStageExecutionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-start-execution.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-start-execution.ts new file mode 100644 index 0000000000..30affbe3a6 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-start-execution.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const StartExecutionSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + clientRequestToken: z + .string() + .min(1) + .max(128) + .regex(/^[a-zA-Z0-9-]+$/, 'Client request token may only contain letters, digits, and hyphens') + .optional(), + variables: z + .array( + z.object({ + name: z.string().min(1, 'Variable name is required'), + value: z.string().min(1, 'Variable value cannot be empty'), + }) + ) + .min(1) + .max(50) + .optional(), +}) + +const StartExecutionResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelineExecutionId: z.string(), + }), +}) + +export const awsCodepipelineStartExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/start-execution', + body: StartExecutionSchema, + response: { mode: 'json', schema: StartExecutionResponseSchema }, +}) +export type AwsCodepipelineStartExecutionRequest = ContractBodyInput< + typeof awsCodepipelineStartExecutionContract +> +export type AwsCodepipelineStartExecutionBody = ContractBody< + typeof awsCodepipelineStartExecutionContract +> +export type AwsCodepipelineStartExecutionResponse = ContractJsonResponse< + typeof awsCodepipelineStartExecutionContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/codepipeline-stop-execution.ts b/apps/sim/lib/api/contracts/tools/aws/codepipeline-stop-execution.ts new file mode 100644 index 0000000000..44399128fb --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/codepipeline-stop-execution.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const StopExecutionSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + pipelineName: z + .string() + .min(1, 'Pipeline name is required') + .max(100, 'Pipeline name must be at most 100 characters'), + pipelineExecutionId: z.string().min(1, 'Pipeline execution ID is required'), + abandon: z.boolean().optional(), + reason: z.string().max(200, 'Stop reason must be at most 200 characters').optional(), +}) + +const StopExecutionResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + pipelineExecutionId: z.string(), + }), +}) + +export const awsCodepipelineStopExecutionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/codepipeline/stop-execution', + body: StopExecutionSchema, + response: { mode: 'json', schema: StopExecutionResponseSchema }, +}) +export type AwsCodepipelineStopExecutionRequest = ContractBodyInput< + typeof awsCodepipelineStopExecutionContract +> +export type AwsCodepipelineStopExecutionBody = ContractBody< + typeof awsCodepipelineStopExecutionContract +> +export type AwsCodepipelineStopExecutionResponse = ContractJsonResponse< + typeof awsCodepipelineStopExecutionContract +> diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 6f4cf89271..b4be8a5551 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -35,6 +35,7 @@ import { CloudFormationIcon, CloudflareIcon, CloudWatchIcon, + CodePipelineIcon, ConfluenceIcon, CrowdStrikeIcon, CursorIcon, @@ -247,6 +248,7 @@ export const blockTypeToIconMap: Record = { cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, + codepipeline: CodePipelineIcon, confluence_v2: ConfluenceIcon, crowdstrike: CrowdStrikeIcon, cursor_v2: CursorIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index fac30504d2..4c11aafb30 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -2826,6 +2826,57 @@ "integrationType": "observability", "tags": ["cloud", "monitoring"] }, + { + "type": "codepipeline", + "slug": "codepipeline", + "name": "CodePipeline", + "description": "Run, monitor, and approve AWS CodePipeline pipelines", + "longDescription": "Integrate AWS CodePipeline into workflows. Start, stop, and monitor pipeline executions, retry failed stages, and approve or reject manual approval actions. Requires AWS access key and secret access key.", + "bgColor": "linear-gradient(45deg, #2E27AD 0%, #527FFF 100%)", + "iconName": "CodePipelineIcon", + "docsUrl": "https://docs.sim.ai/tools/codepipeline", + "operations": [ + { + "name": "Start Execution", + "description": "Start a CodePipeline pipeline execution, optionally overriding pipeline variables" + }, + { + "name": "Get Pipeline State", + "description": "Get the current state of a CodePipeline pipeline, including stage and action status and pending approval tokens" + }, + { + "name": "List Pipelines", + "description": "List all CodePipeline pipelines in an AWS account and region" + }, + { + "name": "List Executions", + "description": "List recent executions of a CodePipeline pipeline with status and source revisions" + }, + { + "name": "Get Execution", + "description": "Get details of a CodePipeline execution, including status, trigger, source revisions, and resolved variables" + }, + { + "name": "Stop Execution", + "description": "Stop a CodePipeline pipeline execution, either finishing in-progress actions or abandoning them" + }, + { + "name": "Retry Stage", + "description": "Retry the failed actions (or all actions) of a failed CodePipeline stage" + }, + { + "name": "Approve / Reject Approval", + "description": "Approve or reject a pending CodePipeline manual approval action. The approval token is available from Get Pipeline State on the pending approval action" + } + ], + "operationCount": 8, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "devops", + "tags": ["cloud", "ci-cd"] + }, { "type": "confluence_v2", "slug": "confluence", diff --git a/apps/sim/package.json b/apps/sim/package.json index 259f9dca72..31d799eac3 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -40,6 +40,7 @@ "@aws-sdk/client-cloudformation": "3.1032.0", "@aws-sdk/client-cloudwatch": "3.1032.0", "@aws-sdk/client-cloudwatch-logs": "3.1032.0", + "@aws-sdk/client-codepipeline": "3.1032.0", "@aws-sdk/client-dynamodb": "3.1032.0", "@aws-sdk/client-iam": "3.1032.0", "@aws-sdk/client-identitystore": "3.1032.0", diff --git a/apps/sim/tools/codepipeline/get_pipeline_execution.ts b/apps/sim/tools/codepipeline/get_pipeline_execution.ts new file mode 100644 index 0000000000..12c47a59f8 --- /dev/null +++ b/apps/sim/tools/codepipeline/get_pipeline_execution.ts @@ -0,0 +1,153 @@ +import type { + CodePipelineGetPipelineExecutionParams, + CodePipelineGetPipelineExecutionResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const getPipelineExecutionTool: ToolConfig< + CodePipelineGetPipelineExecutionParams, + CodePipelineGetPipelineExecutionResponse +> = { + id: 'codepipeline_get_pipeline_execution', + name: 'CodePipeline Get Pipeline Execution', + description: + 'Get details of a CodePipeline execution, including status, trigger, source revisions, and resolved variables', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + pipelineExecutionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the pipeline execution', + }, + }, + + request: { + url: '/api/tools/codepipeline/get-pipeline-execution', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + pipelineExecutionId: params.pipelineExecutionId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get CodePipeline pipeline execution') + } + + return { + success: true, + output: { + pipelineExecutionId: data.output.pipelineExecutionId, + pipelineName: data.output.pipelineName, + pipelineVersion: data.output.pipelineVersion, + status: data.output.status, + statusSummary: data.output.statusSummary, + executionMode: data.output.executionMode, + executionType: data.output.executionType, + triggerType: data.output.triggerType, + triggerDetail: data.output.triggerDetail, + artifactRevisions: data.output.artifactRevisions, + variables: data.output.variables, + }, + } + }, + + outputs: { + pipelineExecutionId: { type: 'string', description: 'Pipeline execution ID' }, + pipelineName: { type: 'string', description: 'Pipeline name' }, + pipelineVersion: { type: 'number', description: 'Pipeline version number', optional: true }, + status: { + type: 'string', + description: + 'Execution status (Cancelled, InProgress, Stopped, Stopping, Succeeded, Superseded, Failed)', + }, + statusSummary: { + type: 'string', + description: 'Status summary for the execution', + optional: true, + }, + executionMode: { + type: 'string', + description: 'Execution mode (QUEUED, SUPERSEDED, PARALLEL)', + optional: true, + }, + executionType: { + type: 'string', + description: 'Execution type (STANDARD or ROLLBACK)', + optional: true, + }, + triggerType: { + type: 'string', + description: 'What triggered the execution (e.g., Webhook, StartPipelineExecution)', + optional: true, + }, + triggerDetail: { + type: 'string', + description: 'Detail about the trigger (e.g., user ARN)', + optional: true, + }, + artifactRevisions: { + type: 'array', + description: 'Source artifact revisions for the execution', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Artifact name' }, + revisionId: { type: 'string', description: 'Revision ID (e.g., commit SHA)' }, + revisionSummary: { + type: 'string', + description: 'Revision summary (e.g., commit message)', + }, + revisionUrl: { type: 'string', description: 'URL of the revision' }, + created: { type: 'number', description: 'Epoch ms when the revision was created' }, + }, + }, + }, + variables: { + type: 'array', + description: 'Resolved pipeline variables for the execution', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Variable name' }, + resolvedValue: { type: 'string', description: 'Resolved variable value' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/codepipeline/get_pipeline_state.ts b/apps/sim/tools/codepipeline/get_pipeline_state.ts new file mode 100644 index 0000000000..e850ae682f --- /dev/null +++ b/apps/sim/tools/codepipeline/get_pipeline_state.ts @@ -0,0 +1,119 @@ +import type { + CodePipelineGetPipelineStateParams, + CodePipelineGetPipelineStateResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const getPipelineStateTool: ToolConfig< + CodePipelineGetPipelineStateParams, + CodePipelineGetPipelineStateResponse +> = { + id: 'codepipeline_get_pipeline_state', + name: 'CodePipeline Get Pipeline State', + description: + 'Get the current state of a CodePipeline pipeline, including stage and action status and pending approval tokens', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + }, + + request: { + url: '/api/tools/codepipeline/get-pipeline-state', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get CodePipeline pipeline state') + } + + return { + success: true, + output: { + pipelineName: data.output.pipelineName, + pipelineVersion: data.output.pipelineVersion, + created: data.output.created, + updated: data.output.updated, + stageStates: data.output.stageStates, + }, + } + }, + + outputs: { + pipelineName: { type: 'string', description: 'Pipeline name' }, + pipelineVersion: { type: 'number', description: 'Pipeline version number', optional: true }, + created: { + type: 'number', + description: 'Epoch ms when the pipeline was created', + optional: true, + }, + updated: { + type: 'number', + description: 'Epoch ms when the pipeline was last updated', + optional: true, + }, + stageStates: { + type: 'array', + description: 'Per-stage state including latest execution status and action details', + items: { + type: 'object', + properties: { + stageName: { type: 'string', description: 'Stage name' }, + status: { + type: 'string', + description: + 'Latest stage execution status (InProgress, Succeeded, Failed, Stopped, Cancelled)', + }, + pipelineExecutionId: { + type: 'string', + description: 'Pipeline execution ID currently in the stage', + }, + inboundTransitionEnabled: { + type: 'boolean', + description: 'Whether the inbound transition into the stage is enabled', + }, + actionStates: { + type: 'array', + description: + 'Per-action state with status, summary, error details, and approval token (for pending manual approvals)', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/codepipeline/index.ts b/apps/sim/tools/codepipeline/index.ts new file mode 100644 index 0000000000..9ceeb9baf1 --- /dev/null +++ b/apps/sim/tools/codepipeline/index.ts @@ -0,0 +1,19 @@ +import { getPipelineExecutionTool } from '@/tools/codepipeline/get_pipeline_execution' +import { getPipelineStateTool } from '@/tools/codepipeline/get_pipeline_state' +import { listPipelineExecutionsTool } from '@/tools/codepipeline/list_pipeline_executions' +import { listPipelinesTool } from '@/tools/codepipeline/list_pipelines' +import { putApprovalResultTool } from '@/tools/codepipeline/put_approval_result' +import { retryStageExecutionTool } from '@/tools/codepipeline/retry_stage_execution' +import { startExecutionTool } from '@/tools/codepipeline/start_execution' +import { stopExecutionTool } from '@/tools/codepipeline/stop_execution' + +export * from './types' + +export const codepipelineGetPipelineExecutionTool = getPipelineExecutionTool +export const codepipelineGetPipelineStateTool = getPipelineStateTool +export const codepipelineListPipelineExecutionsTool = listPipelineExecutionsTool +export const codepipelineListPipelinesTool = listPipelinesTool +export const codepipelinePutApprovalResultTool = putApprovalResultTool +export const codepipelineRetryStageExecutionTool = retryStageExecutionTool +export const codepipelineStartExecutionTool = startExecutionTool +export const codepipelineStopExecutionTool = stopExecutionTool diff --git a/apps/sim/tools/codepipeline/list_pipeline_executions.ts b/apps/sim/tools/codepipeline/list_pipeline_executions.ts new file mode 100644 index 0000000000..7b8c2d977a --- /dev/null +++ b/apps/sim/tools/codepipeline/list_pipeline_executions.ts @@ -0,0 +1,140 @@ +import type { + CodePipelineListPipelineExecutionsParams, + CodePipelineListPipelineExecutionsResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const listPipelineExecutionsTool: ToolConfig< + CodePipelineListPipelineExecutionsParams, + CodePipelineListPipelineExecutionsResponse +> = { + id: 'codepipeline_list_pipeline_executions', + name: 'CodePipeline List Pipeline Executions', + description: 'List recent executions of a CodePipeline pipeline with status and source revisions', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of executions to return (1-100, default 100)', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous call', + }, + succeededInStage: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return executions that succeeded in this stage', + }, + }, + + request: { + url: '/api/tools/codepipeline/list-pipeline-executions', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + ...(params.maxResults !== undefined && { maxResults: params.maxResults }), + ...(params.nextToken && { nextToken: params.nextToken }), + ...(params.succeededInStage && { succeededInStage: params.succeededInStage }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to list CodePipeline pipeline executions') + } + + return { + success: true, + output: { + executions: data.output.executions, + nextToken: data.output.nextToken, + }, + } + }, + + outputs: { + executions: { + type: 'array', + description: 'Pipeline execution summaries, most recent first', + items: { + type: 'object', + properties: { + pipelineExecutionId: { type: 'string', description: 'Pipeline execution ID' }, + status: { + type: 'string', + description: + 'Execution status (Cancelled, InProgress, Stopped, Stopping, Succeeded, Superseded, Failed)', + }, + statusSummary: { type: 'string', description: 'Status summary for the execution' }, + startTime: { type: 'number', description: 'Epoch ms when the execution started' }, + lastUpdateTime: { + type: 'number', + description: 'Epoch ms when the execution was last updated', + }, + executionMode: { + type: 'string', + description: 'Execution mode (QUEUED, SUPERSEDED, PARALLEL)', + }, + executionType: { + type: 'string', + description: 'Execution type (STANDARD or ROLLBACK)', + }, + stopTriggerReason: { + type: 'string', + description: 'Reason the execution was stopped, if applicable', + }, + triggerType: { type: 'string', description: 'What triggered the execution' }, + triggerDetail: { type: 'string', description: 'Detail about the trigger' }, + sourceRevisions: { + type: 'array', + description: 'Source revisions (commit IDs, summaries, URLs) for the execution', + }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of results', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/codepipeline/list_pipelines.ts b/apps/sim/tools/codepipeline/list_pipelines.ts new file mode 100644 index 0000000000..dd4a35f3f1 --- /dev/null +++ b/apps/sim/tools/codepipeline/list_pipelines.ts @@ -0,0 +1,105 @@ +import type { + CodePipelineListPipelinesParams, + CodePipelineListPipelinesResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const listPipelinesTool: ToolConfig< + CodePipelineListPipelinesParams, + CodePipelineListPipelinesResponse +> = { + id: 'codepipeline_list_pipelines', + name: 'CodePipeline List Pipelines', + description: 'List all CodePipeline pipelines in an AWS account and region', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of pipelines to return (1-1000)', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous call', + }, + }, + + request: { + url: '/api/tools/codepipeline/list-pipelines', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + ...(params.maxResults !== undefined && { maxResults: params.maxResults }), + ...(params.nextToken && { nextToken: params.nextToken }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to list CodePipeline pipelines') + } + + return { + success: true, + output: { + pipelines: data.output.pipelines, + nextToken: data.output.nextToken, + }, + } + }, + + outputs: { + pipelines: { + type: 'array', + description: 'List of pipelines with name, version, type, and timestamps', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Pipeline name' }, + version: { type: 'number', description: 'Pipeline version number' }, + pipelineType: { type: 'string', description: 'Pipeline type (V1 or V2)' }, + executionMode: { + type: 'string', + description: 'Execution mode (QUEUED, SUPERSEDED, PARALLEL)', + }, + created: { type: 'number', description: 'Epoch ms when the pipeline was created' }, + updated: { type: 'number', description: 'Epoch ms when the pipeline was last updated' }, + }, + }, + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of results', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/codepipeline/put_approval_result.ts b/apps/sim/tools/codepipeline/put_approval_result.ts new file mode 100644 index 0000000000..8199eb0f74 --- /dev/null +++ b/apps/sim/tools/codepipeline/put_approval_result.ts @@ -0,0 +1,120 @@ +import type { + CodePipelinePutApprovalResultParams, + CodePipelinePutApprovalResultResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const putApprovalResultTool: ToolConfig< + CodePipelinePutApprovalResultParams, + CodePipelinePutApprovalResultResponse +> = { + id: 'codepipeline_put_approval_result', + name: 'CodePipeline Put Approval Result', + description: + 'Approve or reject a pending CodePipeline manual approval action. The approval token is available from Get Pipeline State on the pending approval action', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + stageName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the stage containing the approval action', + }, + actionName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the manual approval action', + }, + token: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Approval token from Get Pipeline State for the pending approval', + }, + status: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Approval decision: Approved or Rejected', + }, + summary: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Summary explaining the approval decision (max 512 characters)', + }, + }, + + request: { + url: '/api/tools/codepipeline/put-approval-result', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + stageName: params.stageName, + actionName: params.actionName, + token: params.token, + status: params.status, + summary: params.summary, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to submit CodePipeline approval result') + } + + return { + success: true, + output: { + approvedAt: data.output.approvedAt, + status: data.output.status, + }, + } + }, + + outputs: { + approvedAt: { + type: 'number', + description: 'Epoch ms when the approval or rejection was submitted', + optional: true, + }, + status: { + type: 'string', + description: 'The submitted approval decision (Approved or Rejected)', + }, + }, +} diff --git a/apps/sim/tools/codepipeline/retry_stage_execution.ts b/apps/sim/tools/codepipeline/retry_stage_execution.ts new file mode 100644 index 0000000000..1a106ef632 --- /dev/null +++ b/apps/sim/tools/codepipeline/retry_stage_execution.ts @@ -0,0 +1,99 @@ +import type { + CodePipelineRetryStageExecutionParams, + CodePipelineRetryStageExecutionResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const retryStageExecutionTool: ToolConfig< + CodePipelineRetryStageExecutionParams, + CodePipelineRetryStageExecutionResponse +> = { + id: 'codepipeline_retry_stage_execution', + name: 'CodePipeline Retry Stage Execution', + description: 'Retry the failed actions (or all actions) of a failed CodePipeline stage', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + stageName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the failed stage to retry', + }, + pipelineExecutionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the pipeline execution in the failed stage', + }, + retryMode: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Scope of the retry: FAILED_ACTIONS or ALL_ACTIONS', + }, + }, + + request: { + url: '/api/tools/codepipeline/retry-stage-execution', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + stageName: params.stageName, + pipelineExecutionId: params.pipelineExecutionId, + retryMode: params.retryMode, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to retry CodePipeline stage execution') + } + + return { + success: true, + output: { + pipelineExecutionId: data.output.pipelineExecutionId, + }, + } + }, + + outputs: { + pipelineExecutionId: { + type: 'string', + description: 'ID of the pipeline execution with the retried stage', + }, + }, +} diff --git a/apps/sim/tools/codepipeline/start_execution.ts b/apps/sim/tools/codepipeline/start_execution.ts new file mode 100644 index 0000000000..0b31e0a31d --- /dev/null +++ b/apps/sim/tools/codepipeline/start_execution.ts @@ -0,0 +1,92 @@ +import type { + CodePipelineStartExecutionParams, + CodePipelineStartExecutionResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const startExecutionTool: ToolConfig< + CodePipelineStartExecutionParams, + CodePipelineStartExecutionResponse +> = { + id: 'codepipeline_start_execution', + name: 'CodePipeline Start Execution', + description: 'Start a CodePipeline pipeline execution, optionally overriding pipeline variables', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline to start', + }, + clientRequestToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Idempotency token to identify a unique execution request', + }, + variables: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Pipeline variable overrides as an array of { name, value } objects', + }, + }, + + request: { + url: '/api/tools/codepipeline/start-execution', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + ...(params.clientRequestToken && { clientRequestToken: params.clientRequestToken }), + ...(params.variables && params.variables.length > 0 && { variables: params.variables }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to start CodePipeline pipeline execution') + } + + return { + success: true, + output: { + pipelineExecutionId: data.output.pipelineExecutionId, + }, + } + }, + + outputs: { + pipelineExecutionId: { + type: 'string', + description: 'ID of the pipeline execution that was started', + }, + }, +} diff --git a/apps/sim/tools/codepipeline/stop_execution.ts b/apps/sim/tools/codepipeline/stop_execution.ts new file mode 100644 index 0000000000..b591e9c827 --- /dev/null +++ b/apps/sim/tools/codepipeline/stop_execution.ts @@ -0,0 +1,100 @@ +import type { + CodePipelineStopExecutionParams, + CodePipelineStopExecutionResponse, +} from '@/tools/codepipeline/types' +import type { ToolConfig } from '@/tools/types' + +export const stopExecutionTool: ToolConfig< + CodePipelineStopExecutionParams, + CodePipelineStopExecutionResponse +> = { + id: 'codepipeline_stop_execution', + name: 'CodePipeline Stop Execution', + description: + 'Stop a CodePipeline pipeline execution, either finishing in-progress actions or abandoning them', + version: '1.0.0', + + params: { + awsRegion: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + awsAccessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + awsSecretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + pipelineName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the pipeline', + }, + pipelineExecutionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the pipeline execution to stop', + }, + abandon: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Abandon in-progress actions instead of letting them finish (default false)', + }, + reason: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reason for stopping the execution (max 200 characters)', + }, + }, + + request: { + url: '/api/tools/codepipeline/stop-execution', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + region: params.awsRegion, + accessKeyId: params.awsAccessKeyId, + secretAccessKey: params.awsSecretAccessKey, + pipelineName: params.pipelineName, + pipelineExecutionId: params.pipelineExecutionId, + ...(params.abandon !== undefined && { abandon: params.abandon }), + ...(params.reason && { reason: params.reason }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to stop CodePipeline pipeline execution') + } + + return { + success: true, + output: { + pipelineExecutionId: data.output.pipelineExecutionId, + }, + } + }, + + outputs: { + pipelineExecutionId: { + type: 'string', + description: 'ID of the pipeline execution that was stopped', + }, + }, +} diff --git a/apps/sim/tools/codepipeline/types.ts b/apps/sim/tools/codepipeline/types.ts new file mode 100644 index 0000000000..ae185d3b56 --- /dev/null +++ b/apps/sim/tools/codepipeline/types.ts @@ -0,0 +1,182 @@ +import type { ToolResponse } from '@/tools/types' + +interface CodePipelineConnectionConfig { + awsRegion: string + awsAccessKeyId: string + awsSecretAccessKey: string +} + +export interface CodePipelineListPipelinesParams extends CodePipelineConnectionConfig { + maxResults?: number + nextToken?: string +} + +export interface CodePipelineListPipelinesResponse extends ToolResponse { + output: { + pipelines: { + name: string + version: number | undefined + pipelineType: string | undefined + executionMode: string | undefined + created: number | undefined + updated: number | undefined + }[] + nextToken?: string + } +} + +export interface CodePipelineGetPipelineStateParams extends CodePipelineConnectionConfig { + pipelineName: string +} + +export interface CodePipelineActionState { + actionName: string + status: string | undefined + summary: string | undefined + lastStatusChange: number | undefined + externalExecutionId: string | undefined + externalExecutionUrl: string | undefined + errorCode: string | undefined + errorMessage: string | undefined + percentComplete: number | undefined + token: string | undefined + revisionId: string | undefined + entityUrl: string | undefined +} + +export interface CodePipelineStageState { + stageName: string + status: string | undefined + pipelineExecutionId: string | undefined + inboundTransitionEnabled: boolean | undefined + actionStates: CodePipelineActionState[] +} + +export interface CodePipelineGetPipelineStateResponse extends ToolResponse { + output: { + pipelineName: string + pipelineVersion: number | undefined + created: number | undefined + updated: number | undefined + stageStates: CodePipelineStageState[] + } +} + +export interface CodePipelineGetPipelineExecutionParams extends CodePipelineConnectionConfig { + pipelineName: string + pipelineExecutionId: string +} + +export interface CodePipelineGetPipelineExecutionResponse extends ToolResponse { + output: { + pipelineExecutionId: string + pipelineName: string + pipelineVersion: number | undefined + status: string + statusSummary: string | undefined + executionMode: string | undefined + executionType: string | undefined + triggerType: string | undefined + triggerDetail: string | undefined + artifactRevisions: { + name: string + revisionId: string | undefined + revisionSummary: string | undefined + revisionUrl: string | undefined + created: number | undefined + }[] + variables: { + name: string + resolvedValue: string + }[] + } +} + +export interface CodePipelineListPipelineExecutionsParams extends CodePipelineConnectionConfig { + pipelineName: string + maxResults?: number + nextToken?: string + succeededInStage?: string +} + +export interface CodePipelineListPipelineExecutionsResponse extends ToolResponse { + output: { + executions: { + pipelineExecutionId: string + status: string + statusSummary: string | undefined + startTime: number | undefined + lastUpdateTime: number | undefined + executionMode: string | undefined + executionType: string | undefined + stopTriggerReason: string | undefined + triggerType: string | undefined + triggerDetail: string | undefined + sourceRevisions: { + actionName: string + revisionId: string | undefined + revisionSummary: string | undefined + revisionUrl: string | undefined + }[] + }[] + nextToken?: string + } +} + +export interface CodePipelineStartExecutionParams extends CodePipelineConnectionConfig { + pipelineName: string + clientRequestToken?: string + variables?: { name: string; value: string }[] +} + +export interface CodePipelineStartExecutionResponse extends ToolResponse { + output: { + pipelineExecutionId: string + } +} + +export interface CodePipelineStopExecutionParams extends CodePipelineConnectionConfig { + pipelineName: string + pipelineExecutionId: string + abandon?: boolean + reason?: string +} + +export interface CodePipelineStopExecutionResponse extends ToolResponse { + output: { + pipelineExecutionId: string + } +} + +export type CodePipelineRetryMode = 'FAILED_ACTIONS' | 'ALL_ACTIONS' + +export interface CodePipelineRetryStageExecutionParams extends CodePipelineConnectionConfig { + pipelineName: string + stageName: string + pipelineExecutionId: string + retryMode: CodePipelineRetryMode +} + +export interface CodePipelineRetryStageExecutionResponse extends ToolResponse { + output: { + pipelineExecutionId: string + } +} + +export type CodePipelineApprovalStatus = 'Approved' | 'Rejected' + +export interface CodePipelinePutApprovalResultParams extends CodePipelineConnectionConfig { + pipelineName: string + stageName: string + actionName: string + token: string + status: CodePipelineApprovalStatus + summary: string +} + +export interface CodePipelinePutApprovalResultResponse extends ToolResponse { + output: { + approvedAt: number | undefined + status: string + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index c5f425da89..f0821ff3e2 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -434,6 +434,16 @@ import { cloudwatchQueryLogsTool, cloudwatchUnmuteAlarmTool, } from '@/tools/cloudwatch' +import { + codepipelineGetPipelineExecutionTool, + codepipelineGetPipelineStateTool, + codepipelineListPipelineExecutionsTool, + codepipelineListPipelinesTool, + codepipelinePutApprovalResultTool, + codepipelineRetryStageExecutionTool, + codepipelineStartExecutionTool, + codepipelineStopExecutionTool, +} from '@/tools/codepipeline' import { confluenceAddLabelTool, confluenceCreateBlogPostTool, @@ -4302,6 +4312,14 @@ export const tools: Record = { cloudwatch_put_metric_data: cloudwatchPutMetricDataTool, cloudwatch_query_logs: cloudwatchQueryLogsTool, cloudwatch_unmute_alarm: cloudwatchUnmuteAlarmTool, + codepipeline_get_pipeline_execution: codepipelineGetPipelineExecutionTool, + codepipeline_get_pipeline_state: codepipelineGetPipelineStateTool, + codepipeline_list_pipeline_executions: codepipelineListPipelineExecutionsTool, + codepipeline_list_pipelines: codepipelineListPipelinesTool, + codepipeline_put_approval_result: codepipelinePutApprovalResultTool, + codepipeline_retry_stage_execution: codepipelineRetryStageExecutionTool, + codepipeline_start_execution: codepipelineStartExecutionTool, + codepipeline_stop_execution: codepipelineStopExecutionTool, crowdstrike_get_sensor_aggregates: crowdstrikeGetSensorAggregatesTool, crowdstrike_get_sensor_details: crowdstrikeGetSensorDetailsTool, crowdstrike_query_sensors: crowdstrikeQuerySensorsTool, diff --git a/bun.lock b/bun.lock index 6ccdd5bd67..64d26918b1 100644 --- a/bun.lock +++ b/bun.lock @@ -96,6 +96,7 @@ "@aws-sdk/client-cloudformation": "3.1032.0", "@aws-sdk/client-cloudwatch": "3.1032.0", "@aws-sdk/client-cloudwatch-logs": "3.1032.0", + "@aws-sdk/client-codepipeline": "3.1032.0", "@aws-sdk/client-dynamodb": "3.1032.0", "@aws-sdk/client-iam": "3.1032.0", "@aws-sdk/client-identitystore": "3.1032.0", @@ -579,6 +580,8 @@ "@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-pzvsXRUrlq5q0HTmpEUF07koRw1cikeWY4M5brPQimMBZx5VahiIVyacNwD1tr40rKwo72SyFDToBWSnXFVYKA=="], + "@aws-sdk/client-codepipeline": ["@aws-sdk/client-codepipeline@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-VNoKgaIrzM+kQxJTDt+rXshkrj4pf9wgfwSi23DNE1cmaeDGJQJEoScD0vPqry8B8Qaj0omS7UzZyVCVv1ZOFw=="], + "@aws-sdk/client-dynamodb": ["@aws-sdk/client-dynamodb@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/dynamodb-codec": "^3.973.1", "@aws-sdk/middleware-endpoint-discovery": "^3.972.11", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" } }, "sha512-kkXiZBNdWCQAg/8opqAu10TxzdpqMkcGrNAT2ScdfWhCpzYZ2pmSpP8W7BOlA32jYIWnYrEdb808UZsNWYBPAA=="], "@aws-sdk/client-iam": ["@aws-sdk/client-iam@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" } }, "sha512-dzLygZx+PIUJ1Iob2l6a3ToqRtF1FQzF+Ps8lPeFaJSibslUt12hmBGUJ7uIVvoXhGzRRsRwtXTCH++XZpVYag=="], diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index b639bedd2b..90bad6c5a4 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 804, - zodRoutes: 804, + totalRoutes: 811, + zodRoutes: 811, nonZodRoutes: 0, } as const From bc371b01c43a34c639bac600f795b1ab6154fe14 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 10 Jun 2026 12:45:11 -0700 Subject: [PATCH 08/10] fix(file-preview): gate streaming animation to prevent file patch issue with scroll based re-render (#4946) --- .../components/file-viewer/preview-panel.tsx | 38 +++++++++++++++++-- apps/sim/hooks/use-smooth-text.ts | 10 +++-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 23ee5f6b01..e9da592dac 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -35,7 +35,7 @@ import { cn } from '@/lib/core/utils/cn' import { extractTextContent } from '@/lib/core/utils/react-node-text' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { useScrollAnchor } from '@/hooks/use-scroll-anchor' -import { useSmoothText } from '@/hooks/use-smooth-text' +import { RESUME_SKIP_THRESHOLD, useSmoothText } from '@/hooks/use-smooth-text' import { DataTable } from './data-table' import { PreviewLoadingFrame } from './preview-shared' import { ZoomablePreview } from './zoomable-preview' @@ -185,6 +185,10 @@ function remarkCallouts() { const REMARK_PLUGINS = [remarkGfm, remarkBreaks, remarkMermaid, remarkCallouts] const REHYPE_PLUGINS = [rehypeSlug] +const STREAMDOWN_ALLOWED_TAGS: Record = { + 'mermaid-diagram': ['definition'], +} + /** * Soft per-character fade for newly revealed markdown while streaming. Mirrors * the chat surface so a streamed file preview reveals with the same cadence; @@ -197,6 +201,33 @@ const STREAM_ANIMATION = { sep: 'char', } as const +/** + * Gates the per-character fade to streams that build the document from + * scratch. Enabling the fade over an already-rendered document, or during + * in-place rewrites (patch snapshots), replays it on text that is already + * visible, so the gate latches off for those sessions. + */ +function useStreamAnimationGate(content: string, isStreaming: boolean): boolean { + const prevIsStreamingRef = useRef(false) + const prevContentRef = useRef(content) + const animateRef = useRef(false) + + if (isStreaming !== prevIsStreamingRef.current) { + animateRef.current = isStreaming && content.length <= RESUME_SKIP_THRESHOLD + } else if ( + isStreaming && + animateRef.current && + content !== prevContentRef.current && + !content.startsWith(prevContentRef.current) + ) { + animateRef.current = false + } + prevIsStreamingRef.current = isStreaming + prevContentRef.current = content + + return isStreaming && animateRef.current +} + /** * Carries the contentRef and toggle handler from MarkdownPreview down to the * task-list renderers. Only present when the preview is interactive. @@ -876,6 +907,7 @@ const MarkdownPreview = memo(function MarkdownPreview({ // jumping per server chunk. `snapOnNonAppend` shows in-place rewrites (patch) // in full immediately so a diff never appears to retype from the top. const revealedContent = useSmoothText(content, isStreaming, { snapOnNonAppend: true }) + const shouldAnimateStream = useStreamAnimationGate(content, isStreaming) const { ref: autoScrollRef, spacerRef } = useScrollAnchor( isStreaming && !disableAutoScroll, revealedContent @@ -927,12 +959,12 @@ const MarkdownPreview = memo(function MarkdownPreview({ {frontMatterData && } {markdownContent} diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts index 5ad10a6342..292a032007 100644 --- a/apps/sim/hooks/use-smooth-text.ts +++ b/apps/sim/hooks/use-smooth-text.ts @@ -10,11 +10,13 @@ const MIN_STEP = 1 const MAX_STEP = 400 /** - * Content already longer than this at mount is assumed to be an in-progress - * resume (or restored history), so it is shown immediately rather than replayed - * from the first character. + * Content already longer than this when streaming begins is assumed to be + * pre-existing (an in-progress resume, restored history, or an in-place edit + * of an existing document), so it is shown immediately rather than replayed + * from the first character. Consumers gating reveal animations should use the + * same threshold so pacing and animation agree on what counts as "new". */ -const RESUME_SKIP_THRESHOLD = 60 +export const RESUME_SKIP_THRESHOLD = 60 interface SmoothTextOptions { /** From 9aa2a51655386a602381d3d6791caf222dc49abd Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 10 Jun 2026 13:10:40 -0700 Subject: [PATCH 09/10] improvement(mothership): smooth streamed text reveal + dropdown z-index fix (#4947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(emcn): render dropdown menus above modals so in-modal dropdowns are clickable The base DropdownMenuContent defaulted to --z-dropdown (100), below the modal at --z-modal (200) — the only Radix popper that sat below the modal (Popover/Tooltip/Toast all sit above). Since the modal overlay is semi-transparent, an in-modal dropdown was faintly visible but intercepted no clicks, which forced one-off z-popover overrides on ChipDropdown and ChipSelect. Move the DropdownMenu base to the popover layer (--z-popover, above the modal) and drop the redundant per-component overrides, so every menu — including the 39 raw DropdownMenu consumers — is clickable inside a chip modal from a single source of truth. The --z-dropdown variable stays at 100 for the in-flow panels that intentionally sit below modals. * improvement(mothership): smooth streamed text reveal and fix completion flash Port opencode's paced word-boundary reveal into useSmoothText so streamed text builds smoothly regardless of how the model chunks deltas, and keep it smooth through completion: - Reveal on a steady 24ms timer in tiered steps that snap to word/punctuation boundaries instead of revealing partial tokens. - Drain the lagging tail at the paced cadence on stream end instead of snapping; the consumer holds streaming render until the reveal catches up. - Pin a streamed message to Streamdown's streaming mode for its mounted lifetime so the static-mode swap doesn't remount and re-highlight the message. - Key the assistant row by its owning user message id so the live->persisted id swap no longer remounts the row (whole-message blink) at completion. * docs(emcn): trim z-index scale comment to original footprint Correct the pre-existing scale comment in place (the old wording became stale when DropdownMenu moved to the popover layer) rather than expanding it. The scale tokens are global, so their documentation stays with them. --- apps/sim/app/_styles/globals.css | 4 +- .../components/chat-content/chat-content.tsx | 23 +-- .../mothership-chat/mothership-chat.tsx | 16 ++- .../chip-dropdown/chip-dropdown.tsx | 1 - .../components/chip-select/chip-select.tsx | 2 +- .../dropdown-menu/dropdown-menu.tsx | 2 +- apps/sim/hooks/use-smooth-text.ts | 132 +++++++++--------- 7 files changed, 102 insertions(+), 78 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 2048673ec5..0baeb6d70a 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -21,8 +21,8 @@ --auth-primary-btn-hover-border: #e0e0e0; --auth-primary-btn-hover-text: #000000; - /* z-index scale for layered UI - Popover must be above modal so dropdowns inside modals render correctly */ + /* z-index scale. Transient poppers (menus, selects, popovers, tooltips, toasts) + sit above --z-modal so they stay clickable over the semi-transparent overlay. */ --z-dropdown: 100; --z-modal: 200; --z-popover: 300; 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 679c9a0c9c..75a323d04b 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 @@ -292,6 +292,11 @@ function ChatContentInner({ const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content]) const streamedContent = useSmoothText(displayContent, isStreaming) + const isRevealing = isStreaming || streamedContent.length < displayContent.length + + const streamedThisSession = useRef(false) + if (isStreaming) streamedThisSession.current = true + const keepStreamingTree = isRevealing || streamedThisSession.current useEffect(() => { const handler = (e: Event) => { @@ -308,8 +313,8 @@ function ChatContentInner({ }, []) const parsed = useMemo( - () => parseSpecialTags(streamedContent, isStreaming), - [streamedContent, isStreaming] + () => parseSpecialTags(streamedContent, isRevealing), + [streamedContent, isRevealing] ) const hasSpecialContent = parsed.hasPendingTag || parsed.segments.some((s) => s.type !== 'text') @@ -365,9 +370,9 @@ function ChatContentInner({ className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')} > {group.markdown} @@ -383,7 +388,7 @@ function ChatContentInner({ /> ) })} - {parsed.hasPendingTag && isStreaming && } + {parsed.hasPendingTag && isRevealing && }
) } @@ -391,9 +396,9 @@ function ChatContentInner({ return (
:first-child]:mt-0 [&>:last-child]:mb-0')}> {streamedContent} 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 89bea30c4a..a605459da3 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 @@ -223,6 +223,20 @@ export function MothershipChat({ } return out }, [messages]) + const assistantTurnKeyByIndex = useMemo(() => { + const out: string[] = [] + let lastUserId: string | undefined + let ordinal = 0 + for (const [index, message] of messages.entries()) { + if (message.role === 'user') { + lastUserId = message.id + ordinal = 0 + } else { + out[index] = lastUserId ? `assistant:${lastUserId}:${ordinal++}` : message.id + } + } + return out + }, [messages]) const initialScrollDoneRef = useRef(false) const userInputRef = useRef(null) @@ -297,7 +311,7 @@ export function MothershipChat({ const isLast = index === messages.length - 1 return ( ( align={align} onOpenAutoFocus={searchable ? (event) => event.preventDefault() : undefined} className={cn( - 'z-[var(--z-popover)]', matchTriggerWidth && 'w-[var(--radix-dropdown-menu-trigger-width)] max-w-none', contentClassName )} diff --git a/apps/sim/components/emcn/components/chip-select/chip-select.tsx b/apps/sim/components/emcn/components/chip-select/chip-select.tsx index 370326361f..e30a40165c 100644 --- a/apps/sim/components/emcn/components/chip-select/chip-select.tsx +++ b/apps/sim/components/emcn/components/chip-select/chip-select.tsx @@ -238,7 +238,7 @@ export function ChipSelect({ align={align} onOpenAutoFocus={searchable ? (e) => e.preventDefault() : undefined} style={contentStyle} - className={cn('z-[var(--z-popover)] min-w-[160px]', contentClassName)} + className={cn('min-w-[160px]', contentClassName)} > {searchable ? ( (null) + const timeoutRef = useRef | null>(null) const prevContentRef = useRef(content) - // A non-append rewrite (e.g. a patch replacing earlier text) must be shown in - // full at once — re-revealing a prefix of rewritten content would look like - // the document is retyping itself. Adjust during render so the slice below - // never flashes a stale prefix. let effectiveRevealed = revealed if ( snapOnNonAppend && @@ -74,72 +110,42 @@ export function useSmoothText( prevContentRef.current = content contentRef.current = content - streamingRef.current = isStreaming - - // Key the reveal loop to streaming + remaining backlog, NOT to `content`: - // `content` changes on every streamed chunk, and re-subscribing an rAF + setState - // loop on each change is the "a dependency changes on every render" pattern that - // trips React's max-update-depth guard. The running tick reads the latest content - // from `contentRef`, so new chunks are absorbed without per-chunk teardown; - // `hasBacklog` only flips when the reveal falls behind or catches up. - if (!isStreaming && effectiveRevealed !== content.length) { - effectiveRevealed = content.length - revealedRef.current = content.length - } const hasBacklog = effectiveRevealed < content.length useEffect(() => { - if (!isStreaming) { - revealedRef.current = contentRef.current.length - setRevealed(contentRef.current.length) - return - } + const run = () => { + timeoutRef.current = null + const text = contentRef.current + const target = text.length - const tick = () => { - const target = contentRef.current.length - // Upstream sanitization can rewrite earlier text and shrink the string; - // pull the cursor back to the new end so regrowth stays paced rather than - // jumping past it. if (revealedRef.current > target) { revealedRef.current = target setRevealed(target) } const current = revealedRef.current + if (current >= target) return - if (!streamingRef.current) { - revealedRef.current = target - setRevealed(target) - frameRef.current = null - return - } - if (current >= target) { - frameRef.current = null - return - } - - const backlog = target - current - const step = Math.min(MAX_STEP, Math.max(MIN_STEP, Math.ceil(backlog / REVEAL_DIVISOR))) - const next = current + step + const next = nextIndex(text, current) revealedRef.current = next setRevealed(next) - frameRef.current = window.requestAnimationFrame(tick) + if (next < target) { + timeoutRef.current = setTimeout(run, PACE_MS) + } } - if (hasBacklog && frameRef.current === null) { - frameRef.current = window.requestAnimationFrame(tick) + if (hasBacklog && timeoutRef.current === null) { + timeoutRef.current = setTimeout(run, PACE_MS) } return () => { - if (frameRef.current !== null) { - window.cancelAnimationFrame(frameRef.current) - frameRef.current = null + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null } } - }, [isStreaming, hasBacklog]) + }, [hasBacklog]) - // Content can shrink when upstream sanitization rewrites earlier text; never - // hand back a slice index past the current end. if (effectiveRevealed >= content.length) return content return content.slice(0, effectiveRevealed) } From 20dd6541276687e18d75e8604af3c350d200d352 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 10 Jun 2026 13:15:45 -0700 Subject: [PATCH 10/10] fix(billing): prevent deadlock with timeout (#4949) --- apps/sim/lib/billing/core/usage-log.test.ts | 53 +++++++++++++++++++++ apps/sim/lib/billing/core/usage-log.ts | 36 ++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index 66a6175744..448cb2eab4 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -208,6 +208,13 @@ describe('recordCumulativeUsage', () => { return { tx, select, updateSet } } + /** True when any tx.execute call ran a sql`` template containing the substring. */ + const executedSqlContaining = (tx: { execute: ReturnType }, substring: string) => + tx.execute.mock.calls.some(([arg]) => { + const strings = (arg as { strings?: readonly string[] } | null)?.strings + return Array.isArray(strings) && strings.some((s) => s.includes(substring)) + }) + it('inserts the full cumulative on the first flush', async () => { setupTx(null) const result = await recordCumulativeUsage({ @@ -253,4 +260,50 @@ describe('recordCumulativeUsage', () => { expect(updateSet).not.toHaveBeenCalled() expect(mockInsert).not.toHaveBeenCalled() }) + + it('resolves the billing context before opening the locked transaction, exactly once', async () => { + setupTx(null) + await recordCumulativeUsage({ + userId: 'user-1', + source: 'workspace-chat', + model: 'claude-opus-4.8', + cost: 0.3474447, + eventKey: 'update-cost:msg-1-billing', + }) + // One lookup total: pre-resolved outside the tx, and the first-flush + // insert reuses it instead of re-resolving on the pool inside the tx. + expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(1) + expect(mockGetHighestPrioritySubscription.mock.invocationCallOrder[0]).toBeLessThan( + mockTransaction.mock.invocationCallOrder[0] + ) + }) + + it('stamps the pre-resolved billing context onto the first-flush insert', async () => { + setupTx(null) + await recordCumulativeUsage({ + userId: 'user-1', + source: 'workspace-chat', + model: 'claude-opus-4.8', + cost: 0.3474447, + eventKey: 'update-cost:msg-1-billing', + }) + expect(mockValues.mock.calls[0][0][0]).toMatchObject({ + billingEntityId: 'org-1', + billingEntityType: 'organization', + }) + }) + + it('bounds the advisory-lock wait and locks on the 64-bit event-key hash', async () => { + const { tx } = setupTx({ id: 'row-1', cost: '0.3474447' }) + await recordCumulativeUsage({ + userId: 'user-1', + source: 'workspace-chat', + model: 'claude-opus-4.8', + cost: 0.4662453, + eventKey: 'update-cost:msg-1-billing', + }) + expect(executedSqlContaining(tx, 'lock_timeout')).toBe(true) + expect(executedSqlContaining(tx, 'pg_advisory_xact_lock')).toBe(true) + expect(executedSqlContaining(tx, 'hashtextextended')).toBe(true) + }) }) diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 083ee7de9a..5d654723d4 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -405,6 +405,16 @@ export interface RecordCumulativeUsageResult { total: number } +/** + * Bounds the wait for the per-event-key advisory lock (and any row/index lock + * waits inside the critical section). The Go mothership gives each UpdateCost + * POST a 5s deadline, retries 3x with backoff, then dead-letters the charge + * keyed on the same idempotency key — so a stuck lock holder must surface as + * a fast, retryable failure (SQLSTATE 55P03) within that budget rather than + * an unbounded wait that pins pooled connections. + */ +const CUMULATIVE_FLUSH_LOCK_TIMEOUT_MS = 3_000 + /** * Record a request's CUMULATIVE cost idempotently with monotonic top-up. * @@ -413,6 +423,9 @@ export interface RecordCumulativeUsageResult { * each flush. A per-key transactional advisory lock serializes concurrent * flushes so the read-then-write — including the first insert — is race-free * (no two flushes can both believe they are first and clobber each other). + * The billing context is resolved BEFORE the transaction and the lock wait is + * bounded by `lock_timeout`, keeping the critical section to one SELECT plus + * one INSERT/UPDATE on a single pooled connection. * * Because every leg flushes its cumulative and this converges to the max, * there is no under-billing if the request recovers after a partial flush, no @@ -424,9 +437,22 @@ export async function recordCumulativeUsage( ): Promise { const { userId, workspaceId, source, model, cost, eventKey, metadata } = params + // Resolved before the locked transaction on purpose: resolving inside it + // ran the subscription lookups on the global pool while this tx already + // held a pooled connection plus the advisory lock, so under load N + // first-flush transactions each pinned a connection while waiting for one + // more — starving the pool and queueing every same-key flush (and the Go + // side's retries) behind the stall. + const billingContext = await resolveBillingContext(userId) + return db.transaction(async (tx) => { - // Serialize all flushes for this request (lock auto-releases at tx end). - await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${eventKey}))`) + // Serialize all flushes for this request (lock auto-releases at tx end), + // with a bounded wait so a pathological holder fails this flush fast and + // lets the caller retry instead of hanging the connection. + await tx.execute( + sql`select set_config('lock_timeout', ${`${CUMULATIVE_FLUSH_LOCK_TIMEOUT_MS}ms`}, true)` + ) + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${eventKey}, 0))`) const [existing] = await tx .select({ id: usageLog.id, cost: usageLog.cost }) @@ -449,12 +475,14 @@ export async function recordCumulativeUsage( .set({ cost: newTotal.toString(), metadata: metadata ?? null }) .where(eq(usageLog.id, existing.id)) } else { - // First flush for this request: insert the canonical row (recordUsage - // resolves billing entity/period). Runs in the same tx + advisory lock. + // First flush for this request: insert the canonical row with the + // pre-resolved billing context. Runs in the same tx + advisory lock. await recordUsage({ userId, workspaceId, tx, + billingEntity: billingContext.billingEntity, + billingPeriod: billingContext.billingPeriod, entries: [ { category: 'model',