From bec463908169067c1da427b87307c34f77b36ed1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 22 Oct 2026 23:14:59 -0700 Subject: [PATCH 01/30] feat(mothership): run scheduled jobs as durable unattended chat turns (#9331) * feat(mothership): run unattended turns durably on the leased chat path A scheduled job's occurrence must outlive the Sim process driving it. The headless lifecycle the wake door uses takes no controller lease, so a pod death leaves its run unrecoverable. An unattended turn now admits like a typed send (user message, run and recovery intent in one commit under the chat lock), runs detached from any HTTP response, and records a new unattended_stream recovery kind. Takeover restores it non-interactive with the secret authority it was admitted with, instead of the interactive default, and the abandoned-run sweep, stream liveness and billing-admission recording treat both durable kinds alike. Durable start now also forwards a caller's billing-admission hook, so a job can key its per-run budget on the run's billing request. The integration test kills the turn's controller three times mid-run; it fails on a headless implementation, on a sweep blind to the new kind, and on a takeover that restores the interactive default. * feat(db): add scheduled job occurrence columns and archive legacy jobs Scheduled jobs return as Sim-owned schedules whose occurrences run as durable chat turns. A job's run record (job_execution_logs) gains the occurrence it runs (unique per schedule), its outcome and summary, and the billing request its charges settle under; a schedule gains a per-run spend cap. Indexes are built concurrently. Every job row written for the retired Go runner is archived, never deleted, so none can start once the tick claims jobs again. The archive is a script migration committing per keyed batch, so it finishes on any row count and resumes after a crash. * feat(mothership): run scheduled jobs from the schedule tick Behind MOTHERSHIP_JOBS_ENABLED (off by default, and to stay off in production until unattended runs hold production effects for approval), the schedule tick gains a job lane: - Claim: each due job's occurrence is recorded (queued) and the schedule advances past it in one transaction, skipping locked rows, so two ticks record it once and a crash never leaves a stale claim. Missed occurrences collapse into one; cadence is never under 15 minutes; a one-shot job (no cron) runs once and completes; max_runs completes. - Dispatch: the occurrence starts as an unattended durable turn in the job's chat under its occurrence id (UUIDv5 of schedule and instant), so every retry finds the turn it admitted. A busy chat skips the occurrence; a payer over its plan limit skips it; an owner who lost access turns the job off. Only secrets a person named for the job mount. - Reconcile: a queued occurrence whose dispatch was lost is dispatched again after two minutes; a running one whose turn ended is finalized; one past six hours is stopped. - Finalize: exactly once under a row lock. A reported outcome is kept; a turn that ended well without one is not_met, summarized by its last reply. Three failed or interrupted runs in a row turn the job off and email its people; a met until_complete job completes. - Budget: each billing callback records the run's cumulative settled cost on its record (keyed by the run's billing request), and a run at its cap pauses at its next step with a job_run_budget card. The cost is cumulative per billing request, which recovered legs reuse, so the cap survives worker hand-offs and Sim takeovers. * improvement(mothership): run the job lane unflagged behind one indexed probe * improvement(mothership): simplify the job lane and stop jobs through the shared Stop delivery * chore(db): format the scheduled job migration metadata * improvement(mothership): keep the durable-run predicates out of the chat protocol graph * fix(mothership): settle job runs before claiming, keep spend-cap links and failure counts durable * fix(mothership): stop orphaned job runs at their limit and skip any number of deleted occurrences * fix(mothership): end a running job record whose run was removed --- apps/sim/app/api/billing/update-cost/route.ts | 29 +- .../app/api/schedules/execute/route.test.ts | 5 + apps/sim/app/api/schedules/execute/route.ts | 8 + .../special-tags/special-tags.test.tsx | 25 + .../components/special-tags/special-tags.tsx | 37 + .../mothership-chat/mothership-chat.tsx | 43 +- .../mothership-chat/use-chat-find.test.tsx | 12 + .../mothership-chat/use-chat-find.ts | 2 +- .../app/workspace/[workspaceId]/home/types.ts | 6 +- apps/sim/lib/api/contracts/subscription.ts | 11 +- .../mothership/async-runs/orphaned-runs.ts | 3 +- .../lib/mothership/async-runs/repository.ts | 21 +- .../mothership/chat/application/admit-turn.ts | 71 +- .../chat/application/admit-unattended-turn.ts | 131 + .../lib/mothership/chat/persisted-message.ts | 11 +- .../lib/mothership/chat/stream-liveness.ts | 5 +- apps/sim/lib/mothership/generated/billing.ts | 5 +- apps/sim/lib/mothership/generated/protocol.ts | 11 +- apps/sim/lib/mothership/jobs/brief.ts | 66 + apps/sim/lib/mothership/jobs/budget.ts | 50 + apps/sim/lib/mothership/jobs/claim.ts | 109 + apps/sim/lib/mothership/jobs/dispatch.ts | 124 + apps/sim/lib/mothership/jobs/finalize.ts | 175 + .../lib/mothership/jobs/lane.integration.ts | 748 + apps/sim/lib/mothership/jobs/lane.test.ts | 26 + apps/sim/lib/mothership/jobs/lane.ts | 53 + .../lib/mothership/jobs/occurrence.test.ts | 48 + apps/sim/lib/mothership/jobs/occurrence.ts | 53 + apps/sim/lib/mothership/jobs/reconcile.ts | 182 + apps/sim/lib/mothership/jobs/runs.ts | 143 + .../request/application/controls.ts | 27 +- .../request/application/recover-stream.ts | 20 +- .../request/application/stop-delivery.ts | 37 +- .../request/lifecycle/durable-run.ts | 38 + .../request/lifecycle/recovery-config.ts | 13 +- .../lib/mothership/request/lifecycle/run.ts | 2 + .../lib/mothership/request/lifecycle/start.ts | 32 +- .../lifecycle/unattended.integration.ts | 401 + .../request/lifecycle/unattended.ts | 167 + .../schedules/disable-notifications.ts | 10 +- .../0437_scheduled_job_occurrences.sql | 13 + .../db/migrations/meta/0437_snapshot.json | 31774 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 25 + ...chive_legacy_scheduled_jobs.integration.ts | 95 + .../0036_archive_legacy_scheduled_jobs.ts | 51 + packages/db/script-migrations/index.ts | 3 + .../src/mocks/schema-tables.generated.ts | 5 + scripts/check-unused-exports.baseline.json | 1 - 49 files changed, 34815 insertions(+), 119 deletions(-) create mode 100644 apps/sim/lib/mothership/chat/application/admit-unattended-turn.ts create mode 100644 apps/sim/lib/mothership/jobs/brief.ts create mode 100644 apps/sim/lib/mothership/jobs/budget.ts create mode 100644 apps/sim/lib/mothership/jobs/claim.ts create mode 100644 apps/sim/lib/mothership/jobs/dispatch.ts create mode 100644 apps/sim/lib/mothership/jobs/finalize.ts create mode 100644 apps/sim/lib/mothership/jobs/lane.integration.ts create mode 100644 apps/sim/lib/mothership/jobs/lane.test.ts create mode 100644 apps/sim/lib/mothership/jobs/lane.ts create mode 100644 apps/sim/lib/mothership/jobs/occurrence.test.ts create mode 100644 apps/sim/lib/mothership/jobs/occurrence.ts create mode 100644 apps/sim/lib/mothership/jobs/reconcile.ts create mode 100644 apps/sim/lib/mothership/jobs/runs.ts create mode 100644 apps/sim/lib/mothership/request/lifecycle/durable-run.ts create mode 100644 apps/sim/lib/mothership/request/lifecycle/unattended.integration.ts create mode 100644 apps/sim/lib/mothership/request/lifecycle/unattended.ts create mode 100644 packages/db/migrations/0437_scheduled_job_occurrences.sql create mode 100644 packages/db/migrations/meta/0437_snapshot.json create mode 100644 packages/db/script-migrations/0036_archive_legacy_scheduled_jobs.integration.ts create mode 100644 packages/db/script-migrations/0036_archive_legacy_scheduled_jobs.ts diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 6ff3f097f8e..ee751b0386e 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -49,6 +49,7 @@ import { BILLING_CALLBACK_OUTCOME } from '@/lib/mothership/generated/billing-pro import { BillingRouteOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { recordJobRunSpend } from '@/lib/mothership/jobs/budget' import { checkInternalApiKey } from '@/lib/mothership/request/http' import { withIncomingGoSpan } from '@/lib/mothership/request/otel' @@ -122,6 +123,28 @@ async function readUsageStanding( } } +/** + * Adds a scheduled job run's own spend cap to the payer's standing. A payer over its plan limit + * keeps the plan card; otherwise a job run at its cap pauses with the job's card. A cap that + * cannot be read answers as the payer's standing alone, like a standing read that fails. + */ +async function withJobRunBudget( + verdict: BillingUsageVerdict, + billingRequestId: string | undefined, + cumulativeCostUsd: number +): Promise { + if (verdict.usageExceeded || !billingRequestId) return verdict + try { + const card = await recordJobRunSpend(billingRequestId, cumulativeCostUsd) + return card ? { usageExceeded: true, usageUpgrade: card } : verdict + } catch (error) { + logger.warn('Job run spend could not be recorded; answering the payer standing alone', { + error: toError(error).message, + }) + return verdict + } +} + function getBillingResolution( isMarkerlessLegacy: boolean, billingAttribution: BillingAttributionSnapshot | undefined @@ -442,7 +465,11 @@ async function updateCostInner(req: NextRequest, span: Span): Promise ({ vi.mock('@/lib/core/async-jobs', () => asyncJobsMock) +/** The job lane has its own integration suite. */ +vi.mock('@/lib/mothership/jobs/lane', () => ({ + runJobLane: vi.fn(async () => ({ claimed: 0 })), +})) + vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 2f5dc05244f..d92f4f68b45 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -50,6 +50,7 @@ import { registerManualExecutionAborter, unregisterManualExecutionAborter, } from '@/lib/execution/manual-cancellation' +import { runJobLane } from '@/lib/mothership/jobs/lane' import { buildCarrierReconciledMetadata, carrierNotReconciledSql, @@ -1788,6 +1789,13 @@ export async function runScheduleTick(requestId: string): Promise { act(() => root.unmount()) }) }) + +describe('UsageUpgradeDisplay', () => { + it("shows a job run's own spend cap without an account billing action", () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + act(() => { + root.render( + + ) + }) + + expect(container.querySelector('a')).toBeNull() + act(() => root.unmount()) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index c636b1a7fb5..8c361df0f07 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -3443,6 +3443,43 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) { } function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { + return data.reason === 'job_run_budget' ? ( + + ) : ( + + ) +} + +/** + * A scheduled job's run reached its own spend cap. The cap belongs to the job, not to the + * account, so the card offers no billing action: the next run starts with a fresh cap. + */ +interface JobRunBudgetDisplayProps { + message: string +} + +function JobRunBudgetDisplay({ message }: JobRunBudgetDisplayProps) { + return ( + + + Run Spend Limit Reached + + } + > +
+

{message}

+
+
+ ) +} + +/** The payer's plan limit: the card links to where the plan or the limit can change. */ +function PlanUsageLimitDisplay({ data }: { data: UsageUpgradeTagData }) { const { data: session } = useSession() const hostContext = useOptionalWorkspaceHostContext() const organizationContext = useOptionalOrganizationContext() 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 6e3abdb23e4..6498303e747 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 @@ -24,6 +24,7 @@ import { useQueryClient } from '@tanstack/react-query' import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' import { ComputerUseActivity } from '@/components/computer-use/activity' import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase' +import type { TurnOrigin } from '@/lib/mothership/generated/protocol' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { inter } from '@/app/_styles/fonts/inter/inter' import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar' @@ -187,24 +188,36 @@ interface UserMessageRowProps { } /** - * A background-task notification that opened a turn (mothership 21-background-tasks.md - * §6.4): a muted system chip, never a user bubble — the user did not type it. Shows the - * outcome line; the provenance header and the agent's own note stay out of the way. + * A turn automation opened, never a user bubble — the user did not type it: a muted system chip + * with one line. A background-task notification (mothership 21-background-tasks.md §6.4) shows + * its outcome line; a scheduled job's run shows the brief's first line (the job and its run). */ -const TaskNotificationRow = memo(function TaskNotificationRow({ - content, - rowClassName, -}: { +interface AutomatedTurnRowProps { + origin: TurnOrigin content: string rowClassName: string -}) { +} + +const AutomatedTurnRow = memo(function AutomatedTurnRow({ + origin, + content, + rowClassName, +}: AutomatedTurnRowProps) { const lines = content.split('\n').filter((line) => line.length > 0) - const outcome = lines.find((line) => line.startsWith('Task ')) ?? lines[lines.length - 1] ?? '' + const summary = + origin === 'task' + ? (lines.find((line) => line.startsWith('Task ')) ?? lines[lines.length - 1] ?? '').replace( + /^Task [0-9a-f-]+ /, + '' + ) + : (lines[0] ?? '').replace(/^Job: /, '') return (
- Background task - {outcome.replace(/^Task [0-9a-f-]+ /, '')} + + {origin === 'task' ? 'Background task' : 'Scheduled job'} + + {summary}
) @@ -930,8 +943,12 @@ export function MothershipChat({ style={{ top: virtualItem.start }} > {msg.role === 'user' ? ( - interactionPairing.hiddenUserByIndex[index] ? null : msg.origin === 'task' ? ( - + interactionPairing.hiddenUserByIndex[index] ? null : msg.origin ? ( + ) : ( { expect(find.current).toMatchObject({ isOpen: false, query: '', count: 0 }) }) + it('leaves turns automation opened out of the search, whatever opened them', () => { + const find = renderFind({ + messages: [ + { id: 't', role: 'user', origin: 'task', content: 'Task abc finished: triage done.' }, + { id: 'j', role: 'user', origin: 'job', content: 'Job: Nightly triage (Run 1)' }, + assistant('a', 'The triage is done.'), + ], + }) + find.search('triage') + expect(find.current.count).toBe(1) + }) + it('counts the same footnoted matches in the index as it highlights in the rendered message', async () => { const find = renderFind({ chatId: 'chat', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts index e94620a4368..38ae38e85eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/use-chat-find.ts @@ -163,7 +163,7 @@ export function useChatFind({ if (term) { for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) { const message = messages[messageIndex] - if (!message || hiddenUserByIndex[messageIndex] || message.origin === 'task') continue + if (!message || hiddenUserByIndex[messageIndex] || message.origin) continue const text = getMessageSearchText(message, textCache) const occurrences: ChatFindMatch[] = [] let found = 0 diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 2e86ef07509..1997858ae44 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -2,7 +2,7 @@ import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge/searc import type { MothershipChat } from '@/lib/api/contracts/mothership-chats' import type { ManagedMcpConnectorId } from '@/lib/credential-groups/managed-mcp-connectors' import type { AssistantSearchLevel } from '@/lib/mothership/generated/assistant' -import type { ToolActivity } from '@/lib/mothership/generated/protocol' +import type { ToolActivity, TurnOrigin } from '@/lib/mothership/generated/protocol' import type { ChatContext } from '@/stores/panel' import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types' @@ -197,8 +197,8 @@ export interface ChatMessage { role: 'user' | 'assistant' requestMode?: 'agent' | 'assistant' | 'plan' content: string - /** "task": a background-task notification opened this turn, not the user (a system chip, not a bubble). */ - origin?: 'task' + /** Automation opened this turn, not the user: a system chip, not a bubble. */ + origin?: TurnOrigin contentBlocks?: ContentBlock[] attachments?: ChatMessageAttachment[] fileAttachments?: FileAttachmentForApi[] diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts index e0932a3d8ba..8242b4ac1f3 100644 --- a/apps/sim/lib/api/contracts/subscription.ts +++ b/apps/sim/lib/api/contracts/subscription.ts @@ -5,7 +5,11 @@ import { usageLimitReadSchema, usageLimitUpdateSchema, } from '@/lib/billing/application/usage-limit-validation' -import { BillingCallbackBody, BillingCallbackHeaders } from '@/lib/mothership/generated/billing' +import { + BillingCallbackBody, + BillingCallbackHeaders, + UsageUpgrade, +} from '@/lib/mothership/generated/billing' const booleanQueryParamSchema = z .preprocess((value) => { @@ -315,8 +319,9 @@ export const billingSwitchPlanResponseSchema = z.object({ * limit writes the tag with this payload verbatim into its durable log. */ export const usageUpgradePayloadSchema = z.object({ - reason: z.literal('usage_limit'), - action: z.enum(['upgrade_plan', 'increase_limit']), + /** `job_run_budget`: a scheduled job's run reached its own spend cap, not the payer's plan. */ + reason: UsageUpgrade.shape.reason, + action: UsageUpgrade.shape.action, message: z.string(), }) export type UsageUpgradePayload = z.infer diff --git a/apps/sim/lib/mothership/async-runs/orphaned-runs.ts b/apps/sim/lib/mothership/async-runs/orphaned-runs.ts index 3bd93cad2b2..46e9c1c11c3 100644 --- a/apps/sim/lib/mothership/async-runs/orphaned-runs.ts +++ b/apps/sim/lib/mothership/async-runs/orphaned-runs.ts @@ -30,6 +30,7 @@ import { getRedisClient } from '@/lib/core/config/redis' import type { DbTransaction } from '@/lib/db/types' import { SIM_TOOL_EXECUTION_VERSION } from '@/lib/mothership/async-runs/lifecycle' import { publishChatStatusChanged } from '@/lib/mothership/chat-status' +import { isDurableStreamRun } from '@/lib/mothership/request/lifecycle/durable-run' import { acquirePendingChatStream, getChatStreamLockOwners, @@ -519,7 +520,7 @@ export async function findAbandonedRecoverableRuns( and( inArray(copilotRuns.status, UNFINISHED_RUN_STATUSES), isNotNull(controllerToken), - sql`${copilotRuns.requestContext}->'recovery'->>'kind' = 'interactive_stream'`, + isDurableStreamRun(copilotRuns.requestContext), idleFor(ABANDONED_RUN_IDLE_MS), not(idleFor(ORPHANED_RUN_GRACE_MS)), not(stopRequested), diff --git a/apps/sim/lib/mothership/async-runs/repository.ts b/apps/sim/lib/mothership/async-runs/repository.ts index d9fcd11f634..e0d69b87c8a 100644 --- a/apps/sim/lib/mothership/async-runs/repository.ts +++ b/apps/sim/lib/mothership/async-runs/repository.ts @@ -30,8 +30,6 @@ import { type SQL, sql, } from 'drizzle-orm' -import { z } from 'zod' -import { secretMountPolicySchema } from '@/lib/api/contracts/secret-mount-policy' import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' import type { DbOrTx, DbTransaction } from '@/lib/db/types' @@ -64,6 +62,10 @@ import { traceMothershipQuery, traceMothershipTransaction, } from '@/lib/mothership/observability/database' +import { + isDurableStreamRun, + parseRecordedSecretAuthority, +} from '@/lib/mothership/request/lifecycle/durable-run' import type { BillingAdmission } from '@/lib/mothership/request/lifecycle/recovery-config' import { markSpanForError } from '@/lib/mothership/request/otel' import { sealClientToolSettlement } from '@/lib/mothership/request/tools/client-completion-seal.server' @@ -82,7 +84,7 @@ import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-ke const logger = createLogger('CopilotAsyncRunsRepo') const WORKFLOW_EXECUTION_CLAIM_PREFIX = 'workflow:' -const TERMINAL_RUN_STATUSES: CopilotRunStatus[] = ['complete', 'error', 'cancelled'] +export const TERMINAL_RUN_STATUSES: CopilotRunStatus[] = ['complete', 'error', 'cancelled'] // Resolve the tracer lazily per-call to avoid capturing the NoOp tracer // before NodeSDK installs the global TracerProvider (Next.js 16/Turbopack // can evaluate modules before instrumentation-node.ts finishes). @@ -571,7 +573,7 @@ export async function recordRunBillingAdmission( eq(copilotRuns.id, runId), notInArray(copilotRuns.status, TERMINAL_RUN_STATUSES), sql`${copilotRuns.requestContext}->>'controllerToken' = ${controllerToken}`, - sql`${copilotRuns.requestContext}->'recovery'->>'kind' = 'interactive_stream'` + isDurableStreamRun(copilotRuns.requestContext) ) ) .returning({ id: copilotRuns.id, status: copilotRuns.status }) @@ -580,11 +582,6 @@ export async function recordRunBillingAdmission( ) } -const recordedSecretAuthoritySchema = z.object({ - secretMountPolicy: secretMountPolicySchema, - secretActorUserId: z.string().nullable(), -}) - /** * The secret authority a turn was admitted with, for a lane that rebuilds its tool context from * Go's request instead of inheriting the turn's. Headless runs record it at admission; an @@ -610,10 +607,8 @@ export async function getRunSecretAuthority(input: { .limit(1) if (!run || run.chatId !== input.chatId) return NO_SECRET_AUTHORITY - if (run.recorded !== null) { - const recorded = recordedSecretAuthoritySchema.safeParse(run.recorded) - return recorded.success ? recorded.data : NO_SECRET_AUTHORITY - } + if (run.recorded !== null) + return parseRecordedSecretAuthority(run.recorded) ?? NO_SECRET_AUTHORITY if (run.recoveryKind === 'interactive_stream' && run.requestMode !== 'assistant') { return { secretMountPolicy: { ...DEFAULT_SECRET_MOUNT_POLICY }, diff --git a/apps/sim/lib/mothership/chat/application/admit-turn.ts b/apps/sim/lib/mothership/chat/application/admit-turn.ts index f5897d58068..97da3da6fb5 100644 --- a/apps/sim/lib/mothership/chat/application/admit-turn.ts +++ b/apps/sim/lib/mothership/chat/application/admit-turn.ts @@ -7,6 +7,7 @@ import type { z } from 'zod' import { defineWorkspaceOperation } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' import { resolveTurnDesktopDevice } from '@/lib/desktop/application/executor' import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability' import { insertRunSegment, withRunAdmissionLock } from '@/lib/mothership/async-runs/repository' @@ -58,6 +59,50 @@ export async function turnDesktopDevice( return resolveTurnDesktopDevice(principal, offeredDeviceId) } +interface TurnChat { + userId: string + workspaceId?: string | null + organizationId?: string | null + chatId: string +} + +/** + * Makes an admitted turn its chat's current conversation, inside the admission's transaction: + * the chat's conversation mode follows the turn's, and a send's effort pick stays as the chat's + * choice. A chat deleted or moved since its context was resolved is `not_found`. + */ +export async function moveChatToTurn( + tx: DbTransaction, + chat: TurnChat, + turn: { messageId: string; mode?: string }, + effortChoice?: MothershipEffort +): Promise<{ model: string | null }> { + const turnConfig = sql`COALESCE(${copilotChats.config}, '{}'::jsonb) || jsonb_build_object('conversationMode', ${turn.mode ?? 'agent'}::text)` + const [updated] = await tx + .update(copilotChats) + .set({ + conversationId: turn.messageId, + updatedAt: new Date(), + config: effortChoice ? withChatEffortChoice(turnConfig, effortChoice) : turnConfig, + }) + .where( + and( + eq(copilotChats.id, chat.chatId), + eq(copilotChats.userId, chat.userId), + chat.workspaceId + ? eq(copilotChats.workspaceId, chat.workspaceId) + : isNull(copilotChats.workspaceId), + chat.organizationId + ? eq(copilotChats.organizationId, chat.organizationId) + : isNull(copilotChats.organizationId), + isNull(copilotChats.deletedAt) + ) + ) + .returning({ model: copilotChats.model }) + if (!updated) throw new OrchestrationError('not_found', 'Chat not found') + return updated +} + /** The accepted message, its start intent and retry destination commit together. */ export const admitChatTurn = defineAuthorizedChatUseCase({ operation: defineWorkspaceOperation({ @@ -102,32 +147,8 @@ export const admitChatTurn = defineAuthorizedChatUseCase({ } await assertChatStreamLease(input.lease) const desktopDeviceId = await turnDesktopDevice(principal, workspaceId, input.desktopDeviceId) - const turnConfig = sql`COALESCE(${copilotChats.config}, '{}'::jsonb) || jsonb_build_object('conversationMode', ${request.mode ?? 'agent'}::text)` return withRunAdmissionLock(userId, request.messageId, async (tx) => { - const [chat] = await tx - .update(copilotChats) - .set({ - conversationId: request.messageId, - updatedAt: new Date(), - config: input.effortChoice - ? withChatEffortChoice(turnConfig, input.effortChoice) - : turnConfig, - }) - .where( - and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, userId), - workspaceId - ? eq(copilotChats.workspaceId, workspaceId) - : isNull(copilotChats.workspaceId), - organizationId - ? eq(copilotChats.organizationId, organizationId) - : isNull(copilotChats.organizationId), - isNull(copilotChats.deletedAt) - ) - ) - .returning({ model: copilotChats.model }) - if (!chat) throw new OrchestrationError('not_found', 'Chat not found') + const chat = await moveChatToTurn(tx, context, request, input.effortChoice) await appendCopilotChatMessages( chatId, [buildPersistedUserMessage(input.message)], diff --git a/apps/sim/lib/mothership/chat/application/admit-unattended-turn.ts b/apps/sim/lib/mothership/chat/application/admit-unattended-turn.ts new file mode 100644 index 00000000000..cb431c47960 --- /dev/null +++ b/apps/sim/lib/mothership/chat/application/admit-unattended-turn.ts @@ -0,0 +1,131 @@ +import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import { copilotRuns } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type { z } from 'zod' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { insertRunSegment, withRunAdmissionLock } from '@/lib/mothership/async-runs/repository' +import { moveChatToTurn } from '@/lib/mothership/chat/application/admit-turn' +import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' +import { resolveOwnedWorkspaceChatContext } from '@/lib/mothership/chat/application/context' +import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' +import { buildPersistedUserMessage } from '@/lib/mothership/chat/persisted-message' +import { StreamRecoveryConfigSchema } from '@/lib/mothership/request/lifecycle/recovery-config' +import { + assertChatStreamLease, + type ChatStreamLease, +} from '@/lib/mothership/request/session/controller-lease' +import type { TurnSecretAuthority } from '@/lib/mothership/secret-mount-policy' + +/** The delegation audience a scheduled job's turn is admitted under. */ +export const UNATTENDED_TURN_AUDIENCE = 'sim:copilot-jobs' + +interface AdmitUnattendedTurnInput { + /** The turn's request is its identity: its message id names the run, its stream and its user message. */ + recovery: z.input + lease: ChatStreamLease + secretAuthority: TurnSecretAuthority +} + +type UnattendedAdmission = + | { kind: 'admitted'; run: Awaited> } + | { kind: 'duplicate' } + +/** + * Admits a turn nobody is watching (a scheduled job's occurrence) as a durable chat turn: the + * user message, the run and its recovery intent commit together under the chat lock its + * controller holds, exactly as a typed send's admission does, so Sim's takeover can drive it + * on after this process dies. The message id is the idempotency key: a second admission of + * the same occurrence finds its run and admits nothing. + */ +export const admitUnattendedTurn = defineAuthorizedChatUseCase({ + operation: defineWorkspaceOperation({ + id: 'mothership.chats.admit_unattended_turn', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'copilot.use', + contentDelivery: 'none', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + organizationOperation: defineOrganizationOperation({ + id: 'mothership.chats.admit_unattended_turn', + contentDelivery: 'none', + minimumRole: 'member', + capability: 'copilot.use', + principalKinds: ['organization_delegated'], + delegationAudience: UNATTENDED_TURN_AUDIENCE, + delegatedServices: ['copilot'], + }), + resolveContext: ({ + principal, + input, + }: { + principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + input: AdmitUnattendedTurnInput + }) => resolveOwnedWorkspaceChatContext(principal, input.recovery.request.chatId), + authorizationOptions: { + delegation: { + audience: UNATTENDED_TURN_AUDIENCE, + isWithinScope: (principal, context) => + principal.serviceId === 'copilot' && + principal.workspaceId === context.workspaceId && + principal.resourceScope?.chatId === context.chatId, + }, + }, + async execute({ context, input }): Promise { + const { userId, workspaceId, chatId } = context + const recovery = StreamRecoveryConfigSchema.parse(input.recovery) + const request = recovery.request + if ( + recovery.kind !== 'unattended_stream' || + !request.origin || + request.userId !== userId || + request.workspaceId !== workspaceId || + request.organizationId !== undefined || + request.chatId !== chatId + ) { + throw new OrchestrationError('validation', 'Turn identity does not match its chat') + } + const { messageId, message, origin } = request + await assertChatStreamLease(input.lease) + return withRunAdmissionLock(userId, messageId, async (tx) => { + const [existing] = await tx + .select({ id: copilotRuns.id }) + .from(copilotRuns) + .where(eq(copilotRuns.streamId, messageId)) + .limit(1) + if (existing) return { kind: 'duplicate' } as const + const chat = await moveChatToTurn(tx, context, request) + await appendCopilotChatMessages( + chatId, + [ + buildPersistedUserMessage({ + id: messageId, + content: message, + origin, + requestMode: 'agent', + }), + ], + { streamId: messageId, chatModel: chat.model }, + tx + ) + const run = await insertRunSegment(tx, { + id: messageId, + executionId: messageId, + chatId, + userId, + workspaceId, + streamId: messageId, + requestContext: { + requestId: messageId, + controllerToken: input.lease.value, + recovery, + secretAuthority: input.secretAuthority, + }, + }) + return { kind: 'admitted', run } as const + }) + }, +}) diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts index f54e7f58afa..761859ec9fb 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -24,7 +24,7 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/mothership/generated/mothership-stream-v1' -import { ToolActivity } from '@/lib/mothership/generated/protocol' +import { ToolActivity, TurnOrigin } from '@/lib/mothership/generated/protocol' import type { ContentBlock, LocalToolCallStatus, @@ -137,8 +137,8 @@ export interface PersistedMessage { requestMode?: 'agent' | 'assistant' | 'plan' content: string timestamp: string - /** "task": a background-task notification opened this turn, not the user (rendered as a system chip). */ - origin?: 'task' + /** Automation opened this turn, not the user (rendered as a system chip). */ + origin?: TurnOrigin requestId?: string contentBlocks?: PersistedContentBlock[] fileAttachments?: PersistedFileAttachment[] @@ -571,7 +571,7 @@ export interface UserMessageParams { content: string fileAttachments?: PersistedFileAttachment[] contexts?: PersistedMessageContext[] - origin?: 'task' + origin?: TurnOrigin } export function buildPersistedUserMessage(params: UserMessageParams): PersistedMessage { @@ -896,7 +896,8 @@ export function normalizeMessage(raw: Record): PersistedMessage if (raw.requestId && typeof raw.requestId === 'string') { msg.requestId = raw.requestId } - if (raw.origin === 'task') msg.origin = 'task' + const origin = TurnOrigin.safeParse(raw.origin) + if (origin.success) msg.origin = origin.data const rawBlocks = raw.contentBlocks as RawBlock[] | undefined const rawToolCalls = raw.toolCalls as LegacyToolCall[] | undefined diff --git a/apps/sim/lib/mothership/chat/stream-liveness.ts b/apps/sim/lib/mothership/chat/stream-liveness.ts index 8fe13d59235..9627a237b17 100644 --- a/apps/sim/lib/mothership/chat/stream-liveness.ts +++ b/apps/sim/lib/mothership/chat/stream-liveness.ts @@ -2,7 +2,8 @@ import { db } from '@sim/db' import { copilotChats, copilotRuns } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, eq, inArray, notInArray, sql } from 'drizzle-orm' +import { and, eq, inArray, notInArray } from 'drizzle-orm' +import { isDurableStreamRun } from '@/lib/mothership/request/lifecycle/durable-run' import { getChatStreamLockOwners } from '@/lib/mothership/request/session' const logger = createLogger('ChatStreamLiveness') @@ -83,7 +84,7 @@ export async function reconcileChatStreamMarkers( orphaned.map((candidate) => candidate.streamId!) ), notInArray(copilotRuns.status, ['complete', 'error', 'cancelled']), - sql`${copilotRuns.requestContext}->'recovery'->>'kind' = 'interactive_stream'` + isDurableStreamRun(copilotRuns.requestContext) ) ) : [] diff --git a/apps/sim/lib/mothership/generated/billing.ts b/apps/sim/lib/mothership/generated/billing.ts index cf01b6b4b98..18137ab0c09 100644 --- a/apps/sim/lib/mothership/generated/billing.ts +++ b/apps/sim/lib/mothership/generated/billing.ts @@ -64,9 +64,10 @@ export const BillingCallbackHeaders = z context.addIssue({ code: "custom", message: "Incomplete or conflicting billing protocol headers" }); }); -/** Sim's plan-aware usage card: the JSON body of the `` tag its chat renders. */ +/** Sim's plan-aware usage card: the JSON body of the `` tag its chat renders. + * `job_run_budget`: a scheduled job's run reached its own spend cap, not the payer's plan (D94). */ export const UsageUpgrade = z.object({ - reason: z.literal("usage_limit"), + reason: z.enum(["usage_limit", "job_run_budget"]), action: z.enum(["upgrade_plan", "increase_limit"]), message: z.string().min(1).max(1_000), }); diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 43aaed612f2..93526ed0d19 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -146,6 +146,12 @@ export const ENTITLEMENTS = { dashboards: "dashboards", tests: "tests", changelo export type Entitlement = (typeof ENTITLEMENTS)[keyof typeof ENTITLEMENTS]; export const Entitlements = z.array(z.string().min(1).max(64)).max(32); +/** What opened a turn when no person typed it: "task", Sim's wake for a background-task + * notification (21-background-tasks.md); "job", Sim's schedule for one occurrence of a scheduled + * job (D92), its message the job brief. Absent on a person's turn. */ +export const TurnOrigin = z.enum(["task", "job"]); +export type TurnOrigin = z.infer; + export const ChatPayloadSchema = z .strictObject({ desktop: DesktopContextSchema.optional(), @@ -192,9 +198,8 @@ export const ChatPayloadSchema = z /** Explicit client-executor declaration (see contracts ChatRequest.clientCapabilities); * the worker accepts and ignores it — dispatch semantics live on the sim side. */ clientCapabilities: z.array(z.string()).default([]), - /** "task": sim opened this turn for a background-task notification, not for a typed - * message (21-background-tasks.md); recorded on the turn's user_message event. */ - origin: z.enum(["task"]).optional(), + /** Recorded on the turn's user_message event. */ + origin: TurnOrigin.optional(), /** Per-turn effort dial (user-selected in the composer); absent = deployment default. */ effort: z.enum(["none", "low", "medium", "high", "xhigh", "max"]).optional(), modelSelection: ModelSelectionSchema.optional(), diff --git a/apps/sim/lib/mothership/jobs/brief.ts b/apps/sim/lib/mothership/jobs/brief.ts new file mode 100644 index 00000000000..90abda091bf --- /dev/null +++ b/apps/sim/lib/mothership/jobs/brief.ts @@ -0,0 +1,66 @@ +import { db, jobExecutionLogs } from '@sim/db' +import { truncate } from '@sim/utils/string' +import { and, desc, eq, isNotNull, lt } from 'drizzle-orm' +import type { JobRun } from '@/lib/mothership/jobs/runs' + +/** How many earlier runs' outcomes a brief carries. */ +const BRIEF_RECENT_RUNS = 5 +const BRIEF_SUMMARY_CHARS = 400 + +type JobBriefSchedule = Pick< + JobRun, + 'scheduleId' | 'jobTitle' | 'prompt' | 'successCondition' | 'lifecycle' | 'maxRuns' | 'runCount' +> + +/** + * The user message of one occurrence's turn: what the job is, how far along it is, and how its + * recent runs ended, read from the run records rather than from anything the model wrote. The + * worker frames it as an automated turn (origin `job`), so nothing here poses as the user. + */ +export async function buildJobBrief( + schedule: JobBriefSchedule, + occurrenceAt: Date, + runBudgetUsd: number +): Promise { + const recent = await db + .select({ + occurrenceAt: jobExecutionLogs.occurrenceAt, + outcome: jobExecutionLogs.outcome, + summary: jobExecutionLogs.summary, + }) + .from(jobExecutionLogs) + .where( + and( + eq(jobExecutionLogs.scheduleId, schedule.scheduleId), + isNotNull(jobExecutionLogs.outcome), + lt(jobExecutionLogs.occurrenceAt, occurrenceAt) + ) + ) + .orderBy(desc(jobExecutionLogs.occurrenceAt)) + .limit(BRIEF_RECENT_RUNS) + + const run = schedule.maxRuns + ? `Run ${schedule.runCount} of ${schedule.maxRuns}` + : `Run ${schedule.runCount}` + return [ + `Job: ${schedule.jobTitle ?? 'Untitled job'} (${run}, scheduled for ${occurrenceAt.toISOString()})`, + `Task:\n${schedule.prompt ?? ''}`, + schedule.successCondition ? `Success condition: ${schedule.successCondition}` : '', + schedule.lifecycle === 'until_complete' + ? 'The job stops running once its success condition is met.' + : 'The job keeps running on its schedule.', + recent.length > 0 + ? `Recent runs, newest first:\n${recent + .map( + (entry) => + `- ${entry.occurrenceAt?.toISOString() ?? '?'}: ${entry.outcome}${ + entry.summary ? ` — ${truncate(entry.summary, BRIEF_SUMMARY_CHARS)}` : '' + }` + ) + .join('\n')}` + : 'This is the first run.', + `This run may spend up to $${runBudgetUsd.toFixed(2)}.`, + ] + .filter((part) => part.length > 0) + .join('\n\n') +} diff --git a/apps/sim/lib/mothership/jobs/budget.ts b/apps/sim/lib/mothership/jobs/budget.ts new file mode 100644 index 00000000000..9a38f21f0f1 --- /dev/null +++ b/apps/sim/lib/mothership/jobs/budget.ts @@ -0,0 +1,50 @@ +import { db, jobExecutionLogs, workflowSchedule } from '@sim/db' +import { and, eq, sql } from 'drizzle-orm' +import type { UsageUpgradePayload } from '@/lib/api/contracts/subscription' + +/** The spend cap of one job run when its schedule names none, in USD. */ +const DEFAULT_JOB_RUN_BUDGET_USD = 5 + +/** A schedule's per-run cap, or the default when it names none. */ +export function jobRunBudgetUsd(runBudgetUsd: string | null): number { + const budget = runBudgetUsd === null ? Number.NaN : Number(runBudgetUsd) + return Number.isFinite(budget) && budget > 0 ? budget : DEFAULT_JOB_RUN_BUDGET_USD +} + +/** + * Records a job run's settled cost from a billing callback and reports whether the run has + * reached its own cap. The callback's cost is cumulative for the run's billing request, which + * every leg of the run reuses (a recovered leg restores the original admission), so the cap + * holds across worker hand-offs and Sim takeovers. A request that is not a job run's matches no + * record and answers null, so ordinary chats pay one index probe. + */ +export async function recordJobRunSpend( + billingRequestId: string, + cumulativeCostUsd: number +): Promise { + const [run] = await db + .update(jobExecutionLogs) + .set({ + cost: sql`jsonb_build_object('total', GREATEST(COALESCE((${jobExecutionLogs.cost}->>'total')::numeric, 0), ${cumulativeCostUsd}::numeric))`, + }) + .from(workflowSchedule) + .where( + and( + eq(jobExecutionLogs.billingRequestId, billingRequestId), + eq(workflowSchedule.id, jobExecutionLogs.scheduleId), + eq(workflowSchedule.sourceType, 'job') + ) + ) + .returning({ + runBudgetUsd: workflowSchedule.runBudgetUsd, + total: sql`${jobExecutionLogs.cost}->>'total'`, + }) + if (!run) return null + const budget = jobRunBudgetUsd(run.runBudgetUsd) + if (Number(run.total) < budget) return null + return { + reason: 'job_run_budget', + action: 'increase_limit', + message: `This scheduled job run reached its $${budget.toFixed(2)} spend limit, so it stopped here. The next run starts with a fresh limit.`, + } +} diff --git a/apps/sim/lib/mothership/jobs/claim.ts b/apps/sim/lib/mothership/jobs/claim.ts new file mode 100644 index 00000000000..39e8011b48a --- /dev/null +++ b/apps/sim/lib/mothership/jobs/claim.ts @@ -0,0 +1,109 @@ +import { db, jobExecutionLogs, workflowSchedule } from '@sim/db' +import { and, eq, inArray, isNotNull, isNull, lte, sql } from 'drizzle-orm' +import { jobOccurrenceId, nextJobOccurrence } from '@/lib/mothership/jobs/occurrence' + +/** The trigger every job run record carries; the logs surface lists runs under it. */ +export const JOB_RUN_TRIGGER = 'mothership' + +/** A live job whose next occurrence is due; `workflow_schedule_due_job_idx` covers it. */ +export const dueJob = (now: Date) => + and( + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt), + sql`${workflowSchedule.status} NOT IN ('disabled', 'completed')`, + isNotNull(workflowSchedule.nextRunAt), + lte(workflowSchedule.nextRunAt, now), + isNotNull(workflowSchedule.sourceUserId), + isNotNull(workflowSchedule.sourceWorkspaceId), + isNotNull(workflowSchedule.sourceChatId) + ) + +interface ClaimedOccurrence { + executionId: string + scheduleId: string + occurrenceAt: Date +} + +/** + * Claims each due job's occurrence in one transaction: its run record is written (status + * `queued`) and the schedule advances past it, together. The claim never waits for the run, + * so a process that dies after committing leaves a queued record for the reconciler, never a + * schedule stuck on a stale claim. Two ticks racing for a row skip each other's locks, and a + * record that already exists for the occurrence is kept, so each occurrence is recorded once. + * + * A schedule whose `max_runs` is spent completes without a run; one that will not run again (a + * one-shot job, a recurrence past its end) completes once this occurrence is claimed. + */ +export async function claimDueJobOccurrences( + now: Date, + limit: number +): Promise { + if (limit <= 0) return [] + return db.transaction(async (tx) => { + const due = await tx + .select({ + id: workflowSchedule.id, + nextRunAt: workflowSchedule.nextRunAt, + cronExpression: workflowSchedule.cronExpression, + timezone: workflowSchedule.timezone, + excludedDates: workflowSchedule.excludedDates, + endsAt: workflowSchedule.endsAt, + maxRuns: workflowSchedule.maxRuns, + runCount: workflowSchedule.runCount, + sourceWorkspaceId: workflowSchedule.sourceWorkspaceId, + }) + .from(workflowSchedule) + .where(dueJob(now)) + .orderBy(workflowSchedule.nextRunAt) + .limit(limit) + .for('update', { skipLocked: true }) + + const claimed: ClaimedOccurrence[] = [] + const exhausted: string[] = [] + for (const schedule of due) { + const occurrenceAt = schedule.nextRunAt + if (!occurrenceAt || !schedule.sourceWorkspaceId) continue + if (schedule.maxRuns !== null && schedule.runCount >= schedule.maxRuns) { + exhausted.push(schedule.id) + continue + } + const executionId = jobOccurrenceId(schedule.id, occurrenceAt) + const nextRunAt = nextJobOccurrence(schedule, occurrenceAt, now) + const runCount = schedule.runCount + 1 + const finished = + nextRunAt === null || (schedule.maxRuns !== null && runCount >= schedule.maxRuns) + await tx + .insert(jobExecutionLogs) + .values({ + id: executionId, + executionId, + scheduleId: schedule.id, + workspaceId: schedule.sourceWorkspaceId, + occurrenceAt, + level: 'info', + status: 'queued', + trigger: JOB_RUN_TRIGGER, + startedAt: now, + }) + .onConflictDoNothing() + await tx + .update(workflowSchedule) + .set({ + nextRunAt: finished ? null : nextRunAt, + lastQueuedAt: now, + runCount, + ...(finished ? { status: 'completed' } : {}), + updatedAt: now, + }) + .where(eq(workflowSchedule.id, schedule.id)) + claimed.push({ executionId, scheduleId: schedule.id, occurrenceAt }) + } + if (exhausted.length > 0) { + await tx + .update(workflowSchedule) + .set({ status: 'completed', nextRunAt: null, updatedAt: now }) + .where(inArray(workflowSchedule.id, exhausted)) + } + return claimed + }) +} diff --git a/apps/sim/lib/mothership/jobs/dispatch.ts b/apps/sim/lib/mothership/jobs/dispatch.ts new file mode 100644 index 00000000000..cb4683ad478 --- /dev/null +++ b/apps/sim/lib/mothership/jobs/dispatch.ts @@ -0,0 +1,124 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, +} from '@/lib/billing/core/attribution' +import { isHosted } from '@/lib/core/config/env-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { buildJobBrief } from '@/lib/mothership/jobs/brief' +import { jobRunBudgetUsd } from '@/lib/mothership/jobs/budget' +import { failUnstartedJobRun } from '@/lib/mothership/jobs/finalize' +import { + countJobDispatchFailure, + disableJob, + endUnstartedJobRun, + type JobRun, + loadJobRun, + markJobRunRunning, + recordJobRunBillingRequest, +} from '@/lib/mothership/jobs/runs' +import { startUnattendedTurn } from '@/lib/mothership/request/lifecycle/unattended' +import type { TurnSecretAuthority } from '@/lib/mothership/secret-mount-policy' +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' + +const logger = createLogger('MothershipJobDispatch') + +/** A queued occurrence whose start failed this many times ends as failed instead of retrying. */ +const MAX_JOB_DISPATCH_ATTEMPTS = 5 + +type JobDispatchOutcome = + | 'started' + | 'already_started' + | 'skipped_busy' + | 'skipped_budget' + | 'blocked' + | 'retry' + | 'failed' + | 'not_queued' + +/** + * An unattended run mounts only the secrets a person named for the job: a job whose policy + * says `all` (the column's default for workflow schedules) mounts none. + */ +function jobSecretAuthority(run: JobRun, userId: string): TurnSecretAuthority { + return { + secretMountPolicy: { + secretScope: 'selected', + mountedSecrets: run.secretScope === 'selected' ? run.mountedSecrets : [], + }, + secretActorUserId: userId, + } +} + +async function block(run: JobRun, summary: string): Promise { + await endUnstartedJobRun(run.executionId, 'blocked', summary) + if (await disableJob(run.scheduleId)) { + await notifyScheduleAutoDisabled({ scheduleId: run.scheduleId, reason: 'authorization_error' }) + } + return 'blocked' +} + +/** + * Starts a queued occurrence as a durable turn in its job's chat, at most once: the occurrence id + * is the turn's id, so a retry after a crash (or a second tick) finds the turn it already + * admitted instead of starting another. The owner's access is re-checked at admission; losing it + * turns the job off. An occurrence whose chat is busy, or whose payer is over its plan limit, is + * skipped rather than queued behind it. + */ +export async function dispatchJobOccurrence(executionId: string): Promise { + const run = await loadJobRun(executionId) + if (!run || run.status !== 'queued' || !run.occurrenceAt) return 'not_queued' + const { userId, workspaceId, chatId } = run + if (!userId || !workspaceId || !chatId) return block(run, 'The job has no owner or chat.') + if (run.archivedAt || run.scheduleStatus === 'disabled') { + await endUnstartedJobRun(executionId, 'blocked', 'The job was turned off before this run.') + return 'blocked' + } + + try { + const billingAttribution = await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + if (isHosted) { + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + await endUnstartedJobRun(executionId, 'skipped_budget', 'The usage limit was reached.') + return 'skipped_budget' + } + } + const message = await buildJobBrief(run, run.occurrenceAt, jobRunBudgetUsd(run.runBudgetUsd)) + const start = await startUnattendedTurn({ + userId, + workspaceId, + chatId, + messageId: executionId, + message, + origin: 'job', + billingAttribution, + secretAuthority: jobSecretAuthority(run, userId), + onBillingAdmission: (admission) => + recordJobRunBillingRequest(executionId, admission.billingRequestId), + }) + if (start === 'busy') { + await endUnstartedJobRun(executionId, 'skipped_busy', 'Another turn was running in the chat.') + return 'skipped_busy' + } + /** Another attempt admitted (or is admitting) this turn; the reconciler marks it once its run exists. */ + if (start === 'duplicate') return 'already_started' + await markJobRunRunning(executionId) + return 'started' + } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'forbidden' || code === 'not_found') { + return block(run, 'The job owner can no longer run it in this workspace.') + } + const failures = await countJobDispatchFailure(executionId) + logger.warn('Job occurrence could not start; the reconciler retries it', { + executionId, + failures, + error: getErrorMessage(error), + }) + if (failures < MAX_JOB_DISPATCH_ATTEMPTS) return 'retry' + await failUnstartedJobRun(executionId, run.scheduleId, 'The run could not be started.') + return 'failed' + } +} diff --git a/apps/sim/lib/mothership/jobs/finalize.ts b/apps/sim/lib/mothership/jobs/finalize.ts new file mode 100644 index 00000000000..aaa2f56ab7c --- /dev/null +++ b/apps/sim/lib/mothership/jobs/finalize.ts @@ -0,0 +1,175 @@ +import { copilotMessages, copilotRuns, db, jobExecutionLogs, workflowSchedule } from '@sim/db' +import { toRecord } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { and, desc, eq, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { TERMINAL_RUN_STATUSES } from '@/lib/mothership/async-runs/repository' +import { + endedRunStatus, + endUnstartedJobRun, + type JobRunOutcome, + MAX_CONSECUTIVE_JOB_FAILURES, + REPORTED_JOB_OUTCOMES, +} from '@/lib/mothership/jobs/runs' +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' + +const SUMMARY_CHARS = 2_000 + +/** + * Applies one finished run's outcome to its job: a run that reached a verdict (met or not) + * clears the failure streak, a failed or interrupted one extends it, and the third in a row + * turns the job off. A met `until_complete` job completes. Returns whether the job was turned + * off, so the caller can tell its people. + */ +async function settleJobSchedule( + tx: DbOrTx, + scheduleId: string, + outcome: JobRunOutcome +): Promise { + if (outcome === 'met' || outcome === 'not_met') { + await tx + .update(workflowSchedule) + .set({ + failedCount: 0, + ...(outcome === 'met' + ? { + status: sql`CASE WHEN ${workflowSchedule.lifecycle} = 'until_complete' AND ${workflowSchedule.status} = 'active' THEN 'completed' ELSE ${workflowSchedule.status} END`, + nextRunAt: sql`CASE WHEN ${workflowSchedule.lifecycle} = 'until_complete' THEN NULL ELSE ${workflowSchedule.nextRunAt} END`, + } + : {}), + updatedAt: new Date(), + }) + .where(eq(workflowSchedule.id, scheduleId)) + return false + } + if (outcome !== 'failed' && outcome !== 'interrupted') return false + const [schedule] = await tx + .update(workflowSchedule) + .set({ + failedCount: sql`${workflowSchedule.failedCount} + 1`, + lastFailedAt: new Date(), + status: sql`CASE WHEN ${workflowSchedule.failedCount} + 1 >= ${MAX_CONSECUTIVE_JOB_FAILURES} AND ${workflowSchedule.status} = 'active' THEN 'disabled' ELSE ${workflowSchedule.status} END`, + updatedAt: new Date(), + }) + .where(eq(workflowSchedule.id, scheduleId)) + .returning({ status: workflowSchedule.status, failedCount: workflowSchedule.failedCount }) + return schedule?.status === 'disabled' && schedule.failedCount === MAX_CONSECUTIVE_JOB_FAILURES +} + +/** The turn's last assistant text, as the summary of a run that reported no outcome. */ +async function lastAssistantText(tx: DbOrTx, chatId: string, streamId: string): Promise { + const [message] = await tx + .select({ content: copilotMessages.content }) + .from(copilotMessages) + .where( + and( + eq(copilotMessages.chatId, chatId), + eq(copilotMessages.streamId, streamId), + eq(copilotMessages.role, 'assistant') + ) + ) + .orderBy(desc(copilotMessages.seq)) + .limit(1) + const content = message ? toRecord(message.content).content : undefined + return typeof content === 'string' ? truncate(content, SUMMARY_CHARS) : '' +} + +/** + * Settles a running occurrence once its turn has ended. The record moves out of `running` + * under a row lock, so the turn's own end and a reconciler pass settle it exactly once: one + * outcome, one change to the job's failure streak. An outcome the agent reported is kept; a + * turn that ended well without one is `not_met`, summarized by its last reply, and is not a + * failure. A run whose row is gone (its chat was deleted) ends `interrupted`. Returns false while + * the turn is still live. + */ +export async function finalizeJobRun(executionId: string): Promise { + let disabledScheduleId: string | null = null + const settled = await db.transaction(async (tx) => { + const [record] = await tx + .select({ + scheduleId: jobExecutionLogs.scheduleId, + outcome: jobExecutionLogs.outcome, + summary: jobExecutionLogs.summary, + startedAt: jobExecutionLogs.startedAt, + executionData: jobExecutionLogs.executionData, + }) + .from(jobExecutionLogs) + .where( + and(eq(jobExecutionLogs.executionId, executionId), eq(jobExecutionLogs.status, 'running')) + ) + .for('update') + if (!record) return false + const [run] = await tx + .select({ + status: copilotRuns.status, + error: copilotRuns.error, + chatId: copilotRuns.chatId, + completedAt: copilotRuns.completedAt, + }) + .from(copilotRuns) + .where(eq(copilotRuns.streamId, executionId)) + .limit(1) + if (run && !TERMINAL_RUN_STATUSES.includes(run.status)) return false + + const reported = REPORTED_JOB_OUTCOMES.find((outcome) => outcome === record.outcome) + const deadlineStopped = toRecord(record.executionData).deadlineStopRequestedAt !== undefined + let outcome: JobRunOutcome + let summary: string + if (!run) { + /** A started run's row is gone (its chat was deleted): nothing will ever end it otherwise. */ + outcome = 'interrupted' + summary = 'The run was removed before it finished.' + } else if (run.status === 'complete' && reported) { + outcome = reported + summary = record.summary ?? '' + } else if (run.status === 'complete') { + outcome = 'not_met' + summary = await lastAssistantText(tx, run.chatId, executionId) + } else if (run.status === 'cancelled') { + outcome = 'interrupted' + summary = deadlineStopped ? 'Stopped at the run time limit.' : 'The run was stopped.' + } else { + outcome = 'failed' + summary = run.error ?? 'The run failed.' + } + const endedAt = run?.completedAt ?? new Date() + await tx + .update(jobExecutionLogs) + .set({ + ...endedRunStatus(outcome), + outcome, + summary, + endedAt, + totalDurationMs: Math.max(0, endedAt.getTime() - record.startedAt.getTime()), + }) + .where(eq(jobExecutionLogs.executionId, executionId)) + /** A record whose job was deleted mid-run still ends; there is no streak left to count. */ + if (record.scheduleId && (await settleJobSchedule(tx, record.scheduleId, outcome))) { + disabledScheduleId = record.scheduleId + } + return true + }) + if (disabledScheduleId) { + await notifyScheduleAutoDisabled({ + scheduleId: disabledScheduleId, + reason: 'consecutive_failures', + }) + } + return settled +} + +/** + * Ends an occurrence that could never be started as failed, counting it toward the job's failure + * streak in the same transaction, so a crash cannot close the record without the count. + */ +export async function failUnstartedJobRun( + executionId: string, + scheduleId: string, + summary: string +): Promise { + const disabled = await db.transaction(async (tx) => { + if (!(await endUnstartedJobRun(executionId, 'failed', summary, tx))) return false + return settleJobSchedule(tx, scheduleId, 'failed') + }) + if (disabled) await notifyScheduleAutoDisabled({ scheduleId, reason: 'consecutive_failures' }) +} diff --git a/apps/sim/lib/mothership/jobs/lane.integration.ts b/apps/sim/lib/mothership/jobs/lane.integration.ts new file mode 100644 index 00000000000..2811722f461 --- /dev/null +++ b/apps/sim/lib/mothership/jobs/lane.integration.ts @@ -0,0 +1,748 @@ +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +/** + * The schedule tick's job lane against real PostgreSQL and Redis: claiming occurrences, starting + * each as a durable unattended turn in its job's chat through the production admission and chat + * lifecycle, reconciling lost dispatches, surviving the death of the Sim process driving a turn, + * and finalizing each occurrence once. A local HTTP server stands in for the worker; each POST + * for a turn is one leg, which ends the turn, fails it, or stays open until the test acts. + */ +import { envFlagsMock, resetEnvFlagsMock } from '@sim/testing/mocks/env-flags.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => { + const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure') + const { createServer } = await import('node:http') + type Leg = { text: string; mode: 'end' | 'hang' | 'fail' } + const worker = { + requests: [] as Array<{ path: string; body: Record }>, + /** The legs each stream's successive POSTs answer; a stream with none completes at once. */ + legs: new Map(), + open: new Map(), + } + const frame = (streamId: string, chatId: unknown, seq: number, type: string, payload: unknown) => + `data: ${JSON.stringify({ + v: 1, + type, + seq, + ts: new Date().toISOString(), + stream: { streamId, chatId }, + payload, + })}\n\n` + const server = createServer(async (request, response) => { + let raw = '' + for await (const chunk of request) raw += chunk + const body = raw ? JSON.parse(raw) : {} + const path = request.url ?? '' + worker.requests.push({ path, body }) + if (path === '/api/streams/explicit-abort') { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ settled: true })) + return + } + if (path !== '/api/mothership') { + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'Run not found' })) + return + } + const streamId = String(body.messageId ?? '') + const leg = worker.legs.get(streamId)?.shift() ?? { text: 'Nothing to do.', mode: 'end' } + if (leg.mode === 'fail') { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'internal' })) + return + } + const offset = Number(body.receivedTextChars ?? 0) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write(frame(streamId, body.chatId, 1, 'session', { kind: 'start' })) + response.write( + frame(streamId, body.chatId, 2, 'text', { + channel: 'assistant', + text: leg.text, + textOffset: offset, + }) + ) + const textLength = offset + leg.text.length + if (leg.mode === 'end') { + response.write( + frame(streamId, body.chatId, 3, 'complete', { status: 'complete', textLength }) + ) + response.end('data: [DONE]\n\n') + return + } + worker.open.set(streamId, { response, offset: textLength }) + }) + /** Ends a turn's open leg as complete, as the worker does when the agent finishes. */ + const finish = (streamId: string, chatId: string) => { + const open = worker.open.get(streamId) + if (!open) throw new Error('No open leg to finish') + worker.open.delete(streamId) + open.response.write( + frame(streamId, chatId, 3, 'complete', { status: 'complete', textLength: open.offset }) + ) + open.response.end('data: [DONE]\n\n') + } + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as { port: number } + const url = readTestRedisUrl() + const inheritedEnv = { + REDIS_URL: process.env.REDIS_URL, + SIM_AGENT_API_URL: process.env.SIM_AGENT_API_URL, + COPILOT_API_KEY: process.env.COPILOT_API_KEY, + } + if (url) process.env.REDIS_URL = url + process.env.COPILOT_API_KEY = 'job-lane-fixture-key' + process.env.SIM_AGENT_API_URL = `http://127.0.0.1:${port}` + return { redisUrl: url, inheritedEnv, worker: Object.assign(worker, { server, finish }) } +}) + +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) + +/** Spied so the idle tick can prove it neither claims nor reconciles. */ +vi.mock('@/lib/mothership/jobs/claim', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, claimDueJobOccurrences: vi.fn(actual.claimDueJobOccurrences) } +}) +vi.mock('@/lib/mothership/jobs/reconcile', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, reconcileJobRuns: vi.fn(actual.reconcileJobRuns) } +}) +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const { controllers } = vi.hoisted(() => ({ controllers: new Map() })) +vi.mock('@/lib/mothership/request/session/abort', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + registerActiveStream: (streamId: string, controller: AbortController) => { + controllers.set(streamId, controller) + actual.registerActiveStream(streamId, controller) + }, + } +}) + +import { db } from '@sim/db' +import { + copilotChats, + copilotMessages, + copilotRuns, + jobExecutionLogs, + permissions, + user, + workflowSchedule, + workspace, +} from '@sim/db/schema' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { eq, inArray, sql } from 'drizzle-orm' +import { resolveBillingAttribution } from '@/lib/billing/core/attribution' +import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis' +import { ABANDONED_RUN_CURSOR_KEY } from '@/lib/mothership/async-runs/orphaned-runs' +import { chatPubSub } from '@/lib/mothership/chat-status' +import { recordJobRunSpend } from '@/lib/mothership/jobs/budget' +import { claimDueJobOccurrences } from '@/lib/mothership/jobs/claim' +import { dispatchJobOccurrence } from '@/lib/mothership/jobs/dispatch' +import { finalizeJobRun } from '@/lib/mothership/jobs/finalize' +import { jobLaneWorkQuery, runJobLane } from '@/lib/mothership/jobs/lane' +import { jobOccurrenceId, MIN_JOB_CADENCE_MS } from '@/lib/mothership/jobs/occurrence' +import { JOB_DISPATCH_GRACE_MS, reconcileJobRuns } from '@/lib/mothership/jobs/reconcile' +import { takeOverAbandonedRuns } from '@/lib/mothership/request/application/abandoned-runs' +import { startUnattendedTurn } from '@/lib/mothership/request/lifecycle/unattended' +import { + chatStreamLockKey, + StreamControllerSupersededError, +} from '@/lib/mothership/request/session/controller-lease' + +interface QueryPlan { + 'Node Type': string + 'Index Name'?: string + 'Shared Hit Blocks': number + 'Shared Read Blocks': number + Plans?: QueryPlan[] +} + +function planNodes(plan: QueryPlan): QueryPlan[] { + return [plan, ...(plan.Plans ?? []).flatMap(planNodes)] +} + +const userId = generateId() +const workspaceId = generateId() + +/** The fixture user's billing attribution, which the caller of an unattended turn resolves. */ +const payer = () => resolveBillingAttribution({ actorUserId: userId, workspaceId }) +const chatIds: string[] = [] +const scheduleIds: string[] = [] + +function redis() { + const client = getRedisClient() + if (!client) throw new Error('The integration suite requires TEST_REDIS_URL') + return client +} + +async function waitFor(condition: () => Promise, what: string, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await condition()) return + await sleep(50) + } + throw new Error(`Timed out waiting for ${what}`) +} + +interface JobFixture { + cronExpression?: string | null + dueAt?: Date + maxRuns?: number | null + runCount?: number + lifecycle?: 'persistent' | 'until_complete' +} + +async function seedJob(options: JobFixture = {}) { + const chatId = generateId() + chatIds.push(chatId) + await db + .insert(copilotChats) + .values({ id: chatId, userId, workspaceId, type: 'mothership', title: 'Job chat' }) + const scheduleId = generateId() + scheduleIds.push(scheduleId) + const dueAt = options.dueAt ?? new Date(Date.now() - 1_000) + await db.insert(workflowSchedule).values({ + id: scheduleId, + triggerType: 'schedule', + sourceType: 'job', + jobTitle: 'Nightly triage', + prompt: 'Triage the overnight queue.', + successCondition: 'The queue is empty.', + lifecycle: options.lifecycle ?? 'persistent', + cronExpression: options.cronExpression === undefined ? '0 * * * *' : options.cronExpression, + nextRunAt: dueAt, + maxRuns: options.maxRuns ?? null, + runCount: options.runCount ?? 0, + sourceUserId: userId, + sourceWorkspaceId: workspaceId, + sourceChatId: chatId, + secretScope: 'selected', + mountedSecrets: [], + }) + return { chatId, scheduleId, occurrenceId: jobOccurrenceId(scheduleId, dueAt), dueAt } +} + +async function schedule(scheduleId: string) { + const [row] = await db + .select({ + status: workflowSchedule.status, + nextRunAt: workflowSchedule.nextRunAt, + runCount: workflowSchedule.runCount, + failedCount: workflowSchedule.failedCount, + }) + .from(workflowSchedule) + .where(eq(workflowSchedule.id, scheduleId)) + return row +} + +async function runsOf(scheduleId: string) { + return db + .select({ + executionId: jobExecutionLogs.executionId, + status: jobExecutionLogs.status, + outcome: jobExecutionLogs.outcome, + summary: jobExecutionLogs.summary, + }) + .from(jobExecutionLogs) + .where(eq(jobExecutionLogs.scheduleId, scheduleId)) +} + +/** Makes a schedule due again now, as its next occurrence arriving would. */ +async function dueAgain(scheduleId: string) { + const dueAt = new Date(Date.now() - 1_000) + await db + .update(workflowSchedule) + .set({ nextRunAt: dueAt }) + .where(eq(workflowSchedule.id, scheduleId)) + return jobOccurrenceId(scheduleId, dueAt) +} + +function legsFor(streamId: string) { + return worker.requests.filter( + (request) => request.path === '/api/mothership' && request.body.messageId === streamId + ) +} + +/** Reconciler passes (as successive ticks run them) until the occurrence's record is closed. */ +async function reconcileUntilSettled(executionId: string) { + await waitFor(async () => { + await reconcileJobRuns(new Date()) + const [row] = await db + .select({ status: jobExecutionLogs.status }) + .from(jobExecutionLogs) + .where(eq(jobExecutionLogs.executionId, executionId)) + return row?.status === 'complete' || row?.status === 'error' + }, 'the occurrence to settle') +} + +afterAll(async () => { + await new Promise((resolve) => worker.server.close(() => resolve())) + for (const [key, value] of Object.entries(inheritedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +describe.runIf(Boolean(redisUrl))('the scheduled job lane', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Job lane fixture', + email: `${userId}@job-lane.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Job lane fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + }) + + beforeEach(async () => { + worker.requests.length = 0 + resetEnvFlagsMock() + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) + await db + .insert(permissions) + .values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + .onConflictDoNothing() + /** Each test sees only its own jobs as due. */ + await db + .update(workflowSchedule) + .set({ status: 'disabled' }) + .where(eq(workflowSchedule.sourceWorkspaceId, workspaceId)) + }) + + afterAll(async () => { + for (const scheduleId of scheduleIds) { + await db.delete(jobExecutionLogs).where(eq(jobExecutionLogs.scheduleId, scheduleId)) + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId)) + } + for (const chatId of chatIds) { + await db.delete(copilotMessages).where(eq(copilotMessages.chatId, chatId)) + await db.delete(copilotRuns).where(eq(copilotRuns.chatId, chatId)) + await db.delete(copilotChats).where(eq(copilotChats.id, chatId)) + } + await db.delete(permissions).where(eq(permissions.userId, userId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + chatPubSub?.dispose() + await closeRedisConnection() + }) + + it('does nothing but one indexed probe on a tick with no live jobs', async () => { + /** What the archive migration leaves behind: retired jobs and their finished run logs. */ + const archivedIds = Array.from({ length: 2_000 }, () => generateId()) + await db.insert(workflowSchedule).values( + archivedIds.map((id) => ({ + id, + triggerType: 'schedule', + sourceType: 'job', + status: 'disabled', + archivedAt: new Date(), + nextRunAt: new Date(Date.now() - 60_000), + sourceUserId: userId, + sourceWorkspaceId: workspaceId, + })) + ) + await db.insert(jobExecutionLogs).values( + Array.from({ length: 5_000 }, () => { + const id = generateId() + return { + id, + executionId: id, + workspaceId, + level: 'info', + status: 'completed', + trigger: 'mothership', + startedAt: new Date(Date.now() - 60_000), + } + }) + ) + await db.execute(sql`ANALYZE workflow_schedule, job_execution_logs`) + const now = new Date() + vi.mocked(claimDueJobOccurrences).mockClear() + vi.mocked(reconcileJobRuns).mockClear() + + try { + expect(await runJobLane(now)).toEqual({ claimed: 0 }) + expect(claimDueJobOccurrences).not.toHaveBeenCalled() + expect(reconcileJobRuns).not.toHaveBeenCalled() + + const plans = await db.execute<{ 'QUERY PLAN': { Plan: QueryPlan }[] }>( + sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${jobLaneWorkQuery(now)}` + ) + const plan = plans[0]['QUERY PLAN'][0].Plan + const nodes = planNodes(plan) + expect(nodes.some((node) => node['Node Type'] === 'Seq Scan')).toBe(false) + expect(nodes.map((node) => node['Index Name']).filter(Boolean)).toEqual( + expect.arrayContaining(['workflow_schedule_due_job_idx', 'job_execution_logs_open_idx']) + ) + expect(plan['Shared Hit Blocks'] + plan['Shared Read Blocks']).toBeLessThan(20) + } finally { + await db.delete(jobExecutionLogs).where(eq(jobExecutionLogs.workspaceId, workspaceId)) + await db.delete(workflowSchedule).where(inArray(workflowSchedule.id, archivedIds)) + } + }) + + it('records an occurrence once when two ticks claim it at the same time, and starts it once', async () => { + const { scheduleId, occurrenceId, dueAt } = await seedJob() + const now = new Date() + + const [first, second] = await Promise.all([ + claimDueJobOccurrences(now, 10), + claimDueJobOccurrences(now, 10), + ]) + const claims = [...first, ...second].filter((claim) => claim.scheduleId === scheduleId) + expect(claims).toEqual([{ executionId: occurrenceId, scheduleId, occurrenceAt: dueAt }]) + expect(await runsOf(scheduleId)).toHaveLength(1) + expect(await schedule(scheduleId)).toMatchObject({ status: 'active', runCount: 1 }) + /** The next occurrence is the cron's next hour, never sooner than the minimum cadence. */ + const { nextRunAt } = await schedule(scheduleId) + expect(nextRunAt?.getTime()).toBeGreaterThanOrEqual(dueAt.getTime() + MIN_JOB_CADENCE_MS) + + const outcomes = await Promise.all([ + dispatchJobOccurrence(occurrenceId), + dispatchJobOccurrence(occurrenceId), + ]) + expect(outcomes.filter((outcome) => outcome === 'started')).toHaveLength(1) + await reconcileUntilSettled(occurrenceId) + expect(legsFor(occurrenceId)).toHaveLength(1) + expect(await runsOf(scheduleId)).toEqual([ + expect.objectContaining({ + status: 'complete', + outcome: 'not_met', + summary: 'Nothing to do.', + }), + ]) + }) + + it('dispatches an occurrence whose claim committed but whose dispatch was lost, once', async () => { + const { scheduleId, occurrenceId } = await seedJob() + await claimDueJobOccurrences(new Date(), 10) + + /** Within the grace the dispatch may still be in flight elsewhere: nothing happens. */ + await reconcileJobRuns(new Date()) + expect(legsFor(occurrenceId)).toHaveLength(0) + + const later = new Date(Date.now() + JOB_DISPATCH_GRACE_MS + 1_000) + await Promise.all([reconcileJobRuns(later), reconcileJobRuns(later)]) + await reconcileUntilSettled(occurrenceId) + await reconcileJobRuns(later) + + expect(legsFor(occurrenceId)).toHaveLength(1) + expect(await dispatchJobOccurrence(occurrenceId)).toBe('not_queued') + const [chatRun] = await db + .select({ chatId: copilotRuns.chatId }) + .from(copilotRuns) + .where(eq(copilotRuns.streamId, occurrenceId)) + expect( + await startUnattendedTurn({ + userId, + workspaceId, + chatId: chatRun.chatId, + messageId: occurrenceId, + message: 'again', + origin: 'job', + billingAttribution: await payer(), + secretAuthority: { + secretMountPolicy: { secretScope: 'selected', mountedSecrets: [] }, + secretActorUserId: userId, + }, + }) + ).toBe('duplicate') + expect(await runsOf(scheduleId)).toHaveLength(1) + }) + + it('finishes an occurrence whose Sim process died mid-run, finalizing it exactly once', async () => { + const { chatId, scheduleId, occurrenceId } = await seedJob() + worker.legs.set(occurrenceId, [ + { text: 'Started triage. ', mode: 'hang' }, + { text: 'Finished triage.', mode: 'end' }, + ]) + await runJobLane(new Date()) + await waitFor(async () => worker.open.has(occurrenceId), 'the first leg') + + controllers.get(occurrenceId)?.abort(new StreamControllerSupersededError()) + worker.open.get(occurrenceId)?.response.socket?.destroy() + worker.open.delete(occurrenceId) + await waitFor(async () => !(await redis().get(chatStreamLockKey(chatId))), 'the lock to lapse') + /** Before the takeover the turn is unfinished, so the occurrence stays running. */ + expect(await finalizeJobRun(occurrenceId)).toBe(false) + await db + .update(copilotRuns) + .set({ updatedAt: sql`now() - interval '2 minutes'` }) + .where(eq(copilotRuns.streamId, occurrenceId)) + await waitFor(async () => { + await redis().del(ABANDONED_RUN_CURSOR_KEY) + await takeOverAbandonedRuns() + return legsFor(occurrenceId).length === 2 + }, 'the takeover') + await waitFor(async () => { + const [run] = await db + .select({ status: copilotRuns.status }) + .from(copilotRuns) + .where(eq(copilotRuns.streamId, occurrenceId)) + return run?.status === 'complete' + }, 'the turn to complete') + + const settled = await Promise.all([ + finalizeJobRun(occurrenceId), + finalizeJobRun(occurrenceId), + finalizeJobRun(occurrenceId), + ]) + expect(settled.filter(Boolean)).toHaveLength(1) + expect(await runsOf(scheduleId)).toEqual([ + expect.objectContaining({ + status: 'complete', + outcome: 'not_met', + summary: 'Started triage. Finished triage.', + }), + ]) + expect(await schedule(scheduleId)).toMatchObject({ failedCount: 0, status: 'active' }) + }, 60_000) + + it('stops a run at its time limit as a person pressing Stop would, ending it interrupted', async () => { + const { scheduleId, occurrenceId } = await seedJob() + worker.legs.set(occurrenceId, [{ text: 'Still triaging. ', mode: 'hang' }]) + await runJobLane(new Date()) + await waitFor(async () => worker.open.has(occurrenceId), 'the first leg') + + await reconcileJobRuns(new Date(Date.now() + 6 * 60 * 60_000 + 60_000)) + await reconcileUntilSettled(occurrenceId) + + expect( + worker.requests.filter( + (request) => + request.path === '/api/streams/explicit-abort' && request.body.messageId === occurrenceId + ) + ).toHaveLength(1) + expect(await runsOf(scheduleId)).toEqual([ + expect.objectContaining({ + status: 'complete', + outcome: 'interrupted', + summary: 'Stopped at the run time limit.', + }), + ]) + worker.open.get(occurrenceId)?.response.end() + worker.open.delete(occurrenceId) + }, 60_000) + + it('runs a one-shot job once and completes it', async () => { + const { scheduleId, occurrenceId } = await seedJob({ cronExpression: null }) + await runJobLane(new Date()) + await reconcileUntilSettled(occurrenceId) + await runJobLane(new Date()) + + expect(await schedule(scheduleId)).toMatchObject({ + status: 'completed', + nextRunAt: null, + runCount: 1, + }) + expect(await runsOf(scheduleId)).toHaveLength(1) + expect(legsFor(occurrenceId)).toHaveLength(1) + }) + + it('completes a job whose max_runs is spent without running it', async () => { + const { scheduleId, occurrenceId } = await seedJob({ maxRuns: 2, runCount: 2 }) + await runJobLane(new Date()) + expect(await schedule(scheduleId)).toMatchObject({ status: 'completed', runCount: 2 }) + expect(await runsOf(scheduleId)).toEqual([]) + expect(legsFor(occurrenceId)).toHaveLength(0) + }) + + it('skips an occurrence while another turn holds the job chat', async () => { + const { chatId, scheduleId, occurrenceId } = await seedJob() + await redis().set(chatStreamLockKey(chatId), `${generateId()}\n${generateId()}`, 'EX', 60) + try { + await runJobLane(new Date()) + } finally { + await redis().del(chatStreamLockKey(chatId)) + } + expect(await runsOf(scheduleId)).toEqual([ + expect.objectContaining({ status: 'complete', outcome: 'skipped_busy' }), + ]) + expect(legsFor(occurrenceId)).toHaveLength(0) + expect(await schedule(scheduleId)).toMatchObject({ failedCount: 0, status: 'active' }) + }) + + it('turns the job off, starting nothing, once its owner lost access', async () => { + const { scheduleId, occurrenceId } = await seedJob() + await db.delete(permissions).where(eq(permissions.userId, userId)) + await runJobLane(new Date()) + expect(await runsOf(scheduleId)).toEqual([ + expect.objectContaining({ status: 'complete', outcome: 'blocked' }), + ]) + expect(await schedule(scheduleId)).toMatchObject({ status: 'disabled' }) + expect(legsFor(occurrenceId)).toHaveLength(0) + }) + + it('completes an until_complete job once a run reports its condition met', async () => { + const { chatId, scheduleId, occurrenceId } = await seedJob({ lifecycle: 'until_complete' }) + worker.legs.set(occurrenceId, [{ text: 'The queue is empty.', mode: 'hang' }]) + await runJobLane(new Date()) + await waitFor(async () => worker.open.has(occurrenceId), 'the leg') + /** What `jobs report --outcome met` records while the run is live. */ + await db + .update(jobExecutionLogs) + .set({ outcome: 'met', summary: 'Queue empty.' }) + .where(eq(jobExecutionLogs.executionId, occurrenceId)) + worker.finish(occurrenceId, chatId) + await waitFor(async () => { + const [run] = await db + .select({ status: copilotRuns.status }) + .from(copilotRuns) + .where(eq(copilotRuns.streamId, occurrenceId)) + return run?.status === 'complete' + }, 'the turn to complete') + /** The next occurrence comes due before any pass settled the met run. */ + await dueAgain(scheduleId) + await runJobLane(new Date()) + + expect(await runsOf(scheduleId)).toEqual([ + expect.objectContaining({ status: 'complete', outcome: 'met', summary: 'Queue empty.' }), + ]) + expect(await schedule(scheduleId)).toMatchObject({ status: 'completed', nextRunAt: null }) + }) + + it('gives a recovered run back the spend-cap link its dead process never wrote', async () => { + const { chatId, scheduleId, occurrenceId } = await seedJob() + worker.legs.set(occurrenceId, [{ text: 'Working.', mode: 'hang' }]) + await runJobLane(new Date()) + await waitFor(async () => worker.open.has(occurrenceId), 'the leg') + const billingRequestId = generateId() + /** Its admission was recorded on the run, then the process died before linking the record. */ + await db + .update(copilotRuns) + .set({ + requestContext: sql`jsonb_set(${copilotRuns.requestContext}, '{recovery,billingAdmission}', ${JSON.stringify({ billingRequestId, serializedAttribution: 'fixture' })}::jsonb)`, + }) + .where(eq(copilotRuns.streamId, occurrenceId)) + await db + .update(jobExecutionLogs) + .set({ billingRequestId: null }) + .where(eq(jobExecutionLogs.executionId, occurrenceId)) + + await reconcileJobRuns(new Date()) + + const [record] = await db + .select({ billingRequestId: jobExecutionLogs.billingRequestId }) + .from(jobExecutionLogs) + .where(eq(jobExecutionLogs.scheduleId, scheduleId)) + expect(record?.billingRequestId).toBe(billingRequestId) + worker.finish(occurrenceId, chatId) + await reconcileUntilSettled(occurrenceId) + }) + + it('ends a running occurrence whose run was removed, instead of re-reading it forever', async () => { + const { chatId, scheduleId, occurrenceId } = await seedJob() + worker.legs.set(occurrenceId, [{ text: 'Working.', mode: 'hang' }]) + await runJobLane(new Date()) + await waitFor(async () => worker.open.has(occurrenceId), 'the leg') + controllers.get(occurrenceId)?.abort(new StreamControllerSupersededError()) + worker.open.get(occurrenceId)?.response.socket?.destroy() + worker.open.delete(occurrenceId) + await waitFor(async () => !(await redis().get(chatStreamLockKey(chatId))), 'the lock to lapse') + await db.delete(copilotRuns).where(eq(copilotRuns.streamId, occurrenceId)) + + await reconcileJobRuns(new Date(Date.now() + 6 * 60 * 60_000 + 60_000)) + + expect(await runsOf(scheduleId)).toEqual([ + expect.objectContaining({ status: 'complete', outcome: 'interrupted' }), + ]) + }, 60_000) + + it('ends a queued occurrence whose job was deleted, so the lane goes idle', async () => { + const { scheduleId, occurrenceId } = await seedJob() + await claimDueJobOccurrences(new Date(), 10) + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId)) + + await reconcileJobRuns(new Date()) + + const [record] = await db + .select({ status: jobExecutionLogs.status, outcome: jobExecutionLogs.outcome }) + .from(jobExecutionLogs) + .where(eq(jobExecutionLogs.executionId, occurrenceId)) + expect(record).toEqual({ status: 'complete', outcome: 'blocked' }) + await db.delete(jobExecutionLogs).where(eq(jobExecutionLogs.executionId, occurrenceId)) + }) + + it('turns a job off after three failed runs in a row, and a verdict clears the streak', async () => { + const { scheduleId, occurrenceId } = await seedJob() + const fail = (id: string) => + worker.legs.set( + id, + Array.from({ length: 6 }, () => ({ text: '', mode: 'fail' as const })) + ) + fail(occurrenceId) + await runJobLane(new Date()) + await reconcileUntilSettled(occurrenceId) + expect(await schedule(scheduleId)).toMatchObject({ failedCount: 1, status: 'active' }) + + const recovered = await dueAgain(scheduleId) + await runJobLane(new Date()) + await reconcileUntilSettled(recovered) + expect(await schedule(scheduleId)).toMatchObject({ failedCount: 0, status: 'active' }) + + for (let failure = 1; failure <= 3; failure++) { + const id = await dueAgain(scheduleId) + fail(id) + await runJobLane(new Date()) + await reconcileUntilSettled(id) + expect(await schedule(scheduleId)).toMatchObject({ + failedCount: failure, + status: failure < 3 ? 'active' : 'disabled', + }) + } + const outcomes = (await runsOf(scheduleId)).map((run) => run.outcome).sort() + expect(outcomes).toEqual(['failed', 'failed', 'failed', 'failed', 'not_met']) + + await dueAgain(scheduleId) + await runJobLane(new Date()) + expect(await runsOf(scheduleId)).toHaveLength(5) + }, 120_000) + + it('pauses a job run once its settled cost reaches the cap, across out-of-order callbacks', async () => { + const { scheduleId, occurrenceId } = await seedJob() + await claimDueJobOccurrences(new Date(), 10) + const billingRequestId = generateId() + await db + .update(jobExecutionLogs) + .set({ billingRequestId }) + .where(eq(jobExecutionLogs.executionId, occurrenceId)) + await db + .update(workflowSchedule) + .set({ runBudgetUsd: '1.5000' }) + .where(eq(workflowSchedule.id, scheduleId)) + + expect(await recordJobRunSpend(billingRequestId, 0.9)).toBeNull() + expect(await recordJobRunSpend(billingRequestId, 1.6)).toMatchObject({ + reason: 'job_run_budget', + action: 'increase_limit', + }) + /** A late, lower cumulative callback never lowers the recorded spend. */ + expect(await recordJobRunSpend(billingRequestId, 1.2)).toMatchObject({ + reason: 'job_run_budget', + }) + const [row] = await db + .select({ cost: jobExecutionLogs.cost }) + .from(jobExecutionLogs) + .where(eq(jobExecutionLogs.executionId, occurrenceId)) + expect(row.cost).toEqual({ total: 1.6 }) + expect(await recordJobRunSpend(generateId(), 100)).toBeNull() + }) +}) diff --git a/apps/sim/lib/mothership/jobs/lane.test.ts b/apps/sim/lib/mothership/jobs/lane.test.ts new file mode 100644 index 00000000000..496c6ed0890 --- /dev/null +++ b/apps/sim/lib/mothership/jobs/lane.test.ts @@ -0,0 +1,26 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing/mocks/database.mock' +import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' +import { afterEach, describe, expect, it } from 'vitest' +import { runJobLane } from '@/lib/mothership/jobs/lane' + +describe('runJobLane', () => { + afterEach(() => { + resetEnvMock() + resetDbChainMock() + }) + + it.each([ + { COPILOT_API_KEY: undefined, REDIS_URL: 'redis://localhost:6379' }, + { COPILOT_API_KEY: 'key', REDIS_URL: undefined }, + ])('touches nothing where turns cannot be durable (%o)', async (env) => { + setEnv(env) + + expect(await runJobLane()).toEqual({ claimed: 0 }) + expect(dbChainMockFns.execute).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mothership/jobs/lane.ts b/apps/sim/lib/mothership/jobs/lane.ts new file mode 100644 index 00000000000..da3aef937ca --- /dev/null +++ b/apps/sim/lib/mothership/jobs/lane.ts @@ -0,0 +1,53 @@ +import { db, jobExecutionLogs, workflowSchedule } from '@sim/db' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { exists, sql } from 'drizzle-orm' +import { env } from '@/lib/core/config/env' +import { claimDueJobOccurrences, dueJob } from '@/lib/mothership/jobs/claim' +import { dispatchJobOccurrence } from '@/lib/mothership/jobs/dispatch' +import { openJobRun, reconcileJobRuns } from '@/lib/mothership/jobs/reconcile' + +const logger = createLogger('MothershipJobLane') + +/** Occurrences one tick claims; the rest wait for the next tick. */ +const JOB_CLAIM_LIMIT = 50 + +/** + * One row: `due`, whether a job's occurrence is due to be claimed, and `open`, whether an + * occurrence is still queued or running. Each leg probes its own partial index, so a deployment + * with no live jobs pays one index lookup per tick and nothing else. + */ +export function jobLaneWorkQuery(now: Date) { + const due = db.select({ id: workflowSchedule.id }).from(workflowSchedule).where(dueJob(now)) + const open = db + .select({ id: jobExecutionLogs.id }) + .from(jobExecutionLogs) + .where(openJobRun(['queued', 'running'])) + return sql`SELECT ${exists(due.limit(1))} AS due, ${exists(open.limit(1))} AS open` +} + +/** + * The schedule tick's job lane: drives every open occurrence (including ones a crashed tick + * left) towards its end, then claims due occurrences and starts each as a durable turn. Settling + * first means a run that met its condition, or a third failure that turned its job off, stops + * the job before its next occurrence is claimed. + * + * Jobs run only where their turns are durable: the worker is configured, and Redis holds the + * chat locks that let Sim take a turn over after its process dies. Elsewhere the lane does + * nothing, not even a query. + */ +export async function runJobLane(now: Date = new Date()): Promise<{ claimed: number }> { + if (!env.COPILOT_API_KEY || !env.REDIS_URL) return { claimed: 0 } + const [work] = await db.execute<{ due: boolean; open: boolean }>(jobLaneWorkQuery(now)) + if (work?.open) await reconcileJobRuns(now) + const claimed = work?.due ? await claimDueJobOccurrences(now, JOB_CLAIM_LIMIT) : [] + for (const occurrence of claimed) { + await dispatchJobOccurrence(occurrence.executionId).catch((error) => + logger.warn('Job occurrence dispatch failed; the reconciler retries it', { + executionId: occurrence.executionId, + error: getErrorMessage(error), + }) + ) + } + return { claimed: claimed.length } +} diff --git a/apps/sim/lib/mothership/jobs/occurrence.test.ts b/apps/sim/lib/mothership/jobs/occurrence.test.ts new file mode 100644 index 00000000000..3ad884e48e4 --- /dev/null +++ b/apps/sim/lib/mothership/jobs/occurrence.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { nextJobOccurrence } from '@/lib/mothership/jobs/occurrence' + +const quarterHourly = { + cronExpression: '*/15 * * * *', + timezone: 'UTC', + excludedDates: null, + endsAt: null, +} + +describe('nextJobOccurrence', () => { + it('keeps the next slot when the occurrence is claimed on time', () => { + const occurrenceAt = new Date('2026-10-22T10:00:00Z') + expect( + nextJobOccurrence(quarterHourly, occurrenceAt, new Date('2026-10-22T10:00:30Z')) + ).toEqual(new Date('2026-10-22T10:15:00Z')) + }) + + it('never runs again within the minimum cadence of a late claim', () => { + const occurrenceAt = new Date('2026-10-22T10:00:00Z') + expect( + nextJobOccurrence(quarterHourly, occurrenceAt, new Date('2026-10-22T10:14:00Z')) + ).toEqual(new Date('2026-10-22T10:30:00Z')) + }) + + it('runs a cron tighter than the minimum cadence at most every fifteen minutes', () => { + const everyMinute = { ...quarterHourly, cronExpression: '* * * * *' } + const occurrenceAt = new Date('2026-10-22T10:00:00Z') + expect(nextJobOccurrence(everyMinute, occurrenceAt, occurrenceAt)).toEqual( + new Date('2026-10-22T10:15:00Z') + ) + }) + + it('skips any number of deleted occurrences instead of ending the job', () => { + const everyMinute = { ...quarterHourly, cronExpression: '* * * * *' } + const occurrenceAt = new Date('2026-10-22T10:00:00Z') + const firstSlot = new Date('2026-10-22T10:15:00Z').getTime() + const excludedDates = Array.from({ length: 1_500 }, (_, minute) => + new Date(firstSlot + minute * 60_000).toISOString() + ) + expect( + nextJobOccurrence({ ...everyMinute, excludedDates }, occurrenceAt, occurrenceAt) + ).toEqual(new Date(firstSlot + 1_500 * 60_000)) + }) +}) diff --git a/apps/sim/lib/mothership/jobs/occurrence.ts b/apps/sim/lib/mothership/jobs/occurrence.ts new file mode 100644 index 00000000000..4babea0c2dc --- /dev/null +++ b/apps/sim/lib/mothership/jobs/occurrence.ts @@ -0,0 +1,53 @@ +import { Cron } from 'croner' +import { uuidV5 } from '@/lib/core/utils/uuid-v5' + +/** RFC 4122 namespace for scheduled job occurrences; fixed forever, since ids derive from it. */ +const JOB_OCCURRENCE_NAMESPACE = '1c1b6a5e-7f3d-5a8e-9b7c-4d2e8f6a0b31' + +/** A job runs at most this often, however its cron reads. */ +export const MIN_JOB_CADENCE_MS = 15 * 60_000 + +/** How late a tick may claim an on-time occurrence; the floor forgives it so a 15-minute cron keeps its slots. */ +const CLAIM_SLACK_MS = 60_000 + +/** + * The id of one occurrence of one schedule: a UUIDv5 of the schedule id and the occurrence's + * instant. It names the occurrence's run record, and the job turn runs under it as its message, + * stream and run id, so every attempt to start the same occurrence (two ticks, a reconciler + * retry) carries the same id and the worker and Sim each admit it at most once. + */ +export function jobOccurrenceId(scheduleId: string, occurrenceAt: Date): string { + return uuidV5(`${scheduleId}@${occurrenceAt.toISOString()}`, JOB_OCCURRENCE_NAMESPACE) +} + +interface JobCadence { + cronExpression: string | null + timezone: string + excludedDates: readonly string[] | null + endsAt: Date | null +} + +/** + * When a schedule runs next after an occurrence it claimed at `now`, or null when it never runs + * again. A one-shot job (no cron) runs once. Missed occurrences collapse: the next one is + * counted from `now`, never backfilled. It is never sooner than {@link MIN_JOB_CADENCE_MS} after + * the claimed occurrence, nor after the claim itself (less a tick's slack), so an occurrence + * claimed late does not run again moments later. It skips deleted occurrences and ends at `endsAt`. + */ +export function nextJobOccurrence(cadence: JobCadence, occurrenceAt: Date, now: Date): Date | null { + if (!cadence.cronExpression) return null + const cron = new Cron(cadence.cronExpression, { timezone: cadence.timezone || 'UTC' }) + const excluded = new Set(cadence.excludedDates ?? []) + const earliest = new Date( + Math.max( + occurrenceAt.getTime() + MIN_JOB_CADENCE_MS - 1, + now.getTime() + MIN_JOB_CADENCE_MS - CLAIM_SLACK_MS + ) + ) + let next = cron.nextRun(earliest) + /** Each step consumes one excluded instant, so this ends. */ + while (next && excluded.has(next.toISOString())) next = cron.nextRun(next) + if (!next) return null + if (cadence.endsAt && next.getTime() > cadence.endsAt.getTime()) return null + return next +} diff --git a/apps/sim/lib/mothership/jobs/reconcile.ts b/apps/sim/lib/mothership/jobs/reconcile.ts new file mode 100644 index 00000000000..8274fbc2e47 --- /dev/null +++ b/apps/sim/lib/mothership/jobs/reconcile.ts @@ -0,0 +1,182 @@ +import { copilotRuns, db, jobExecutionLogs } from '@sim/db' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, asc, eq, inArray, isNotNull, isNull, lte, or, sql } from 'drizzle-orm' +import { requestRunStop, TERMINAL_RUN_STATUSES } from '@/lib/mothership/async-runs/repository' +import { JOB_RUN_TRIGGER } from '@/lib/mothership/jobs/claim' +import { dispatchJobOccurrence } from '@/lib/mothership/jobs/dispatch' +import { finalizeJobRun } from '@/lib/mothership/jobs/finalize' +import { + endUnstartedJobRun, + markJobRunRunning, + recordJobRunBillingRequest, +} from '@/lib/mothership/jobs/runs' +import { + deliverRunStop, + readStoppedRunWork, +} from '@/lib/mothership/request/application/stop-delivery' + +const logger = createLogger('MothershipJobReconciler') + +/** A queued occurrence older than this lost its dispatch (a crash after the claim committed). */ +export const JOB_DISPATCH_GRACE_MS = 2 * 60_000 + +/** + * A job run's wall-clock limit. Turns have no default cutoff; this one is the job's own deadline, + * an explicit caller limit, after which the run is stopped like a person pressing Stop. + */ +const JOB_RUN_TIME_LIMIT_MS = 6 * 60 * 60_000 + +const RECONCILE_BATCH_SIZE = 100 + +type OpenJobRunStatus = 'queued' | 'running' + +/** An occurrence's run record that has not ended; `job_execution_logs_open_idx` covers it. */ +export const openJobRun = (statuses: OpenJobRunStatus[]) => + and( + inArray(jobExecutionLogs.status, statuses), + eq(jobExecutionLogs.trigger, JOB_RUN_TRIGGER), + isNotNull(jobExecutionLogs.occurrenceAt) + ) + +/** The billing request a run's first leg was admitted under, as its recovery intent recorded it. */ +const admittedBillingRequestId = sql< + string | null +>`${copilotRuns.requestContext}->'recovery'->'billingAdmission'->>'billingRequestId'` + +/** + * Open records that need this pass, oldest first. Every queued record does; a running one only + * once its turn ended or its run row is gone, its time limit passed without a Stop, or its + * spend-cap link is missing, so a backlog of healthy long runs never hides the ones that finished. + */ +function openJobRuns(status: OpenJobRunStatus, now: Date) { + const needsAction = + status === 'queued' + ? undefined + : or( + inArray(copilotRuns.status, TERMINAL_RUN_STATUSES), + isNull(copilotRuns.id), + and( + lte(jobExecutionLogs.startedAt, new Date(now.getTime() - JOB_RUN_TIME_LIMIT_MS)), + sql`${jobExecutionLogs.executionData}->>'deadlineStopRequestedAt' IS NULL` + ), + and(isNull(jobExecutionLogs.billingRequestId), isNotNull(admittedBillingRequestId)) + ) + return db + .select({ + executionId: jobExecutionLogs.executionId, + status: jobExecutionLogs.status, + startedAt: jobExecutionLogs.startedAt, + deadlineStopRequestedAt: sql< + string | null + >`${jobExecutionLogs.executionData}->>'deadlineStopRequestedAt'`, + billingRequestId: jobExecutionLogs.billingRequestId, + admittedBillingRequestId, + scheduleId: jobExecutionLogs.scheduleId, + runStatus: copilotRuns.status, + userId: copilotRuns.userId, + workspaceId: copilotRuns.workspaceId, + chatId: copilotRuns.chatId, + }) + .from(jobExecutionLogs) + .leftJoin(copilotRuns, eq(copilotRuns.streamId, jobExecutionLogs.executionId)) + .where(and(openJobRun([status]), needsAction)) + .orderBy(asc(jobExecutionLogs.startedAt)) + .limit(RECONCILE_BATCH_SIZE) +} + +/** + * Drives every open occurrence to an end, so no crash leaves one queued or running forever: + * a queued record whose dispatch was lost is dispatched again under the same id (the turn is + * admitted at most once, so a retry of one that did start only finds it); a record whose turn + * ended is finalized; one past the time limit is stopped and finalized once it settles; one + * whose job was deleted before it started is ended. A run whose spend-cap link was lost (its + * process died between recording the billing admission and the link) gets it back. + */ +export async function reconcileJobRuns(now: Date): Promise { + /** Each status reads its own batch, so a backlog of long runs never starves queued ones. */ + const [queued, running] = await Promise.all([ + openJobRuns('queued', now), + openJobRuns('running', now), + ]) + for (const row of [...queued, ...running]) { + try { + if (!row.runStatus) { + if (row.status === 'running') { + await finalizeJobRun(row.executionId) + continue + } + if (!row.scheduleId) { + await endUnstartedJobRun( + row.executionId, + 'blocked', + 'The job was deleted before this run.' + ) + } else if (now.getTime() - row.startedAt.getTime() >= JOB_DISPATCH_GRACE_MS) { + await dispatchJobOccurrence(row.executionId) + } + continue + } + if (row.status === 'queued') await markJobRunRunning(row.executionId) + if (!row.billingRequestId && row.admittedBillingRequestId) { + await recordJobRunBillingRequest(row.executionId, row.admittedBillingRequestId) + } + if (TERMINAL_RUN_STATUSES.includes(row.runStatus)) { + await finalizeJobRun(row.executionId) + } else if ( + !row.deadlineStopRequestedAt && + row.userId && + row.workspaceId && + row.chatId && + now.getTime() - row.startedAt.getTime() >= JOB_RUN_TIME_LIMIT_MS + ) { + await stopAtTimeLimit({ + executionId: row.executionId, + userId: row.userId, + workspaceId: row.workspaceId, + chatId: row.chatId, + }) + } + } catch (error) { + logger.warn('Job run could not be reconciled; the next pass retries', { + executionId: row.executionId, + error: getErrorMessage(error), + }) + } + } +} + +/** Stops a run at its time limit through the same delivery a person's Stop takes. */ +async function stopAtTimeLimit(run: { + executionId: string + userId: string + workspaceId: string + chatId: string +}): Promise { + const stopped = await requestRunStop({ + streamId: run.executionId, + userId: run.userId, + workspaceId: run.workspaceId, + chatId: run.chatId, + }) + /** A run outside the job's scope records no Stop, so nothing is delivered to it. */ + if (!stopped || stopped.workspaceId !== run.workspaceId || stopped.chatId !== run.chatId) return + /** Marked once the Stop is durable, so a pass that fails before it stops the run again. */ + await db + .update(jobExecutionLogs) + .set({ + executionData: sql`jsonb_set(${jobExecutionLogs.executionData}, '{deadlineStopRequestedAt}', to_jsonb(now()))`, + }) + .where(eq(jobExecutionLogs.executionId, run.executionId)) + await deliverRunStop( + { + id: stopped.id, + streamId: run.executionId, + userId: run.userId, + chatId: run.chatId, + desktopDeviceId: stopped.desktopDeviceId, + }, + readStoppedRunWork(run.executionId, run.userId) + ) + logger.info('Stopped a job run at its time limit', { executionId: run.executionId }) +} diff --git a/apps/sim/lib/mothership/jobs/runs.ts b/apps/sim/lib/mothership/jobs/runs.ts new file mode 100644 index 00000000000..53f4917ac72 --- /dev/null +++ b/apps/sim/lib/mothership/jobs/runs.ts @@ -0,0 +1,143 @@ +import { db, jobExecutionLogs, workflowSchedule } from '@sim/db' +import { and, eq, isNull, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' + +/** What a job run record says about how its occurrence ended. */ +export type JobRunOutcome = + | 'met' + | 'not_met' + | 'blocked' + | 'failed' + | 'skipped_busy' + | 'skipped_budget' + | 'interrupted' + +/** Outcomes the agent reports itself; finalize keeps them rather than inferring one. */ +export const REPORTED_JOB_OUTCOMES: readonly JobRunOutcome[] = ['met', 'not_met', 'blocked'] + +/** How an ended run reads in the logs: a failed run is an error, every other outcome is not. */ +export function endedRunStatus(outcome: JobRunOutcome) { + return outcome === 'failed' + ? ({ status: 'error', level: 'error' } as const) + : ({ status: 'complete', level: 'info' } as const) +} + +/** A job is turned off after this many failed or interrupted runs in a row. */ +export const MAX_CONSECUTIVE_JOB_FAILURES = 3 + +/** The occurrence's run record, with what dispatching it reads from its schedule. */ +export async function loadJobRun(executionId: string) { + const [row] = await db + .select({ + executionId: jobExecutionLogs.executionId, + status: jobExecutionLogs.status, + occurrenceAt: jobExecutionLogs.occurrenceAt, + scheduleId: workflowSchedule.id, + scheduleStatus: workflowSchedule.status, + archivedAt: workflowSchedule.archivedAt, + jobTitle: workflowSchedule.jobTitle, + prompt: workflowSchedule.prompt, + successCondition: workflowSchedule.successCondition, + lifecycle: workflowSchedule.lifecycle, + maxRuns: workflowSchedule.maxRuns, + runCount: workflowSchedule.runCount, + runBudgetUsd: workflowSchedule.runBudgetUsd, + userId: workflowSchedule.sourceUserId, + workspaceId: workflowSchedule.sourceWorkspaceId, + chatId: workflowSchedule.sourceChatId, + secretScope: workflowSchedule.secretScope, + mountedSecrets: workflowSchedule.mountedSecrets, + }) + .from(jobExecutionLogs) + .innerJoin(workflowSchedule, eq(workflowSchedule.id, jobExecutionLogs.scheduleId)) + .where( + and(eq(jobExecutionLogs.executionId, executionId), eq(workflowSchedule.sourceType, 'job')) + ) + .limit(1) + return row ?? null +} + +export type JobRun = NonNullable>> + +/** Moves a queued record to running; false when another attempt already moved it. */ +export async function markJobRunRunning(executionId: string): Promise { + const rows = await db + .update(jobExecutionLogs) + .set({ status: 'running' }) + .where( + and(eq(jobExecutionLogs.executionId, executionId), eq(jobExecutionLogs.status, 'queued')) + ) + .returning({ id: jobExecutionLogs.id }) + return rows.length > 0 +} + +/** + * Ends an occurrence that never became a turn (skipped, or refused before admission). Only a + * queued record ends this way, so it never overwrites a run that started. + */ +export async function endUnstartedJobRun( + executionId: string, + outcome: JobRunOutcome, + summary: string, + tx: DbOrTx = db +): Promise { + const rows = await tx + .update(jobExecutionLogs) + .set({ + ...endedRunStatus(outcome), + outcome, + summary, + endedAt: new Date(), + totalDurationMs: 0, + }) + .where( + and(eq(jobExecutionLogs.executionId, executionId), eq(jobExecutionLogs.status, 'queued')) + ) + .returning({ id: jobExecutionLogs.id }) + return rows.length > 0 +} + +/** Records the billing request a run's charges settle under, once, for its spend cap. */ +export async function recordJobRunBillingRequest( + executionId: string, + billingRequestId: string +): Promise { + await db + .update(jobExecutionLogs) + .set({ billingRequestId }) + .where( + and(eq(jobExecutionLogs.executionId, executionId), isNull(jobExecutionLogs.billingRequestId)) + ) +} + +/** Counts a failed dispatch attempt on a queued record and returns the attempts so far. */ +export async function countJobDispatchFailure(executionId: string): Promise { + const [row] = await db + .update(jobExecutionLogs) + .set({ + executionData: sql`jsonb_set(${jobExecutionLogs.executionData}, '{dispatchFailures}', to_jsonb(COALESCE((${jobExecutionLogs.executionData}->>'dispatchFailures')::int, 0) + 1))`, + }) + .where( + and(eq(jobExecutionLogs.executionId, executionId), eq(jobExecutionLogs.status, 'queued')) + ) + .returning({ + failures: sql`(${jobExecutionLogs.executionData}->>'dispatchFailures')::int`, + }) + return row?.failures ?? 0 +} + +/** Turns a job off; the caller tells its people why. */ +export async function disableJob(scheduleId: string): Promise { + const rows = await db + .update(workflowSchedule) + .set({ status: 'disabled', nextRunAt: null, updatedAt: new Date() }) + .where( + and( + eq(workflowSchedule.id, scheduleId), + eq(workflowSchedule.sourceType, 'job'), + eq(workflowSchedule.status, 'active') + ) + ) + .returning({ id: workflowSchedule.id }) + return rows.length > 0 +} diff --git a/apps/sim/lib/mothership/request/application/controls.ts b/apps/sim/lib/mothership/request/application/controls.ts index 2bed39336db..1f41f7c046d 100644 --- a/apps/sim/lib/mothership/request/application/controls.ts +++ b/apps/sim/lib/mothership/request/application/controls.ts @@ -9,8 +9,6 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { areStreamToolExecutionsSettled, getLatestRunForStream, - getUnsettledClientWorkflowExecutions, - getUnsettledStreamSandboxProcesses, requestRunStop, } from '@/lib/mothership/async-runs/repository' import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' @@ -19,7 +17,10 @@ import { resolveOwnedChatContext, } from '@/lib/mothership/chat/application/context' import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' -import { deliverRunStop } from '@/lib/mothership/request/application/stop-delivery' +import { + deliverRunStop, + readStoppedRunWork, +} from '@/lib/mothership/request/application/stop-delivery' import { requestStreamSteering } from '@/lib/mothership/request/session/steer' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' @@ -160,25 +161,7 @@ export const abortRun = defineAuthorizedChatUseCase({ { allowBlockedOwner: true } ) /** Read alongside the signals, which need none of it. */ - const work = Promise.all([ - getUnsettledClientWorkflowExecutions(streamId, userId).catch((error) => { - logger.warn('Stopped stream workflow ownership could not be read', { - streamId, - error: getErrorMessage(error), - }) - return [] - }), - getUnsettledStreamSandboxProcesses(streamId, userId).catch((error) => { - logger.warn('Stopped stream sandbox ownership could not be read', { - streamId, - error: getErrorMessage(error), - }) - return [] - }), - ]).then(([clientWorkflowExecutionIds, sandboxProcesses]) => ({ - clientWorkflowExecutionIds, - sandboxProcesses, - })) + const work = readStoppedRunWork(streamId, userId) const { settled, workerSettled } = await deliverRunStop( { id: run.id, streamId, userId, chatId, desktopDeviceId: run.desktopDeviceId }, work diff --git a/apps/sim/lib/mothership/request/application/recover-stream.ts b/apps/sim/lib/mothership/request/application/recover-stream.ts index 62125711caf..03083dc2689 100644 --- a/apps/sim/lib/mothership/request/application/recover-stream.ts +++ b/apps/sim/lib/mothership/request/application/recover-stream.ts @@ -30,6 +30,7 @@ import { claimRunController, planRecovery, } from '@/lib/mothership/request/lifecycle/controller-ownership' +import { parseRecordedSecretAuthority } from '@/lib/mothership/request/lifecycle/durable-run' import { RESOURCE_AUTHORING_VERSION, StreamRecoveryConfigSchema, @@ -46,6 +47,7 @@ import { assertChatStreamLease } from '@/lib/mothership/request/session/controll import { eventToStreamEvent } from '@/lib/mothership/request/session/event' import { startsAtReplayHead } from '@/lib/mothership/request/session/recovery' import { StreamRecoveryExhaustedError } from '@/lib/mothership/request/session/turn-failure' +import { NO_SECRET_AUTHORITY } from '@/lib/mothership/secret-mount-policy' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipStreamRecovery') @@ -236,6 +238,16 @@ async function recoverStreamRun(context: StreamRunContext, admission?: AbortSign exhausted: plan.kind === 'exhausted', }) const requestId = typeof saved?.requestId === 'string' ? saved.requestId : generateId() + /** + * An unattended turn was admitted for nobody to answer and with the secrets its schedule + * mounts, so its recovered leg keeps both: no prompt waits on a person, and an authority it + * never recorded mounts nothing rather than the interactive default. + */ + const unattended = config.data.kind === 'unattended_stream' + const recordedSecretAuthority = parseRecordedSecretAuthority(saved.secretAuthority) + const secretAuthority = unattended + ? (recordedSecretAuthority ?? NO_SECRET_AUTHORITY) + : recordedSecretAuthority const completion = { chatId, userMessageId: run.streamId, @@ -282,9 +294,15 @@ async function recoverStreamRun(context: StreamRunContext, admission?: AbortSign : undefined, billingAttribution, userPermission: userPermission ?? undefined, - interactive: true, + interactive: !unattended, autoExecuteTools: true, clientToolPickupExpected: config.data.clientToolPickupExpected, + ...(secretAuthority + ? { + secretMountPolicy: secretAuthority.secretMountPolicy, + secretActorUserId: secretAuthority.secretActorUserId, + } + : {}), recovery: { ...config.data, streamId: run.streamId, diff --git a/apps/sim/lib/mothership/request/application/stop-delivery.ts b/apps/sim/lib/mothership/request/application/stop-delivery.ts index 38a38d5603e..76215fae5d5 100644 --- a/apps/sim/lib/mothership/request/application/stop-delivery.ts +++ b/apps/sim/lib/mothership/request/application/stop-delivery.ts @@ -4,10 +4,12 @@ import { ringDesktopInbox } from '@/lib/desktop/executor/doorbell' import { markExecutionCancelled } from '@/lib/execution/cancellation' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { settleStoppedRunWithoutController } from '@/lib/mothership/async-runs/orphaned-runs' -import type { - RunStopTarget, - StoppedRun, - StoppedRunWork, +import { + getUnsettledClientWorkflowExecutions, + getUnsettledStreamSandboxProcesses, + type RunStopTarget, + type StoppedRun, + type StoppedRunWork, } from '@/lib/mothership/async-runs/repository' import { abortActiveStream, @@ -31,6 +33,33 @@ interface RunStopDeliveryOptions { mothershipBaseURL?: string } +/** + * The client workflows and sandbox commands a stopped run started, read once its admission + * closed. Each read is best-effort: one that fails stops nothing it would have found. + */ +export async function readStoppedRunWork( + streamId: string, + userId: string +): Promise { + const [clientWorkflowExecutionIds, sandboxProcesses] = await Promise.all([ + getUnsettledClientWorkflowExecutions(streamId, userId).catch((error) => { + logger.warn('Stopped stream workflow ownership could not be read', { + streamId, + error: getErrorMessage(error), + }) + return [] + }), + getUnsettledStreamSandboxProcesses(streamId, userId).catch((error) => { + logger.warn('Stopped stream sandbox ownership could not be read', { + streamId, + error: getErrorMessage(error), + }) + return [] + }), + ]) + return { clientWorkflowExecutionIds, sandboxProcesses } +} + /** * Delivers a Stop that is already durable to everything still acting on the run: the * desktop, the worker, this process's controller, the run's client workflows and sandbox diff --git a/apps/sim/lib/mothership/request/lifecycle/durable-run.ts b/apps/sim/lib/mothership/request/lifecycle/durable-run.ts new file mode 100644 index 00000000000..c4f192ab9c1 --- /dev/null +++ b/apps/sim/lib/mothership/request/lifecycle/durable-run.ts @@ -0,0 +1,38 @@ +/** + * What a run's saved request context says about its durability, kept free of the chat protocol + * schema so the run repository and the sweeps can read it without loading the lifecycle. + */ +import { type AnyColumn, type SQL, sql } from 'drizzle-orm' +import { z } from 'zod' +import { secretMountPolicySchema } from '@/lib/api/contracts/secret-mount-policy' +import type { TurnSecretAuthority } from '@/lib/mothership/secret-mount-policy' + +/** + * What a durable turn's recovery restores about its audience. An interactive stream answers a + * person in the chat; an unattended stream (a scheduled job's occurrence) has nobody to answer a + * prompt, so a recovered leg runs it non-interactively too, exactly as it was admitted. + */ +export const DURABLE_STREAM_KINDS = ['interactive_stream', 'unattended_stream'] as const +export type DurableStreamKind = (typeof DURABLE_STREAM_KINDS)[number] + +/** A run's saved request context names a durable turn that recovery can take over. */ +export function isDurableStreamRun(requestContext: AnyColumn): SQL { + return sql`${requestContext}->'recovery'->>'kind' IN (${sql.join( + DURABLE_STREAM_KINDS.map((kind) => sql`${kind}`), + sql`, ` + )})` +} + +const RecordedSecretAuthoritySchema = z.object({ + secretMountPolicy: secretMountPolicySchema, + secretActorUserId: z.string().nullable(), +}) + +/** + * The secret authority a run recorded at admission (`requestContext.secretAuthority`), or + * undefined when it recorded none or garbage. + */ +export function parseRecordedSecretAuthority(value: unknown): TurnSecretAuthority | undefined { + const recorded = RecordedSecretAuthoritySchema.safeParse(value) + return recorded.success ? recorded.data : undefined +} diff --git a/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts b/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts index 68b51d5b05d..f48605eb53a 100644 --- a/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts +++ b/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { hasCopilotResourceAdmission } from '@/lib/mothership/auth/application-delegation' import { COPILOT_REQUEST_MODES } from '@/lib/mothership/constants' import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol' +import { DURABLE_STREAM_KINDS } from '@/lib/mothership/request/lifecycle/durable-run' import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run' export const BillingAdmissionSchema = z @@ -39,7 +40,7 @@ export function durableResourceAuthoringVersion( export const StreamRecoveryConfigSchema = z .object({ - kind: z.literal('interactive_stream'), + kind: z.enum(DURABLE_STREAM_KINDS), resourceAuthoringVersion: z.literal(RESOURCE_AUTHORING_VERSION).optional(), billingAdmission: BillingAdmissionSchema.optional(), request: DurableChatRequestSchema, @@ -64,7 +65,13 @@ export async function streamRecoveryConfig( options: CopilotLifecycleOptions, request: Record ) { - if (options.interactive !== true) return undefined + /** + * A recovered leg keeps the kind its turn was admitted with. A fresh leg is durable only for a + * person in the chat; an unattended turn is admitted with its recovery intent already written. + */ + const kind = + options.recovery?.kind ?? (options.interactive === true ? 'interactive_stream' : undefined) + if (!kind) return undefined const resourceAuthoringVersion = durableResourceAuthoringVersion( hasCopilotResourceAdmission({ ...options, @@ -75,7 +82,7 @@ export async function streamRecoveryConfig( }) ) return StreamRecoveryConfigSchema.parse({ - kind: 'interactive_stream', + kind, request, goRoute: options.goRoute ?? '/api/copilot', clientToolPickupExpected: options.clientToolPickupExpected ?? true, diff --git a/apps/sim/lib/mothership/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts index e97531ef7f9..52db59650a2 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.ts @@ -60,6 +60,7 @@ import { authorizeLifecycleContinuation, restoreBillingAdmission, } from '@/lib/mothership/request/lifecycle/admission' +import type { DurableStreamKind } from '@/lib/mothership/request/lifecycle/durable-run' import { StreamRetryWindow } from '@/lib/mothership/request/lifecycle/stream-retry' import { recordDegraded } from '@/lib/mothership/request/metrics' import { AbortReason } from '@/lib/mothership/request/session/abort-reason' @@ -300,6 +301,7 @@ export interface CopilotLifecycleOptions extends OrchestratorOptions { goRoute?: string /** Reattach this existing run; goRoute still identifies the original interaction surface. */ recovery?: { + kind?: DurableStreamKind billingAdmission?: { billingRequestId: string; serializedAttribution: string } streamId: string events: readonly StreamEvent[] diff --git a/apps/sim/lib/mothership/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts index 189a252c2af..62f87601a6b 100644 --- a/apps/sim/lib/mothership/request/lifecycle/start.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.ts @@ -3,6 +3,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' import { and, eq, isNull } from 'drizzle-orm' import { assertBillingAttributionSnapshot, @@ -339,7 +340,10 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS ...(params.admittedRun?.requestContext ?? {}), requestId, controllerToken: lease?.value, - recovery: await streamRecoveryConfig(orchestrateOptions, requestPayload), + /** An admitted turn already wrote its recovery intent; a fresh run derives one. */ + recovery: + toRecord(params.admittedRun?.requestContext).recovery ?? + (await streamRecoveryConfig(orchestrateOptions, requestPayload)), } try { if (chatId && !orchestrateOptions.recovery) { @@ -426,18 +430,20 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS abortSignal: abortController.signal, assertControllerOwnership, onBillingAdmission: async (admission) => { - if (!chatId || !savedRequestContext.recovery) return - await assertControllerOwnership() - if (!lease) throw new StreamControllerSupersededError() - const updated = await recordRunBillingAdmission( - runId, - { - billingRequestId: admission.billingRequestId, - serializedAttribution: admission.serializedAttribution, - }, - lease.value - ) - if (!updated) throw new Error('Run no longer owns billing admission') + if (chatId && savedRequestContext.recovery) { + await assertControllerOwnership() + if (!lease) throw new StreamControllerSupersededError() + const updated = await recordRunBillingAdmission( + runId, + { + billingRequestId: admission.billingRequestId, + serializedAttribution: admission.serializedAttribution, + }, + lease.value + ) + if (!updated) throw new Error('Run no longer owns billing admission') + } + await orchestrateOptions.onBillingAdmission?.(admission) }, onEvent: async (event) => { try { diff --git a/apps/sim/lib/mothership/request/lifecycle/unattended.integration.ts b/apps/sim/lib/mothership/request/lifecycle/unattended.integration.ts new file mode 100644 index 00000000000..ebcd64d9b5c --- /dev/null +++ b/apps/sim/lib/mothership/request/lifecycle/unattended.integration.ts @@ -0,0 +1,401 @@ +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +/** + * An unattended turn (a scheduled job's occurrence) started with no browser attached survives + * the death of the Sim process driving it, against real Redis and PostgreSQL through the + * production admission, chat lifecycle, abandoned-run takeover and finalization. A local HTTP + * server stands in for the worker: each POST for the turn is one leg, and a leg either ends the + * turn or stays open until the test kills its controller, as a pod deploy or crash would. + * + * A controller is killed by aborting it as superseded (it stops without finalizing, exactly as + * a process that lost its chat lock) and cutting its worker connection; the run is then left + * looking as a dead pod leaves it: unfinished, its last write older than the takeover window. + */ +import { envFlagsMock, resetEnvFlagsMock } from '@sim/testing/mocks/env-flags.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => { + const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure') + const { createServer } = await import('node:http') + type Leg = { text: string; end: boolean } + const worker = { + /** Every POST the worker received, in order. */ + requests: [] as Array<{ path: string; body: Record }>, + /** The legs each stream's successive POSTs to /api/mothership answer, in order. */ + legs: new Map(), + /** The open response of each stream's leg that has not ended. */ + open: new Map(), + } + const server = createServer(async (request, response) => { + let raw = '' + for await (const chunk of request) raw += chunk + const body = raw ? JSON.parse(raw) : {} + const path = request.url ?? '' + worker.requests.push({ path, body }) + if (path === '/api/streams/explicit-abort') { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ settled: true })) + return + } + const streamId = String(body.messageId ?? '') + const leg = path === '/api/mothership' ? worker.legs.get(streamId)?.shift() : undefined + if (!leg) { + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'Run not found' })) + return + } + /** A re-attach names how much text it already holds; the worker sends only what follows. */ + const offset = Number(body.receivedTextChars ?? 0) + const frame = (seq: number, type: string, payload: unknown) => + `data: ${JSON.stringify({ + v: 1, + type, + seq, + ts: new Date().toISOString(), + stream: { streamId, chatId: body.chatId }, + payload, + })}\n\n` + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write(frame(1, 'session', { kind: 'start' })) + response.write(frame(2, 'text', { channel: 'assistant', text: leg.text, textOffset: offset })) + if (leg.end) { + response.write( + frame(3, 'complete', { status: 'complete', textLength: offset + leg.text.length }) + ) + response.end('data: [DONE]\n\n') + return + } + worker.open.set(streamId, response) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as { port: number } + const url = readTestRedisUrl() + const inheritedEnv = { + REDIS_URL: process.env.REDIS_URL, + SIM_AGENT_API_URL: process.env.SIM_AGENT_API_URL, + } + if (url) process.env.REDIS_URL = url + process.env.SIM_AGENT_API_URL = `http://127.0.0.1:${port}` + return { redisUrl: url, inheritedEnv, worker: Object.assign(worker, { server }) } +}) + +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +/** Each live controller by stream, so a test can kill the one driving its turn. */ +const { controllers, legOptions } = vi.hoisted(() => ({ + controllers: new Map(), + legOptions: [] as Array<{ + streamId: string + interactive: boolean | undefined + recovered: boolean + secretMountPolicy: unknown + }>, +})) +vi.mock('@/lib/mothership/request/session/abort', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + registerActiveStream: (streamId: string, controller: AbortController) => { + controllers.set(streamId, controller) + actual.registerActiveStream(streamId, controller) + }, + } +}) +/** Records how each leg's lifecycle ran: the first and every recovered one. */ +vi.mock('@/lib/mothership/request/lifecycle/run', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + runCopilotLifecycle: (...args: Parameters) => { + const [, options] = args + legOptions.push({ + streamId: String(options.recovery?.streamId ?? args[0].messageId), + interactive: options.interactive, + recovered: Boolean(options.recovery), + secretMountPolicy: options.secretMountPolicy, + }) + return actual.runCopilotLifecycle(...args) + }, + } +}) + +import { db } from '@sim/db' +import { + copilotChats, + copilotMessages, + copilotRuns, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { toRecord } from '@sim/utils/object' +import { asc, eq, sql } from 'drizzle-orm' +import { resolveBillingAttribution } from '@/lib/billing/core/attribution' +import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis' +import { ABANDONED_RUN_CURSOR_KEY } from '@/lib/mothership/async-runs/orphaned-runs' +import { chatPubSub } from '@/lib/mothership/chat-status' +import { takeOverAbandonedRuns } from '@/lib/mothership/request/application/abandoned-runs' +import { startUnattendedTurn } from '@/lib/mothership/request/lifecycle/unattended' +import { + chatStreamLockKey, + StreamControllerSupersededError, +} from '@/lib/mothership/request/session/controller-lease' + +const userId = generateId() +const workspaceId = generateId() + +/** The fixture user's billing attribution, which the caller of an unattended turn resolves. */ +const payer = () => resolveBillingAttribution({ actorUserId: userId, workspaceId }) +const chatIds: string[] = [] +const JOB_SECRETS = { secretScope: 'selected' as const, mountedSecrets: ['REPORT_TOKEN'] } + +function redis() { + const client = getRedisClient() + if (!client) throw new Error('The integration suite requires TEST_REDIS_URL') + return client +} + +async function jobChat() { + const chatId = generateId() + chatIds.push(chatId) + await db + .insert(copilotChats) + .values({ id: chatId, userId, workspaceId, type: 'mothership', title: 'Nightly triage' }) + return chatId +} + +async function storedRun(runId: string) { + const [run] = await db + .select({ + status: copilotRuns.status, + requestContext: copilotRuns.requestContext, + updatedAt: copilotRuns.updatedAt, + }) + .from(copilotRuns) + .where(eq(copilotRuns.id, runId)) + return run +} + +async function messagesOf(chatId: string) { + return db + .select({ role: copilotMessages.role, content: copilotMessages.content }) + .from(copilotMessages) + .where(eq(copilotMessages.chatId, chatId)) + .orderBy(asc(copilotMessages.seq)) +} + +function legsFor(streamId: string) { + return worker.requests.filter( + (request) => request.path === '/api/mothership' && request.body.messageId === streamId + ) +} + +async function waitFor(condition: () => Promise, what: string, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await condition()) return + await sleep(50) + } + throw new Error(`Timed out waiting for ${what}`) +} + +/** + * The process driving the turn dies mid-leg: its controller stops without finalizing and its + * worker connection drops. Afterwards the run looks as a dead pod leaves it, its lock gone and + * its last write older than the takeover's idle window. + */ +async function killController(chatId: string, streamId: string) { + const controller = controllers.get(streamId) + if (!controller) throw new Error('No live controller to kill') + controller.abort(new StreamControllerSupersededError()) + worker.open.get(streamId)?.socket?.destroy() + worker.open.delete(streamId) + await waitFor(async () => !(await redis().get(chatStreamLockKey(chatId))), 'the lock to lapse') + await db + .update(copilotRuns) + .set({ updatedAt: sql`now() - interval '2 minutes'` }) + .where(eq(copilotRuns.streamId, streamId)) +} + +/** Sim's periodic takeover rounds, until one re-drives the run (after any recovery backoff). */ +async function takeOverUntilReattached(streamId: string) { + const before = legsFor(streamId).length + await waitFor( + async () => { + await redis().del(ABANDONED_RUN_CURSOR_KEY) + await takeOverAbandonedRuns() + return legsFor(streamId).length > before + }, + 'a takeover to re-attach the run', + 30_000 + ) +} + +afterAll(async () => { + await new Promise((resolve) => worker.server.close(() => resolve())) + for (const [key, value] of Object.entries(inheritedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +describe.runIf(Boolean(redisUrl))('an unattended turn with no browser attached', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Unattended turn fixture', + email: `${userId}@unattended-turn.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Unattended turn fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + }) + + beforeEach(() => { + worker.requests.length = 0 + legOptions.length = 0 + resetEnvFlagsMock() + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) + }) + + afterAll(async () => { + for (const chatId of chatIds) { + await db.delete(copilotMessages).where(eq(copilotMessages.chatId, chatId)) + await db.delete(copilotRuns).where(eq(copilotRuns.chatId, chatId)) + await db.delete(copilotChats).where(eq(copilotChats.id, chatId)) + } + await db.delete(permissions).where(eq(permissions.userId, userId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + chatPubSub?.dispose() + await closeRedisConnection() + }) + + it('survives three Sim process deaths mid-run, finishing once as the same unattended run', async () => { + const chatId = await jobChat() + const occurrenceId = generateId() + const parts = ['Checked the queue. ', 'Two items triaged. ', 'One escalated. ', 'Done.'] + worker.legs.set(occurrenceId, [ + { text: parts[0], end: false }, + { text: parts[1], end: false }, + { text: parts[2], end: false }, + { text: parts[3], end: true }, + ]) + const start = async () => + startUnattendedTurn({ + userId, + workspaceId, + chatId, + messageId: occurrenceId, + message: 'Triage the overnight queue.', + origin: 'job', + billingAttribution: await payer(), + secretAuthority: { secretMountPolicy: JOB_SECRETS, secretActorUserId: userId }, + }) + + expect(await start()).toBe('started') + await waitFor(async () => worker.open.has(occurrenceId), 'the first leg') + /** A dispatcher retry while the turn runs finds it, and starts nothing. */ + expect(await start()).toBe('duplicate') + + for (let death = 1; death <= 3; death++) { + await killController(chatId, occurrenceId) + await takeOverUntilReattached(occurrenceId) + if (death < 3) await waitFor(async () => worker.open.has(occurrenceId), 'the next leg') + } + + await waitFor( + async () => (await storedRun(occurrenceId))?.status === 'complete', + 'the run to complete' + ) + await waitFor( + async () => !(await redis().get(chatStreamLockKey(chatId))), + 'the chat to be released' + ) + + const legs = legsFor(occurrenceId) + expect(legs).toHaveLength(4) + /** Every later leg is a re-attach of the same run carrying its receipt, never a fresh start. */ + expect(legs.map((leg) => leg.body.receivedTextChars)).toEqual([ + 0, + parts[0].length, + parts[0].length + parts[1].length, + parts[0].length + parts[1].length + parts[2].length, + ]) + expect(legs.every((leg) => leg.body.origin === 'job' && leg.body.chatId === chatId)).toBe(true) + expect(worker.requests.filter((r) => r.path === '/api/streams/explicit-abort')).toHaveLength(0) + + /** Every leg ran as it was admitted: nobody to prompt, and only the job's mounted secrets. */ + const turnLegs = legOptions.filter((leg) => leg.streamId === occurrenceId) + expect(turnLegs.map((leg) => leg.recovered)).toEqual([false, true, true, true]) + for (const leg of turnLegs) { + expect(leg.interactive).toBe(false) + expect(leg.secretMountPolicy).toEqual(JOB_SECRETS) + } + + const messages = await messagesOf(chatId) + expect(messages.map((message) => message.role)).toEqual(['user', 'assistant']) + expect(toRecord(messages[0].content)).toMatchObject({ + id: occurrenceId, + origin: 'job', + content: 'Triage the overnight queue.', + }) + expect(toRecord(messages[1].content).content).toBe(parts.join('')) + + const run = await storedRun(occurrenceId) + expect(toRecord(toRecord(run.requestContext).recovery).kind).toBe('unattended_stream') + expect(toRecord(toRecord(run.requestContext).recoveryBackoff).attempts).toBe(3) + const runs = await db + .select({ id: copilotRuns.id }) + .from(copilotRuns) + .where(eq(copilotRuns.chatId, chatId)) + expect(runs).toHaveLength(1) + + /** The finished occurrence is never admitted again. */ + expect(await start()).toBe('duplicate') + expect(legsFor(occurrenceId)).toHaveLength(4) + }, 120_000) + + it('answers busy, admitting nothing, while another turn holds the chat', async () => { + const chatId = await jobChat() + const typedTurn = generateId() + await redis().set(chatStreamLockKey(chatId), `${typedTurn}\n${generateId()}`, 'EX', 60) + const occurrenceId = generateId() + try { + expect( + await startUnattendedTurn({ + userId, + workspaceId, + chatId, + messageId: occurrenceId, + message: 'Triage the overnight queue.', + origin: 'job', + billingAttribution: await payer(), + secretAuthority: { secretMountPolicy: JOB_SECRETS, secretActorUserId: userId }, + }) + ).toBe('busy') + } finally { + await redis().del(chatStreamLockKey(chatId)) + } + expect(await storedRun(occurrenceId)).toBeUndefined() + expect(await messagesOf(chatId)).toEqual([]) + expect(legsFor(occurrenceId)).toHaveLength(0) + }) +}) diff --git a/apps/sim/lib/mothership/request/lifecycle/unattended.ts b/apps/sim/lib/mothership/request/lifecycle/unattended.ts new file mode 100644 index 00000000000..587665a0eda --- /dev/null +++ b/apps/sim/lib/mothership/request/lifecycle/unattended.ts @@ -0,0 +1,167 @@ +/** + * Durable turns nobody is watching. A scheduled job's occurrence runs as a chat turn on the same + * leased path a typed send takes: a controller holds the chat lock, the run records its recovery + * intent and billing admission, and Sim's abandoned-run takeover re-drives it after the process + * driving it dies (a deploy or a crash). The headless lifecycle the wake door uses has none of + * that: its run carries no controller, so nothing can continue it once its process is gone. + */ +import type { + AttributedBillingRequestEnvelope, + BillingAttributionSnapshot, +} from '@/lib/billing/core/attribution' +import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository' +import { + createCopilotResourceAdmission, + createTrustedCopilotPrincipal, +} from '@/lib/mothership/auth/application-delegation' +import { + admitUnattendedTurn, + UNATTENDED_TURN_AUDIENCE, +} from '@/lib/mothership/chat/application/admit-unattended-turn' +import { buildOnComplete, buildOnError } from '@/lib/mothership/chat/completion' +import { PROTOCOL_VERSION } from '@/lib/mothership/generated/protocol' +import { + durableResourceAuthoringVersion, + StreamRecoveryConfigSchema, +} from '@/lib/mothership/request/lifecycle/recovery-config' +import { createSSEStream } from '@/lib/mothership/request/lifecycle/start' +import { + acquirePendingChatStream, + getLocalChatStreamLease, + getPendingChatStreamId, + releasePendingChatStream, +} from '@/lib/mothership/request/session/abort' +import type { TurnSecretAuthority } from '@/lib/mothership/secret-mount-policy' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const DELEGATION_TTL_MS = 60_000 + +export interface UnattendedTurnInput { + userId: string + workspaceId: string + chatId: string + /** The turn's id: its run, its stream and its user message. Retrying with it never starts a second turn. */ + messageId: string + message: string + origin: 'job' + /** Who pays for the turn, resolved by the caller (which already needed it to check the limit). */ + billingAttribution: BillingAttributionSnapshot + /** Which secrets the turn's code may mount; recorded so a recovered leg mounts the same. */ + secretAuthority: TurnSecretAuthority + /** Called once the turn's first leg is admitted for billing, with the request its charges settle under. */ + onBillingAdmission?: (admission: AttributedBillingRequestEnvelope) => Promise +} + +/** + * `started`: this call admitted the turn and its controller is running it. `duplicate`: a turn + * with this id was already admitted (by an earlier attempt). `busy`: another turn holds the chat. + */ +export type UnattendedTurnStart = 'started' | 'duplicate' | 'busy' + +/** + * Admits and starts an unattended turn, detached from any HTTP response. The admission commits + * before the controller starts, so a process that dies right after leaves a recoverable run, not + * a lost one. + */ +export async function startUnattendedTurn( + input: UnattendedTurnInput +): Promise { + const { userId, workspaceId, chatId, messageId } = input + const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!(await acquirePendingChatStream(chatId, messageId, 0))) { + /** The holder may be this very turn, still being admitted by another attempt. */ + const holder = await getPendingChatStreamId(chatId) + return holder === messageId || (await getLatestRunForStream(messageId, userId)) + ? 'duplicate' + : 'busy' + } + const lease = getLocalChatStreamLease(chatId, messageId) + try { + if (!lease) throw new Error('An unattended turn needs its chat lease') + const principal = createTrustedCopilotPrincipal( + { userId, workspaceId, chatId, delegationId: `${input.origin}:${messageId}` }, + { audience: UNATTENDED_TURN_AUDIENCE, ttlMs: DELEGATION_TTL_MS } + ) + const request = { + message: input.message, + userId, + protocolVersion: PROTOCOL_VERSION, + mode: 'agent' as const, + workspaceId, + chatId, + messageId, + origin: input.origin, + } + const copilotResourceAdmission = createCopilotResourceAdmission({ + userId, + invocation: { kind: 'chat', chatId }, + }) + const recovery = StreamRecoveryConfigSchema.parse({ + kind: 'unattended_stream', + request, + goRoute: '/api/mothership', + clientToolPickupExpected: false, + requestMode: 'agent', + resourceAuthoringVersion: durableResourceAuthoringVersion(true), + }) + const executionId = messageId + const requestId = messageId + const admission = await admitUnattendedTurn.execute({ + principal, + input: { recovery, lease, secretAuthority: input.secretAuthority }, + }) + if (admission.kind === 'duplicate') { + await releasePendingChatStream(chatId, messageId, lease) + return 'duplicate' + } + const completion = { + chatId, + userMessageId: messageId, + requestId, + workspaceId, + userId, + requestMode: 'agent' as const, + notifyWorkspaceStatus: true, + runController: { id: admission.run.id, token: lease.value }, + } + const stream = createSSEStream({ + requestPayload: request, + admittedRun: admission.run, + userId, + streamId: messageId, + executionId, + runId: admission.run.id, + chatId, + currentChat: null, + message: input.message, + titleModel: '', + requestId, + workspaceId, + orchestrateOptions: { + userId, + workspaceId, + chatId, + executionId, + runId: admission.run.id, + goRoute: '/api/mothership', + copilotResourceAdmission, + billingAttribution: input.billingAttribution, + ...(userPermission ? { userPermission } : {}), + interactive: false, + autoExecuteTools: true, + clientToolPickupExpected: false, + secretMountPolicy: input.secretAuthority.secretMountPolicy, + secretActorUserId: input.secretAuthority.secretActorUserId, + ...(input.onBillingAdmission ? { onBillingAdmission: input.onBillingAdmission } : {}), + onComplete: buildOnComplete(completion), + onError: buildOnError(completion), + }, + }) + // Nobody reads it: the lifecycle runs detached, as a typed turn does after its tab closes. + void stream.cancel().catch(() => {}) + return 'started' + } catch (error) { + await releasePendingChatStream(chatId, messageId, lease) + throw error + } +} diff --git a/apps/sim/lib/workflows/schedules/disable-notifications.ts b/apps/sim/lib/workflows/schedules/disable-notifications.ts index 833f54a4026..af646fc3578 100644 --- a/apps/sim/lib/workflows/schedules/disable-notifications.ts +++ b/apps/sim/lib/workflows/schedules/disable-notifications.ts @@ -43,6 +43,9 @@ export async function notifyScheduleAutoDisabled(params: { workflowName: workflow.name, workflowUserId: workflow.userId, workflowWorkspaceId: workflow.workspaceId, + jobTitle: workflowSchedule.jobTitle, + jobUserId: workflowSchedule.sourceUserId, + jobWorkspaceId: workflowSchedule.sourceWorkspaceId, }) .from(workflowSchedule) .leftJoin(workflow, eq(workflow.id, workflowSchedule.workflowId)) @@ -55,9 +58,10 @@ export async function notifyScheduleAutoDisabled(params: { return } - const ownerUserId = row.workflowUserId - const workspaceId = row.workflowWorkspaceId - const resourceName = row.workflowName ?? undefined + /** A scheduled job has no workflow; its owner, workspace and title live on the schedule. */ + const ownerUserId = row.workflowUserId ?? row.jobUserId + const workspaceId = row.workflowWorkspaceId ?? row.jobWorkspaceId + const resourceName = row.workflowName ?? row.jobTitle ?? undefined const recipients = await resolveRecipients(ownerUserId, workspaceId) if (recipients.length === 0) { diff --git a/packages/db/migrations/0437_scheduled_job_occurrences.sql b/packages/db/migrations/0437_scheduled_job_occurrences.sql new file mode 100644 index 00000000000..cc047db1815 --- /dev/null +++ b/packages/db/migrations/0437_scheduled_job_occurrences.sql @@ -0,0 +1,13 @@ +-- Replay-safety: this file ends in CONCURRENTLY index builds below an embedded COMMIT, +-- so a failure there replays the whole file from the top — every statement here is idempotent. +ALTER TABLE "job_execution_logs" ADD COLUMN IF NOT EXISTS "occurrence_at" timestamp;--> statement-breakpoint +ALTER TABLE "job_execution_logs" ADD COLUMN IF NOT EXISTS "outcome" text;--> statement-breakpoint +ALTER TABLE "job_execution_logs" ADD COLUMN IF NOT EXISTS "summary" text;--> statement-breakpoint +ALTER TABLE "job_execution_logs" ADD COLUMN IF NOT EXISTS "billing_request_id" text;--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD COLUMN IF NOT EXISTS "run_budget_usd" numeric(12, 4);--> statement-breakpoint +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "job_execution_logs_schedule_occurrence_unique" ON "job_execution_logs" USING btree ("schedule_id","occurrence_at");--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "job_execution_logs_billing_request_unique" ON "job_execution_logs" USING btree ("billing_request_id") WHERE "job_execution_logs"."billing_request_id" IS NOT NULL;--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "job_execution_logs_open_idx" ON "job_execution_logs" USING btree ("started_at") WHERE "job_execution_logs"."status" IN ('queued', 'running');--> statement-breakpoint +SET lock_timeout = '5s'; diff --git a/packages/db/migrations/meta/0437_snapshot.json b/packages/db/migrations/meta/0437_snapshot.json new file mode 100644 index 00000000000..481a435d4c7 --- /dev/null +++ b/packages/db/migrations/meta/0437_snapshot.json @@ -0,0 +1,31774 @@ +{ + "id": "e31b3897-d8ab-4260-92be-b71943923bcc", + "prevId": "0ac8e6c1-9440-4310-9af4-7ab28dc2de42", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_revoked_at": { + "name": "refresh_revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_revoked_code": { + "name": "refresh_revoked_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_revoked_token_hash": { + "name": "refresh_revoked_token_hash", + "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.agent_memory_turn": { + "name": "agent_memory_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_order": { + "name": "execution_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "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()" + } + }, + "indexes": { + "agent_memory_turn_invocation_unique": { + "name": "agent_memory_turn_invocation_unique", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memory_turn_workflow_idx": { + "name": "agent_memory_turn_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_memory_turn_memory_id_memory_id_fk": { + "name": "agent_memory_turn_memory_id_memory_id_fk", + "tableFrom": "agent_memory_turn", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_memory_turn_workflow_id_workflow_id_fk": { + "name": "agent_memory_turn_workflow_id_workflow_id_fk", + "tableFrom": "agent_memory_turn", + "tableTo": "workflow", + "columnsFrom": ["workflow_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": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_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\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "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 + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.changelog_change": { + "name": "changelog_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "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 + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "changelog_change_release_position_unique": { + "name": "changelog_change_release_position_unique", + "columns": [ + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "changelog_change_release_id_changelog_release_id_fk": { + "name": "changelog_change_release_id_changelog_release_id_fk", + "tableFrom": "changelog_change", + "tableTo": "changelog_release", + "columnsFrom": ["release_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "changelog_change_workflow_id_workflow_id_fk": { + "name": "changelog_change_workflow_id_workflow_id_fk", + "tableFrom": "changelog_change", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "changelog_change_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "changelog_change_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "changelog_change", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "changelog_change_chat_id_copilot_chats_id_fk": { + "name": "changelog_change_chat_id_copilot_chats_id_fk", + "tableFrom": "changelog_change", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.changelog_release": { + "name": "changelog_release", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_major": { + "name": "version_major", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_minor": { + "name": "version_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_patch": { + "name": "version_patch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bump_reason": { + "name": "bump_reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_file_id": { + "name": "body_file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "published_at": { + "name": "published_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_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": { + "changelog_release_workspace_version_unique": { + "name": "changelog_release_workspace_version_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_major", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_minor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_patch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "changelog_release_body_file_unique": { + "name": "changelog_release_body_file_unique", + "columns": [ + { + "expression": "body_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "changelog_release_workspace_published_idx": { + "name": "changelog_release_workspace_published_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "changelog_release_workspace_id_workspace_id_fk": { + "name": "changelog_release_workspace_id_workspace_id_fk", + "tableFrom": "changelog_release", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "changelog_release_body_file_id_workspace_files_id_fk": { + "name": "changelog_release_body_file_id_workspace_files_id_fk", + "tableFrom": "changelog_release", + "tableTo": "workspace_files", + "columnsFrom": ["body_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "changelog_release_created_by_user_id_fk": { + "name": "changelog_release_created_by_user_id_fk", + "tableFrom": "changelog_release", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "changelog_release_updated_by_user_id_fk": { + "name": "changelog_release_updated_by_user_id_fk", + "tableFrom": "changelog_release", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "changelog_release_version_check": { + "name": "changelog_release_version_check", + "value": "\"changelog_release\".\"version_major\" >= 0 AND \"changelog_release\".\"version_minor\" >= 0 AND \"changelog_release\".\"version_patch\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_user_id_idx": { + "name": "chat_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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.client_credential_token": { + "name": "client_credential_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_digest": { + "name": "access_token_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_credential_token_expires_at_idx": { + "name": "client_credential_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"client_credential_token\".\"access_token_digest\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_credential_token_access_token_digest_idx": { + "name": "client_credential_token_access_token_digest_idx", + "columns": [ + { + "expression": "access_token_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"client_credential_token\".\"access_token_digest\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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 + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_requested_at": { + "name": "permission_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pickup_deadline_at": { + "name": "pickup_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "persist_seq": { + "name": "persist_seq", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "nextval('copilot_async_tool_calls_persist_seq')" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "browser_download_started_at": { + "name": "browser_download_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_started_at": { + "name": "execution_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_settled_at": { + "name": "execution_settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_owner_token": { + "name": "execution_owner_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_lease_expires_at": { + "name": "execution_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_revoked_at": { + "name": "execution_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_attempts": { + "name": "execution_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_workflow_execution_id": { + "name": "client_workflow_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_processes": { + "name": "sandbox_processes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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_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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memory_space_id": { + "name": "memory_space_id", + "type": "uuid", + "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 + }, + "external_conversation_key": { + "name": "external_conversation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_metadata": { + "name": "external_conversation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "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": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_organization_id_idx": { + "name": "copilot_chats_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_external_conversation_unique": { + "name": "copilot_chats_external_conversation_unique", + "columns": [ + { + "expression": "external_conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_chats\".\"external_conversation_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_org_created_idx": { + "name": "copilot_chats_user_org_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_organization_id_organization_id_fk": { + "name": "copilot_chats_organization_id_organization_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "copilot_chats_owner_check": { + "name": "copilot_chats_owner_check", + "value": "num_nonnulls(\"copilot_chats\".\"workspace_id\", \"copilot_chats\".\"organization_id\") <= 1" + }, + "copilot_chats_organization_workflow_check": { + "name": "copilot_chats_organization_workflow_check", + "value": "\"copilot_chats\".\"organization_id\" IS NULL OR \"copilot_chats\".\"workflow_id\" IS NULL" + } + }, + "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 + }, + "display_content": { + "name": "display_content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_organization_request_stops": { + "name": "copilot_organization_request_stops", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "copilot_organization_request_stops_user_id_user_id_fk": { + "name": "copilot_organization_request_stops_user_id_user_id_fk", + "tableFrom": "copilot_organization_request_stops", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_organization_request_stops_organization_id_organization_id_fk": { + "name": "copilot_organization_request_stops_organization_id_organization_id_fk", + "tableFrom": "copilot_organization_request_stops", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "copilot_organization_request_stops_user_id_organization_id_stream_id_pk": { + "name": "copilot_organization_request_stops_user_id_organization_id_stream_id_pk", + "columns": ["user_id", "organization_id", "stream_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_request_stops": { + "name": "copilot_request_stops", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "copilot_request_stops_user_id_user_id_fk": { + "name": "copilot_request_stops_user_id_user_id_fk", + "tableFrom": "copilot_request_stops", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_request_stops_workspace_id_workspace_id_fk": { + "name": "copilot_request_stops_workspace_id_workspace_id_fk", + "tableFrom": "copilot_request_stops", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "copilot_request_stops_user_id_workspace_id_stream_id_pk": { + "name": "copilot_request_stops_user_id_workspace_id_stream_id_pk", + "columns": ["user_id", "workspace_id", "stream_id"] + } + }, + "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 + }, + "tool_execution_version": { + "name": "tool_execution_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tool_admission_closed_at": { + "name": "tool_admission_closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": "'{}'" + }, + "desktop_device_id": { + "name": "desktop_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "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_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_chat_started_at_idx": { + "name": "copilot_runs_chat_started_at_idx", + "columns": [ + { + "expression": "chat_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_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": {} + }, + "copilot_runs_desktop_device_started_at_idx": { + "name": "copilot_runs_desktop_device_started_at_idx", + "columns": [ + { + "expression": "desktop_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_runs\".\"desktop_device_id\" IS NOT NULL", + "concurrently": true, + "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" + }, + "copilot_runs_organization_id_organization_id_fk": { + "name": "copilot_runs_organization_id_organization_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_desktop_device_id_desktop_devices_id_fk": { + "name": "copilot_runs_desktop_device_id_desktop_devices_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "desktop_devices", + "columnsFrom": ["desktop_device_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_service_usage": { + "name": "copilot_service_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 8)", + "primaryKey": false, + "notNull": false + }, + "worker_origin": { + "name": "worker_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_service_usage_pending_idx": { + "name": "copilot_service_usage_pending_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "delivered_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "encrypted_personal_token": { + "name": "encrypted_personal_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_oauth_config_version": { + "name": "mcp_oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "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 + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_organization_id_idx": { + "name": "credential_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_organization_account_unique": { + "name": "credential_organization_account_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_org_personal_token_unique": { + "name": "credential_org_personal_token_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "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": {} + }, + "credential_personal_token_identity_unique": { + "name": "credential_personal_token_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'personal_token'", + "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_organization_id_organization_id_fk": { + "name": "credential_organization_id_organization_id_fk", + "tableFrom": "credential", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_slack_app_id_slack_app_id_fk": { + "name": "credential_slack_app_id_slack_app_id_fk", + "tableFrom": "credential", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "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_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_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_owner_check": { + "name": "credential_owner_check", + "value": "num_nonnulls(\"credential\".\"workspace_id\", \"credential\".\"organization_id\") = 1" + }, + "credential_organization_type_check": { + "name": "credential_organization_type_check", + "value": "\"credential\".\"organization_id\" IS NULL OR \"credential\".\"type\" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')" + }, + "credential_personal_token_source_check": { + "name": "credential_personal_token_source_check", + "value": "(type::text <> 'personal_token') OR (\n created_by IS NOT NULL\n AND provider_id IS NOT NULL\n AND provider_id = 'gitlab'\n AND provider_subject_id IS NOT NULL\n AND provider_tenant_id IS NOT NULL\n AND encrypted_personal_token IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND account_id IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND authorization_app_id IS NULL\n AND encrypted_oauth_token_set IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "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_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by 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_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_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 + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "credential_group_organization_id_idx": { + "name": "credential_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_organization_unique": { + "name": "credential_group_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_unique": { + "name": "credential_group_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_organization_id_organization_id_fk": { + "name": "credential_group_organization_id_organization_id_fk", + "tableFrom": "credential_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_owner_check": { + "name": "credential_group_owner_check", + "value": "num_nonnulls(\"credential_group\".\"workspace_id\", \"credential_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_user_unique": { + "name": "credential_group_enrollment_group_user_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential_group_enrollment\".\"user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_user_id_idx": { + "name": "credential_group_enrollment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_user_id_user_id_fk": { + "name": "credential_group_enrollment_user_id_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard": { + "name": "dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_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": { + "dashboard_workspace_id_unique": { + "name": "dashboard_workspace_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboard_workspace_id_workspace_id_fk": { + "name": "dashboard_workspace_id_workspace_id_fk", + "tableFrom": "dashboard", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_created_by_user_id_fk": { + "name": "dashboard_created_by_user_id_fk", + "tableFrom": "dashboard", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "dashboard_updated_by_user_id_fk": { + "name": "dashboard_updated_by_user_id_fk", + "tableFrom": "dashboard", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "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.deleted_account_identity": { + "name": "deleted_account_identity", + "schema": "", + "columns": { + "identity_hash": { + "name": "identity_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "deleted_account_identity_expires_at_idx": { + "name": "deleted_account_identity_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.desktop_devices": { + "name": "desktop_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "desktop_devices_user_id_idx": { + "name": "desktop_devices_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "desktop_devices_session_id_idx": { + "name": "desktop_devices_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "desktop_devices_user_id_user_id_fk": { + "name": "desktop_devices_user_id_user_id_fk", + "tableFrom": "desktop_devices", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "desktop_devices_session_id_session_id_fk": { + "name": "desktop_devices_session_id_session_id_fk", + "tableFrom": "desktop_devices", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "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 + }, + "processing_recovery_after": { + "name": "processing_recovery_after", + "type": "timestamp", + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "acl_requirements": { + "name": "acl_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "acl_verified_at": { + "name": "acl_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_seen_at": { + "name": "source_seen_at", + "type": "timestamp", + "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_uploaded_by_idx": { + "name": "doc_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"uploaded_by\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_kb_source_modified_idx": { + "name": "doc_kb_source_modified_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "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_processing_recovery_idx": { + "name": "doc_connector_processing_recovery_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_connector_processing_status_idx": { + "name": "doc_connector_processing_status_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "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_source_lookup_idx": { + "name": "doc_connector_source_lookup_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_v2_idx": { + "name": "doc_connector_reconciliation_v2_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "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_connector_tombstone_idx": { + "name": "doc_connector_tombstone_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NULL AND (\"document\".\"deleted_at\" IS NOT NULL OR \"document\".\"content_hash\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_live_idx": { + "name": "doc_connector_live_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag1_lower_idx": { + "name": "doc_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag2_lower_idx": { + "name": "doc_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag3_lower_idx": { + "name": "doc_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag4_lower_idx": { + "name": "doc_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag5_lower_idx": { + "name": "doc_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag6_lower_idx": { + "name": "doc_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag7_lower_idx": { + "name": "doc_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": 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": true, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "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_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": {} + }, + "emb_kb_tag1_lower_idx": { + "name": "emb_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag2_lower_idx": { + "name": "emb_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag3_lower_idx": { + "name": "emb_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag4_lower_idx": { + "name": "emb_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag5_lower_idx": { + "name": "emb_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag6_lower_idx": { + "name": "emb_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag7_lower_idx": { + "name": "emb_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": 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": true, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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": { + "fastupdate": "off" + } + } + }, + "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_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_search": { + "name": "embedding_search", + "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 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "binary": { + "name": "binary", + "type": "bit(1536)", + "primaryKey": false, + "notNull": false + }, + "binary_384": { + "name": "binary_384", + "type": "bit(384)", + "primaryKey": false, + "notNull": false + }, + "binary_768": { + "name": "binary_768", + "type": "bit(768)", + "primaryKey": false, + "notNull": false + }, + "binary_1024": { + "name": "binary_1024", + "type": "bit(1024)", + "primaryKey": false, + "notNull": false + }, + "binary_3072": { + "name": "binary_3072", + "type": "bit(3072)", + "primaryKey": false, + "notNull": false + }, + "vector": { + "name": "vector", + "type": "halfvec(1536)", + "primaryKey": false, + "notNull": false + }, + "vector_384": { + "name": "vector_384", + "type": "halfvec(384)", + "primaryKey": false, + "notNull": false + }, + "vector_512": { + "name": "vector_512", + "type": "halfvec(512)", + "primaryKey": false, + "notNull": false + }, + "vector_768": { + "name": "vector_768", + "type": "halfvec(768)", + "primaryKey": false, + "notNull": false + }, + "vector_1024": { + "name": "vector_1024", + "type": "halfvec(1024)", + "primaryKey": false, + "notNull": false + }, + "vector_3072": { + "name": "vector_3072", + "type": "halfvec(3072)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "embedding_search_kb_idx": { + "name": "embedding_search_kb_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_search_document_lookup_idx": { + "name": "embedding_search_document_lookup_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"embedding_search\".\"enabled\"", + "concurrently": true, + "method": "btree", + "with": {} + }, + "embedding_search_cosine_hnsw_idx": { + "name": "embedding_search_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_512_cosine_hnsw_idx": { + "name": "embedding_search_512_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_512", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_384_cosine_hnsw_idx": { + "name": "embedding_search_384_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_768_cosine_hnsw_idx": { + "name": "embedding_search_768_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_1024_cosine_hnsw_idx": { + "name": "embedding_search_1024_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_3072_cosine_hnsw_idx": { + "name": "embedding_search_3072_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_3072", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": { + "embedding_search_id_embedding_id_fk": { + "name": "embedding_search_id_embedding_id_fk", + "tableFrom": "embedding_search", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_search_width_check": { + "name": "embedding_search_width_check", + "value": "num_nonnulls(\"binary\", \"binary_384\", \"binary_768\", \"binary_1024\", \"binary_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "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": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workspace_id_idx": { + "name": "execution_large_values_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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.file_search_dependency": { + "name": "file_search_dependency", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_search_dependency_file_idx": { + "name": "file_search_dependency_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_search_dependency_owner_idx": { + "name": "file_search_dependency_owner_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_search_dependency_build_id_workspace_file_search_build_id_fk": { + "name": "file_search_dependency_build_id_workspace_file_search_build_id_fk", + "tableFrom": "file_search_dependency", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_search_dependency_build_id_file_id_pk": { + "name": "file_search_dependency_build_id_file_id_pk", + "columns": ["build_id", "file_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_search_dispatch_queue": { + "name": "file_search_dispatch_queue", + "schema": "", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "file_search_dispatch_schedule_idx": { + "name": "file_search_dispatch_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "file_search_dispatch_queue_entity_type_entity_id_pk": { + "name": "file_search_dispatch_queue_entity_type_entity_id_pk", + "columns": ["entity_type", "entity_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_search_dispatch_owner_check": { + "name": "file_search_dispatch_owner_check", + "value": "\"file_search_dispatch_queue\".\"entity_type\" IN ('workspace', 'project') AND length(\"file_search_dispatch_queue\".\"entity_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "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": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_project_id_idx": { + "name": "folder_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "folder_project_resource_parent_name_active_unique": { + "name": "folder_project_resource_parent_name_active_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "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": "\"folder\".\"deleted_at\" IS NULL AND \"folder\".\"project_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "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": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_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": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "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": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_project_id_project_id_fk": { + "name": "folder_project_id_project_id_fk", + "tableFrom": "folder", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "folder_workspace_parent_owner_fk": { + "name": "folder_workspace_parent_owner_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id", "workspace_id", "resource_type"], + "columnsTo": ["id", "workspace_id", "resource_type"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "folder_project_parent_owner_fk": { + "name": "folder_project_parent_owner_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id", "project_id", "resource_type"], + "columnsTo": ["id", "project_id", "resource_type"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "folder_workspace_owner_type_unique": { + "name": "folder_workspace_owner_type_unique", + "nullsNotDistinct": false, + "columns": ["id", "workspace_id", "resource_type"] + }, + "folder_project_owner_type_unique": { + "name": "folder_project_owner_type_unique", + "nullsNotDistinct": false, + "columns": ["id", "project_id", "resource_type"] + } + }, + "policies": {}, + "checkConstraints": { + "folder_owner_check": { + "name": "folder_owner_check", + "value": "num_nonnulls(\"folder\".\"workspace_id\", \"folder\".\"project_id\") = 1 AND (\"folder\".\"project_id\" IS NULL OR \"folder\".\"resource_type\" = 'file')" + } + }, + "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 + }, + "occurrence_at": { + "name": "occurrence_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_request_id": { + "name": "billing_request_id", + "type": "text", + "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_schedule_occurrence_unique": { + "name": "job_execution_logs_schedule_occurrence_unique", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurrence_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_billing_request_unique": { + "name": "job_execution_logs_billing_request_unique", + "columns": [ + { + "expression": "billing_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"job_execution_logs\".\"billing_request_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_open_idx": { + "name": "job_execution_logs_open_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"job_execution_logs\".\"status\" IN ('queued', 'running')", + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_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 + }, + "is_search_index": { + "name": "is_search_index", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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_organization_id_idx": { + "name": "kb_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_search_index_unique": { + "name": "kb_organization_search_index_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_name_active_unique": { + "name": "kb_organization_name_active_unique", + "columns": [ + { + "expression": "organization_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": {} + }, + "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_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_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": {} + }, + "kb_workspace_search_index_unique": { + "name": "kb_workspace_search_index_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_organization_id_organization_id_fk": { + "name": "knowledge_base_organization_id_organization_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kb_owner_check": { + "name": "kb_owner_check", + "value": "num_nonnulls(\"knowledge_base\".\"workspace_id\", \"knowledge_base\".\"organization_id\") = 1" + }, + "kb_organization_search_index_check": { + "name": "kb_organization_search_index_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"is_search_index\"" + }, + "kb_organization_folder_check": { + "name": "kb_organization_folder_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"folder_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_base_import": { + "name": "knowledge_base_import", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "documents_total": { + "name": "documents_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "documents_imported": { + "name": "documents_imported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "documents_failed": { + "name": "documents_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "vectors_reused": { + "name": "vectors_reused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failures": { + "name": "failures", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": { + "knowledge_base_import_watchdog_idx": { + "name": "knowledge_base_import_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "knowledge_base_import_knowledge_base_id_idx": { + "name": "knowledge_base_import_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_import_workspace_id_workspace_id_fk": { + "name": "knowledge_base_import_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base_import", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_import_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_import_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_import", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image_removal_pending": { + "name": "image_removal_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "member_tombstone_cursor": { + "name": "member_tombstone_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "member_resurrection_cursor": { + "name": "member_resurrection_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "directory_checkpoint": { + "name": "directory_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_directory_sync_at": { + "name": "next_directory_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "detached_at": { + "name": "detached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "detach_reserved_bytes": { + "name": "detach_reserved_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "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": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_directory_sync_due_idx": { + "name": "kc_directory_sync_due_idx", + "columns": [ + { + "expression": "next_directory_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'admin' AND \"knowledge_connector\".\"deleted_at\" IS 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" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope_renewed_at": { + "name": "scope_renewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope_renewal_cursor": { + "name": "scope_renewal_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_renewal_started_at": { + "name": "scope_renewal_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_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": { + "kcm_organization_id_idx": { + "name": "kcm_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_organization_id_organization_id_fk": { + "name": "knowledge_connector_member_organization_id_organization_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_owner_check": { + "name": "kcm_owner_check", + "value": "num_nonnulls(\"knowledge_connector_member\".\"workspace_id\", \"knowledge_connector_member\".\"organization_id\") = 1" + }, + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_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 + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "processing_dispatch_failed": { + "name": "processing_dispatch_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_renewed": { + "name": "observations_renewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_failure_class": { + "name": "database_failure_class", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'partial', 'completed', 'failed')" + }, + "kcmsl_database_failure_class_check": { + "name": "kcmsl_database_failure_class_check", + "value": "\"knowledge_connector_member_sync_log\".\"database_failure_class\" IN ('capacity', 'conflict', 'connection')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_partition": { + "name": "knowledge_connector_partition", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "partition_key": { + "name": "partition_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_served_at": { + "name": "last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure": { + "name": "failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "permission_cursor": { + "name": "permission_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_attempts": { + "name": "permission_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permission_retry_at": { + "name": "permission_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permission_last_served_at": { + "name": "permission_last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_started_at": { + "name": "permission_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_failure": { + "name": "permission_failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcp_content_due_idx": { + "name": "kcp_content_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcp_permission_due_idx": { + "name": "kcp_permission_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_partition_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_partition_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_partition", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcp_pk": { + "name": "kcp_pk", + "columns": ["connector_id", "partition_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcp_partition_key_check": { + "name": "kcp_partition_key_check", + "value": "octet_length(\"knowledge_connector_partition\".\"partition_key\") BETWEEN 1 AND 1024" + }, + "kcp_context_check": { + "name": "kcp_context_check", + "value": "jsonb_typeof(\"knowledge_connector_partition\".\"context\") = 'object' AND octet_length(\"knowledge_connector_partition\".\"context\"::text) <= 16384" + }, + "kcp_status_check": { + "name": "kcp_status_check", + "value": "\"knowledge_connector_partition\".\"status\" IN ('pending', 'complete', 'blocked')" + }, + "kcp_cursor_check": { + "name": "kcp_cursor_check", + "value": "(\"knowledge_connector_partition\".\"cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"cursor\") <= 393216) AND (\"knowledge_connector_partition\".\"permission_cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"permission_cursor\") <= 393216)" + }, + "kcp_attempts_check": { + "name": "kcp_attempts_check", + "value": "\"knowledge_connector_partition\".\"attempts\" >= 0 AND \"knowledge_connector_partition\".\"permission_attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_grant": { + "name": "knowledge_connector_permission_grant", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kcpg_subject_idx": { + "name": "kcpg_subject_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kcpg_snapshot_fk": { + "name": "kcpg_snapshot_fk", + "tableFrom": "knowledge_connector_permission_grant", + "tableTo": "knowledge_connector_permission_snapshot", + "columnsFrom": ["connector_id"], + "columnsTo": ["connector_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcpg_pk": { + "name": "kcpg_pk", + "columns": ["connector_id", "group_key", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcpg_group_check": { + "name": "kcpg_group_check", + "value": "length(\"knowledge_connector_permission_grant\".\"group_key\") BETWEEN 1 AND 255" + }, + "kcpg_subject_check": { + "name": "kcpg_subject_check", + "value": "\"knowledge_connector_permission_grant\".\"subject_token\" ~ '^u:[^[:space:]A-Z]+@[^[:space:]A-Z]+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_snapshot": { + "name": "knowledge_connector_permission_snapshot", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "kcps_connector_fk": { + "name": "kcps_connector_fk", + "tableFrom": "knowledge_connector_permission_snapshot", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcps_revision_check": { + "name": "kcps_revision_check", + "value": "\"knowledge_connector_permission_snapshot\".\"revision\" > 0" + } + }, + "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_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "listed_count": { + "name": "listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_failure_class": { + "name": "database_failure_class", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_terminal_started_at_idx": { + "name": "kcsl_terminal_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" IN ('completed', 'failed')", + "concurrently": true, + "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": { + "kcsl_database_failure_class_check": { + "name": "kcsl_database_failure_class_check", + "value": "\"knowledge_connector_sync_log\".\"database_failure_class\" IN ('capacity', 'conflict', 'connection')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_directory": { + "name": "knowledge_external_directory", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_sync_at": { + "name": "last_complete_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ked_organization_id_idx": { + "name": "ked_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_workspace_identity_unique": { + "name": "ked_workspace_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_organization_identity_unique": { + "name": "ked_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_directory_workspace_id_workspace_id_fk": { + "name": "knowledge_external_directory_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_external_directory_organization_id_organization_id_fk": { + "name": "knowledge_external_directory_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ked_owner_check": { + "name": "ked_owner_check", + "value": "num_nonnulls(\"knowledge_external_directory\".\"workspace_id\", \"knowledge_external_directory\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_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": { + "keg_organization_id_idx": { + "name": "keg_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_identity_unique": { + "name": "keg_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_synced_idx": { + "name": "keg_organization_synced_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_group_organization_id_organization_id_fk": { + "name": "knowledge_external_group_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "keg_owner_check": { + "name": "keg_owner_check", + "value": "num_nonnulls(\"knowledge_external_group\".\"workspace_id\", \"knowledge_external_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_subject_token_idx": { + "name": "kegm_subject_token_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_subject_token_pk": { + "name": "knowledge_external_group_member_group_id_subject_token_pk", + "columns": ["group_id", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_projection_dirty": { + "name": "knowledge_projection_dirty", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content": { + "name": "content", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marked_at": { + "name": "marked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "knowledge_projection_dirty_marked_at_idx": { + "name": "knowledge_projection_dirty_marked_at_idx", + "columns": [ + { + "expression": "marked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_projection_dirty_document_id_document_id_fk": { + "name": "knowledge_projection_dirty_document_id_document_id_fk", + "tableFrom": "knowledge_projection_dirty", + "tableTo": "document", + "columnsFrom": ["document_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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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" + }, + "mcp_server_oauth_organization_id_organization_id_fk": { + "name": "mcp_server_oauth_organization_id_organization_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_server_oauth_owner_check": { + "name": "mcp_server_oauth_owner_check", + "value": "num_nonnulls(\"mcp_server_oauth\".\"workspace_id\", \"mcp_server_oauth\".\"organization_id\") = 1" + } + }, + "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": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config_version": { + "name": "oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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_organization_id_idx": { + "name": "mcp_servers_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "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_organization_id_organization_id_fk": { + "name": "mcp_servers_organization_id_organization_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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": { + "mcp_servers_owner_check": { + "name": "mcp_servers_owner_check", + "value": "num_nonnulls(\"mcp_servers\".\"workspace_id\", \"mcp_servers\".\"organization_id\") = 1" + }, + "mcp_servers_organization_managed_check": { + "name": "mcp_servers_organization_managed_check", + "value": "\"mcp_servers\".\"organization_id\" IS NULL OR \"mcp_servers\".\"credential_group_id\" IS NOT NULL" + }, + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "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 + }, + "storage_version": { + "name": "storage_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "encrypted_context_summary": { + "name": "encrypted_context_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "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()" + }, + "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.memory_artifact": { + "name": "memory_artifact", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "memory_artifact_key_idx": { + "name": "memory_artifact_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_artifact_memory_id_memory_id_fk": { + "name": "memory_artifact_memory_id_memory_id_fk", + "tableFrom": "memory_artifact", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_artifact_key_execution_large_values_key_fk": { + "name": "memory_artifact_key_execution_large_values_key_fk", + "tableFrom": "memory_artifact", + "tableTo": "execution_large_values", + "columnsFrom": ["key"], + "columnsTo": ["key"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memory_artifact_memory_id_key_pk": { + "name": "memory_artifact_memory_id_key_pk", + "columns": ["memory_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_item": { + "name": "memory_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "memory_item_sequence_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "append_key": { + "name": "append_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance_status": { + "name": "provenance_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance_entries": { + "name": "provenance_entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_item_append_unique": { + "name": "memory_item_append_unique", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "append_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_item_sequence_idx": { + "name": "memory_item_sequence_idx", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_item_memory_id_memory_id_fk": { + "name": "memory_item_memory_id_memory_id_fk", + "tableFrom": "memory_item", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_item_kind_check": { + "name": "memory_item_kind_check", + "value": "\"memory_item\".\"kind\" IN ('message', 'exchange')" + }, + "memory_item_provenance_status_check": { + "name": "memory_item_provenance_status_check", + "value": "\"memory_item\".\"provenance_status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_benchmark_runs": { + "name": "mothership_benchmark_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "benchmark_id": { + "name": "benchmark_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "evaluation_key": { + "name": "evaluation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "correct": { + "name": "correct", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "automatic_correct": { + "name": "automatic_correct", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_benchmark_runs_benchmark_created_idx": { + "name": "mothership_benchmark_runs_benchmark_created_idx", + "columns": [ + { + "expression": "benchmark_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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_benchmark_runs_benchmark_id_mothership_benchmarks_id_fk": { + "name": "mothership_benchmark_runs_benchmark_id_mothership_benchmarks_id_fk", + "tableFrom": "mothership_benchmark_runs", + "tableTo": "mothership_benchmarks", + "columnsFrom": ["benchmark_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mothership_benchmark_runs_score_check": { + "name": "mothership_benchmark_runs_score_check", + "value": "\"mothership_benchmark_runs\".\"total\" > 0 AND \"mothership_benchmark_runs\".\"correct\" >= 0 AND \"mothership_benchmark_runs\".\"correct\" <= \"mothership_benchmark_runs\".\"total\"" + }, + "mothership_benchmark_runs_version_check": { + "name": "mothership_benchmark_runs_version_check", + "value": "\"mothership_benchmark_runs\".\"version\" > 0" + }, + "mothership_benchmark_runs_automatic_score_check": { + "name": "mothership_benchmark_runs_automatic_score_check", + "value": "\"mothership_benchmark_runs\".\"automatic_correct\" >= 0 AND \"mothership_benchmark_runs\".\"automatic_correct\" <= \"mothership_benchmark_runs\".\"total\"" + } + }, + "isRLSEnabled": false + }, + "public.mothership_benchmarks": { + "name": "mothership_benchmarks", + "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 + }, + "run_as_user_id": { + "name": "run_as_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "running_stage": { + "name": "running_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "planner_chat_id": { + "name": "planner_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "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": { + "mothership_benchmarks_owner_created_idx": { + "name": "mothership_benchmarks_owner_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mothership_benchmarks_workspace_idx": { + "name": "mothership_benchmarks_workspace_idx", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_benchmarks_organization_id_organization_id_fk": { + "name": "mothership_benchmarks_organization_id_organization_id_fk", + "tableFrom": "mothership_benchmarks", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_benchmarks_user_id_user_id_fk": { + "name": "mothership_benchmarks_user_id_user_id_fk", + "tableFrom": "mothership_benchmarks", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_benchmarks_run_as_user_id_user_id_fk": { + "name": "mothership_benchmarks_run_as_user_id_user_id_fk", + "tableFrom": "mothership_benchmarks", + "tableTo": "user", + "columnsFrom": ["run_as_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_benchmarks_source_workspace_id_workspace_id_fk": { + "name": "mothership_benchmarks_source_workspace_id_workspace_id_fk", + "tableFrom": "mothership_benchmarks", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mothership_benchmarks_version_check": { + "name": "mothership_benchmarks_version_check", + "value": "\"mothership_benchmarks\".\"version\" > 0" + }, + "mothership_benchmarks_attempt_check": { + "name": "mothership_benchmarks_attempt_check", + "value": "(\"mothership_benchmarks\".\"running_stage\" IS NULL AND \"mothership_benchmarks\".\"attempt_id\" IS NULL AND \"mothership_benchmarks\".\"lease_expires_at\" IS NULL) OR (\"mothership_benchmarks\".\"running_stage\" IS NOT NULL AND \"mothership_benchmarks\".\"running_stage\" IN ('distill', 'redact', 'plan', 'reconstruct', 'grade') AND \"mothership_benchmarks\".\"attempt_id\" IS NOT NULL AND \"mothership_benchmarks\".\"lease_expires_at\" IS NOT NULL)" + } + }, + "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_memory_selections": { + "name": "mothership_memory_selections", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_memory_selections_user_id_user_id_fk": { + "name": "mothership_memory_selections_user_id_user_id_fk", + "tableFrom": "mothership_memory_selections", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_memory_selections_organization_id_organization_id_fk": { + "name": "mothership_memory_selections_organization_id_organization_id_fk", + "tableFrom": "mothership_memory_selections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_memory_selections_space_id_mothership_memory_spaces_id_fk": { + "name": "mothership_memory_selections_space_id_mothership_memory_spaces_id_fk", + "tableFrom": "mothership_memory_selections", + "tableTo": "mothership_memory_spaces", + "columnsFrom": ["space_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mothership_memory_selections_user_id_organization_id_pk": { + "name": "mothership_memory_selections_user_id_organization_id_pk", + "columns": ["user_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_memory_spaces": { + "name": "mothership_memory_spaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_memory_spaces_owner_idx": { + "name": "mothership_memory_spaces_owner_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_memory_spaces_user_id_user_id_fk": { + "name": "mothership_memory_spaces_user_id_user_id_fk", + "tableFrom": "mothership_memory_spaces", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_memory_spaces_organization_id_organization_id_fk": { + "name": "mothership_memory_spaces_organization_id_organization_id_fk", + "tableFrom": "mothership_memory_spaces", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_resource_effects": { + "name": "mothership_resource_effects", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effect_id": { + "name": "effect_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_resource_effects_chat_id_copilot_chats_id_fk": { + "name": "mothership_resource_effects_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_resource_effects", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mothership_resource_effects_chat_id_effect_id_pk": { + "name": "mothership_resource_effects_chat_id_effect_id_pk", + "columns": ["chat_id", "effect_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_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 + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_access_token_search_resource_check": { + "name": "oauth_access_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_access_token\".\"scopes\")) OR \"oauth_access_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "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 + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_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 + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + }, + "oauth_refresh_token_search_resource_check": { + "name": "oauth_refresh_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_refresh_token\".\"scopes\")) OR \"oauth_refresh_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "require_sso": { + "name": "require_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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_access_request_settings": { + "name": "organization_access_request_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "allow_requests": { + "name": "allow_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_access_request_settings_organization_id_organization_id_fk": { + "name": "organization_access_request_settings_organization_id_organization_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_access_request_settings_updated_by_user_id_fk": { + "name": "organization_access_request_settings_updated_by_user_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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.organization_search_history": { + "name": "organization_search_history", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "queries": { + "name": "queries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "organization_search_history_user_idx": { + "name": "organization_search_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_history_organization_id_organization_id_fk": { + "name": "organization_search_history_organization_id_organization_id_fk", + "tableFrom": "organization_search_history", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_history_user_id_user_id_fk": { + "name": "organization_search_history_user_id_user_id_fk", + "tableFrom": "organization_search_history", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_history_organization_id_user_id_pk": { + "name": "organization_search_history_organization_id_user_id_pk", + "columns": ["organization_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_integration": { + "name": "organization_search_integration", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_search_integration_organization_id_organization_id_fk": { + "name": "organization_search_integration_organization_id_organization_id_fk", + "tableFrom": "organization_search_integration", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_integration_organization_id_connector_type_pk": { + "name": "organization_search_integration_organization_id_connector_type_pk", + "columns": ["organization_id", "connector_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_mcp_invocation": { + "name": "organization_search_mcp_invocation", + "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": false + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_mcp_invocation_org_created_idx": { + "name": "organization_search_mcp_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_mcp_invocation_user_idx": { + "name": "organization_search_mcp_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_search_mcp_invocation_org_fk": { + "name": "org_search_mcp_invocation_org_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_search_mcp_invocation_user_fk": { + "name": "org_search_mcp_invocation_user_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_mcp_invocation_tool_check": { + "name": "organization_search_mcp_invocation_tool_check", + "value": "\"organization_search_mcp_invocation\".\"tool_name\" IN ('search', 'read_document', 'chat')" + }, + "organization_search_mcp_invocation_outcome_check": { + "name": "organization_search_mcp_invocation_outcome_check", + "value": "\"organization_search_mcp_invocation\".\"outcome\" IN ('success', 'error', 'cancelled', 'rate_limited')" + }, + "organization_search_mcp_invocation_duration_check": { + "name": "organization_search_mcp_invocation_duration_check", + "value": "\"organization_search_mcp_invocation\".\"duration_ms\" >= 0" + }, + "organization_search_mcp_invocation_client_name_check": { + "name": "organization_search_mcp_invocation_client_name_check", + "value": "length(\"organization_search_mcp_invocation\".\"client_name\") <= 256" + }, + "organization_search_mcp_invocation_auth_check": { + "name": "organization_search_mcp_invocation_auth_check", + "value": "(\"organization_search_mcp_invocation\".\"auth_kind\" = 'oauth_access_token' AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NOT NULL)\n OR (\"organization_search_mcp_invocation\".\"auth_kind\" IN ('personal_api_key', 'workspace_api_key') AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NULL AND \"organization_search_mcp_invocation\".\"client_name\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.organization_secret": { + "name": "organization_secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_secret_shared_unique": { + "name": "organization_secret_shared_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_secret\".\"owner_user_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_secret_member_unique": { + "name": "organization_secret_member_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_secret\".\"owner_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_secret_owner_idx": { + "name": "organization_secret_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_secret_source_id_organization_secret_source_id_fk": { + "name": "organization_secret_source_id_organization_secret_source_id_fk", + "tableFrom": "organization_secret", + "tableTo": "organization_secret_source", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_secret_owner_user_id_user_id_fk": { + "name": "organization_secret_owner_user_id_user_id_fk", + "tableFrom": "organization_secret", + "tableTo": "user", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_secret_source": { + "name": "organization_secret_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "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": { + "organization_secret_source_org_unique": { + "name": "organization_secret_source_org_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_secret_source_organization_id_organization_id_fk": { + "name": "organization_secret_source_organization_id_organization_id_fk", + "tableFrom": "organization_secret_source", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_secret_source_mode_check": { + "name": "organization_secret_source_mode_check", + "value": "\"organization_secret_source\".\"mode\" IN ('organization', 'member')" + } + }, + "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_pending_type_available_idx": { + "name": "outbox_event_pending_type_available_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_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": "\"outbox_event\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "oauth_config": { + "name": "oauth_config", + "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_organization_id_idx": { + "name": "pending_draft_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_org": { + "name": "pending_draft_user_provider_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_organization_id_organization_id_fk": { + "name": "pending_credential_draft_organization_id_organization_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "organization", + "columnsFrom": ["organization_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": { + "pending_draft_owner_check": { + "name": "pending_draft_owner_check", + "value": "num_nonnulls(\"pending_credential_draft\".\"workspace_id\", \"pending_credential_draft\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.permission_access_request": { + "name": "permission_access_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requester_id": { + "name": "requester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "jsonb", + "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()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "permission_access_request_pending_unique": { + "name": "permission_access_request_pending_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"permission_access_request\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_org_queue_idx": { + "name": "permission_access_request_org_queue_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_requester_idx": { + "name": "permission_access_request_requester_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_access_request_organization_id_organization_id_fk": { + "name": "permission_access_request_organization_id_organization_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_requester_id_user_id_fk": { + "name": "permission_access_request_requester_id_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["requester_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_decided_by_user_id_fk": { + "name": "permission_access_request_decided_by_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["decided_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "permission_access_request_status_check": { + "name": "permission_access_request_status_check", + "value": "\"permission_access_request\".\"status\" in ('pending', 'fulfilled', 'declined', 'cancelled', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_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.pinned_item": { + "name": "pinned_item", + "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 + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_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": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_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": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_scope_key": { + "name": "organization_scope_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END", + "type": "stored" + } + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "project_organization_archive_id_idx": { + "name": "project_organization_archive_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_archive_id_idx": { + "name": "project_owner_archive_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_organization_id_organization_id_fk": { + "name": "project_organization_id_organization_id_fk", + "tableFrom": "project", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_owner_id_user_id_fk": { + "name": "project_owner_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_id_organization_scope_unique": { + "name": "project_id_organization_scope_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_scope_key"] + } + }, + "policies": {}, + "checkConstraints": { + "project_name_length": { + "name": "project_name_length", + "value": "char_length(btrim(\"project\".\"name\")) BETWEEN 1 AND 100" + } + }, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_entity_id_idx": { + "name": "public_share_entity_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "public_share_entity_binding_check": { + "name": "public_share_entity_binding_check", + "value": "\n (\"public_share\".\"entity_type\" IS NULL AND \"public_share\".\"entity_id\" IS NULL AND \"public_share\".\"workspace_id\" IS NOT NULL)\n OR (\"public_share\".\"entity_type\" IS NOT NULL AND \"public_share\".\"entity_id\" IS NOT NULL AND char_length(\"public_share\".\"entity_id\") > 0\n AND ((\"public_share\".\"entity_type\" = 'workspace' AND \"public_share\".\"workspace_id\" IS NOT NULL AND \"public_share\".\"entity_id\" = \"public_share\".\"workspace_id\")\n OR (\"public_share\".\"entity_type\" = 'project' AND \"public_share\".\"workspace_id\" IS NULL AND \"public_share\".\"resource_type\" = 'file')))\n " + } + }, + "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 + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_state": { + "name": "capacity_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_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": { + "resource_policy_organization_id_idx": { + "name": "resource_policy_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_organization_id_organization_id_fk": { + "name": "resource_policy_organization_id_organization_id_fk", + "tableFrom": "resource_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_policy_owner_check": { + "name": "resource_policy_owner_check", + "value": "num_nonnulls(\"resource_policy\".\"workspace_id\", \"resource_policy\".\"organization_id\") = 1" + } + }, + "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.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_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": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "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": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "baseline_permission": { + "name": "baseline_permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "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": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "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": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_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": {} + } + }, + "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 + }, + "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 + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_app": { + "name": "slack_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_signing_secret": { + "name": "encrypted_signing_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "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": {}, + "foreignKeys": { + "slack_app_organization_id_organization_id_fk": { + "name": "slack_app_organization_id_organization_id_fk", + "tableFrom": "slack_app", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_app_owner_check": { + "name": "slack_app_owner_check", + "value": "(\"slack_app\".\"kind\" = 'custom' AND \"slack_app\".\"organization_id\" IS NOT NULL) OR (\"slack_app\".\"kind\" = 'shared' AND \"slack_app\".\"organization_id\" IS NULL)" + }, + "slack_app_custom_credentials_check": { + "name": "slack_app_custom_credentials_check", + "value": "\"slack_app\".\"kind\" = 'shared' OR (\"slack_app\".\"client_id\" IS NOT NULL AND \"slack_app\".\"encrypted_client_secret\" IS NOT NULL AND \"slack_app\".\"encrypted_signing_secret\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.slack_search_installation": { + "name": "slack_search_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "credential_version": { + "name": "credential_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_outcome": { + "name": "last_outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_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": { + "slack_search_installation_organization_idx": { + "name": "slack_search_installation_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_credential_unique": { + "name": "slack_search_installation_credential_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_app_team_unique": { + "name": "slack_search_installation_app_team_unique", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_active_team_unique": { + "name": "slack_search_installation_active_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_installation\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_installation_organization_id_organization_id_fk": { + "name": "slack_search_installation_organization_id_organization_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_credential_id_credential_id_fk": { + "name": "slack_search_installation_credential_id_credential_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_slack_app_id_slack_app_id_fk": { + "name": "slack_search_installation_slack_app_id_slack_app_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_search_turn": { + "name": "slack_search_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "slack_search_turn_ordinal_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_key": { + "name": "conversation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "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'" + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "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": { + "slack_search_turn_event_unique": { + "name": "slack_search_turn_event_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_pending_idx": { + "name": "slack_search_turn_pending_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_thread_idx": { + "name": "slack_search_turn_thread_idx", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_active_thread_unique": { + "name": "slack_search_turn_active_thread_unique", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_turn\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_turn_installation_id_slack_search_installation_id_fk": { + "name": "slack_search_turn_installation_id_slack_search_installation_id_fk", + "tableFrom": "slack_search_turn", + "tableTo": "slack_search_installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "primary_provider_id": { + "name": "primary_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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 + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "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": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "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" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "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 + }, + "knowledge_base_id": { + "name": "knowledge_base_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 + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_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_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_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": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "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 + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "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()" + }, + "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'" + }, + "credit_balance": { + "name": "credit_balance", + "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'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "free_tier_eligible": { + "name": "free_tier_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_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 + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_created_by_idx": { + "name": "user_table_def_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_row_changes": { + "name": "user_table_row_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "user_table_row_changes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_delta": { + "name": "row_delta", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_table_row_changes_table_id_idx": { + "name": "user_table_row_changes_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_row_changes_table_id_user_table_definitions_id_fk": { + "name": "user_table_row_changes_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_row_changes", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "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 + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "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_created_by_idx": { + "name": "user_table_rows_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_rows\".\"created_by\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": { + "fastupdate": "off" + } + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "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": "\"workflow\".\"archived_at\" IS NULL", + "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_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "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 + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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_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_activity_level_idx": { + "name": "workflow_execution_logs_workspace_activity_level_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_duration_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_budget_usd": { + "name": "run_budget_usd", + "type": "numeric(12, 4)", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_test": { + "name": "workflow_test", + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_file_id": { + "name": "body_file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cases": { + "name": "cases", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "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": { + "workflow_test_workspace_name_unique": { + "name": "workflow_test_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_test\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_test_body_file_unique": { + "name": "workflow_test_body_file_unique", + "columns": [ + { + "expression": "body_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_test_workspace_id_workspace_id_fk": { + "name": "workflow_test_workspace_id_workspace_id_fk", + "tableFrom": "workflow_test", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_test_body_file_id_workspace_files_id_fk": { + "name": "workflow_test_body_file_id_workspace_files_id_fk", + "tableFrom": "workflow_test", + "tableTo": "workspace_files", + "columnsFrom": ["body_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_test_created_by_user_id_user_id_fk": { + "name": "workflow_test_created_by_user_id_user_id_fk", + "tableFrom": "workflow_test", + "tableTo": "user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_test_run": { + "name": "workflow_test_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "test_id": { + "name": "test_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped": { + "name": "skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ran_against": { + "name": "ran_against", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "triggered_by_actor": { + "name": "triggered_by_actor", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_test_run_test_version_started_idx": { + "name": "workflow_test_run_test_version_started_idx", + "columns": [ + { + "expression": "test_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_test_run_test_id_workflow_test_id_fk": { + "name": "workflow_test_run_test_id_workflow_test_id_fk", + "tableFrom": "workflow_test_run", + "tableTo": "workflow_test", + "columnsFrom": ["test_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_test_run_workspace_id_workspace_id_fk": { + "name": "workflow_test_run_workspace_id_workspace_id_fk", + "tableFrom": "workflow_test_run", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_test_run_triggered_by_user_id_user_id_fk": { + "name": "workflow_test_run_triggered_by_user_id_user_id_fk", + "tableFrom": "workflow_test_run", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_test_run_version_check": { + "name": "workflow_test_run_version_check", + "value": "\"workflow_test_run\".\"version\" IN ('draft', 'deployed')" + }, + "workflow_test_run_status_check": { + "name": "workflow_test_run_status_check", + "value": "\"workflow_test_run\".\"status\" IN ('running', 'passed', 'failed', 'error')" + }, + "workflow_test_run_completed_check": { + "name": "workflow_test_run_completed_check", + "value": "(\"workflow_test_run\".\"status\" = 'running') = (\"workflow_test_run\".\"completed_at\" IS NULL)" + } + }, + "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 + }, + "organization_scope_key": { + "name": "organization_scope_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END", + "type": "stored" + } + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fork_sync_new_workflows_excluded": { + "name": "fork_sync_new_workflows_excluded", + "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": { + "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_billed_account_user_id_idx": { + "name": "workspace_billed_account_user_id_idx", + "columns": [ + { + "expression": "billed_account_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_project_id_id_idx": { + "name": "workspace_project_id_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "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_project_id_project_id_fk": { + "name": "workspace_project_id_project_id_fk", + "tableFrom": "workspace", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_project_organization_fk": { + "name": "workspace_project_organization_fk", + "tableFrom": "workspace", + "tableTo": "project", + "columnsFrom": ["project_id", "organization_scope_key"], + "columnsTo": ["id", "organization_scope_key"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_fork_project_fk": { + "name": "workspace_fork_project_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id", "project_id"], + "columnsTo": ["id", "project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_id_project_unique": { + "name": "workspace_id_project_unique", + "nullsNotDistinct": false, + "columns": ["id", "project_id"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_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": {} + }, + "workspace_file_uploaded_by_idx": { + "name": "workspace_file_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "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_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_entity_type": { + "name": "after_entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_entity_id": { + "name": "after_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_build": { + "name": "workspace_file_search_build", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_file_search_build_file_idx": { + "name": "workspace_file_search_build_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_build_cleanup_idx": { + "name": "workspace_file_search_build_cleanup_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_build\".\"expires_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_search_build_owner_check": { + "name": "file_search_build_owner_check", + "value": "(\"workspace_file_search_build\".\"entity_type\" IS NULL AND \"workspace_file_search_build\".\"entity_id\" IS NULL AND \"workspace_file_search_build\".\"workspace_id\" IS NOT NULL) OR (\"workspace_file_search_build\".\"entity_type\" = 'workspace' AND \"workspace_file_search_build\".\"entity_id\" = \"workspace_file_search_build\".\"workspace_id\") OR (\"workspace_file_search_build\".\"entity_type\" = 'project' AND length(\"workspace_file_search_build\".\"entity_id\") > 0 AND \"workspace_file_search_build\".\"workspace_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_chunk": { + "name": "workspace_file_search_chunk", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_start": { + "name": "line_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fragment": { + "name": "fragment", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "overlap": { + "name": "overlap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "file_search_chunk_owner_content_idx": { + "name": "file_search_chunk_owner_content_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_chunk\".\"entity_type\" = 'project'", + "concurrently": true, + "method": "gin", + "with": { + "fastupdate": "off" + } + }, + "workspace_file_search_chunk_line_idx": { + "name": "workspace_file_search_chunk_line_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "line_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_chunk_content_idx": { + "name": "workspace_file_search_chunk_content_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": true, + "method": "gin", + "with": { + "fastupdate": "off" + } + } + }, + "foreignKeys": { + "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_chunk", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_chunk_pk": { + "name": "workspace_file_search_chunk_pk", + "columns": ["build_id", "ordinal"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_search_chunk_owner_check": { + "name": "file_search_chunk_owner_check", + "value": "(\"workspace_file_search_chunk\".\"entity_type\" IS NULL AND \"workspace_file_search_chunk\".\"entity_id\" IS NULL AND \"workspace_file_search_chunk\".\"workspace_id\" IS NOT NULL) OR (\"workspace_file_search_chunk\".\"entity_type\" = 'workspace' AND \"workspace_file_search_chunk\".\"entity_id\" = \"workspace_file_search_chunk\".\"workspace_id\") OR (\"workspace_file_search_chunk\".\"entity_type\" = 'project' AND length(\"workspace_file_search_chunk\".\"entity_id\") > 0 AND \"workspace_file_search_chunk\".\"workspace_id\" IS NULL)" + }, + "workspace_file_search_chunk_content_size": { + "name": "workspace_file_search_chunk_content_size", + "value": "octet_length(\"workspace_file_search_chunk\".\"content\") <= 8192" + }, + "workspace_file_search_chunk_position": { + "name": "workspace_file_search_chunk_position", + "value": "\"workspace_file_search_chunk\".\"ordinal\" >= 0 AND \"workspace_file_search_chunk\".\"line_start\" > 0 AND \"workspace_file_search_chunk\".\"overlap\" BETWEEN 0 AND 2" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_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_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_revision": { + "name": "workspace_file_search_revision", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "handoff_expires_at": { + "name": "handoff_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "file_search_revision_owner_pending_idx": { + "name": "file_search_revision_owner_pending_idx", + "columns": [ + { + "expression": "coalesce(\"entity_type\", 'workspace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"entity_id\", \"workspace_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "file_search_revision_owner_status_idx": { + "name": "file_search_revision_owner_status_idx", + "columns": [ + { + "expression": "coalesce(\"entity_type\", 'workspace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"entity_id\", \"workspace_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_workspace_status_idx": { + "name": "workspace_file_search_revision_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_build_idx": { + "name": "workspace_file_search_revision_build_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_pending_idx": { + "name": "workspace_file_search_revision_pending_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_active_idx": { + "name": "workspace_file_search_revision_active_idx", + "columns": [ + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_revision_file_id_workspace_files_id_fk": { + "name": "workspace_file_search_revision_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_search_revision_owner_check": { + "name": "file_search_revision_owner_check", + "value": "(\"workspace_file_search_revision\".\"entity_type\" IS NULL AND \"workspace_file_search_revision\".\"entity_id\" IS NULL AND \"workspace_file_search_revision\".\"workspace_id\" IS NOT NULL) OR (\"workspace_file_search_revision\".\"entity_type\" = 'workspace' AND \"workspace_file_search_revision\".\"entity_id\" = \"workspace_file_search_revision\".\"workspace_id\") OR (\"workspace_file_search_revision\".\"entity_type\" = 'project' AND length(\"workspace_file_search_revision\".\"entity_id\") > 0 AND \"workspace_file_search_revision\".\"workspace_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_version": { + "name": "workspace_file_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "workspace_file_version_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_user_ids": { + "name": "author_user_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "restored_from_version": { + "name": "restored_from_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_status": { + "name": "secret_provenance_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_entries": { + "name": "secret_provenance_entries", + "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": { + "workspace_file_version_file_version_unique": { + "name": "workspace_file_version_file_version_unique", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_key_unique": { + "name": "workspace_file_version_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_workspace_id_idx": { + "name": "workspace_file_version_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_workspace_superseded_idx": { + "name": "workspace_file_version_workspace_superseded_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "superseded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_version\".\"superseded_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_version_file_id_workspace_files_id_fk": { + "name": "workspace_file_version_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_version", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_version_workspace_id_workspace_id_fk": { + "name": "workspace_file_version_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_version", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_version_provenance_status_check": { + "name": "workspace_file_version_provenance_status_check", + "value": "\"workspace_file_version\".\"secret_provenance_status\" IS NULL OR \"workspace_file_version\".\"secret_provenance_status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "date_trunc('milliseconds', now())" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_project_id_idx": { + "name": "workspace_files_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_project_folder_name_active_unique": { + "name": "workspace_files_project_folder_name_active_unique", + "columns": [ + { + "expression": "project_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\".\"project_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "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_search_owner_keyset_idx": { + "name": "workspace_files_search_owner_keyset_idx", + "columns": [ + { + "expression": "(CASE WHEN \"project_id\" IS NOT NULL THEN 'project' ELSE 'workspace' END)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"project_id\", \"workspace_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND ((\"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL) OR (\"workspace_files\".\"context\" = 'project' AND \"workspace_files\".\"project_id\" IS NOT NULL))", + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_active_keyset_idx": { + "name": "workspace_files_workspace_active_keyset_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_display_name_idx": { + "name": "workspace_files_workspace_display_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "regexp_replace(regexp_replace(regexp_replace(normalize(\"original_name\", NFC), '^[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+|[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+$', '', 'g'), '[\\x01-\\x1f\\x7f]', '', 'g'), '[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+', ' ', 'g')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": true, + "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_organization_id_idx": { + "name": "workspace_files_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_project_id_project_id_fk": { + "name": "workspace_files_project_id_project_id_fk", + "tableFrom": "workspace_files", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "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_organization_id_organization_id_fk": { + "name": "workspace_files_organization_id_organization_id_fk", + "tableFrom": "workspace_files", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "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": { + "workspace_files_owner_check": { + "name": "workspace_files_owner_check", + "value": "num_nonnulls(\"workspace_files\".\"workspace_id\", \"workspace_files\".\"project_id\", \"workspace_files\".\"organization_id\") <= 1" + }, + "workspace_files_workspace_binding_check": { + "name": "workspace_files_workspace_binding_check", + "value": "\"workspace_files\".\"context\" NOT IN ('workspace', 'chat', 'mothership', 'execution', 'workspace-logos') OR \"workspace_files\".\"workspace_id\" IS NOT NULL" + }, + "workspace_files_project_binding_check": { + "name": "workspace_files_project_binding_check", + "value": "(\"workspace_files\".\"project_id\" IS NOT NULL) = (\"workspace_files\".\"context\" = 'project') AND (\"workspace_files\".\"project_id\" IS NULL OR \"workspace_files\".\"chat_id\" IS NULL)" + }, + "workspace_files_organization_binding_check": { + "name": "workspace_files_organization_binding_check", + "value": "\"workspace_files\".\"organization_id\" IS NULL OR (\"workspace_files\".\"workspace_id\" IS NULL AND \"workspace_files\".\"context\" = 'knowledge-base' AND \"workspace_files\".\"folder_id\" IS NULL AND \"workspace_files\".\"chat_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_workflow_sync": { + "name": "workspace_fork_workflow_sync", + "schema": "", + "columns": { + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workflow_id": { + "name": "source_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_deployment_version_id": { + "name": "source_deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "workspace_fork_workflow_sync_sequence_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "promote_run_id": { + "name": "promote_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rollback_operation_id": { + "name": "rollback_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rolled_back_at": { + "name": "rolled_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_fork_workflow_sync_baseline_idx": { + "name": "workspace_fork_workflow_sync_baseline_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_fork_workflow_sync\".\"activated_at\" IS NOT NULL AND \"workspace_fork_workflow_sync\".\"rolled_back_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_run_idx": { + "name": "workspace_fork_workflow_sync_run_idx", + "columns": [ + { + "expression": "promote_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_rollback_idx": { + "name": "workspace_fork_workflow_sync_rollback_idx", + "columns": [ + { + "expression": "rollback_operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_fork_workflow_sync\".\"rollback_operation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_source_idx": { + "name": "workspace_fork_workflow_sync_source_idx", + "columns": [ + { + "expression": "source_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_target_idx": { + "name": "workspace_fork_workflow_sync_target_idx", + "columns": [ + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_child_workspace_idx": { + "name": "workspace_fork_workflow_sync_child_workspace_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_workflow_sync_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_workflow_sync_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_workflow_sync_source_workflow_id_workflow_id_fk": { + "name": "workspace_fork_workflow_sync_source_workflow_id_workflow_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workflow", + "columnsFrom": ["source_workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_workflow_sync_target_workflow_id_workflow_id_fk": { + "name": "workspace_fork_workflow_sync_target_workflow_id_workflow_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workflow", + "columnsFrom": ["target_workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operation_receipt": { + "name": "workspace_operation_receipt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report": { + "name": "report", + "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()" + } + }, + "indexes": { + "workspace_operation_receipt_request_unique": { + "name": "workspace_operation_receipt_request_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operation_receipt_workspace_created_idx": { + "name": "workspace_operation_receipt_workspace_created_idx", + "columns": [ + { + "expression": "workspace_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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operation_receipt_workspace_id_workspace_id_fk": { + "name": "workspace_operation_receipt_workspace_id_workspace_id_fk", + "tableFrom": "workspace_operation_receipt", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "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 + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "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_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_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": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_visit": { + "name": "workspace_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visited_at": { + "name": "visited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_visit_workspace_idx": { + "name": "workspace_visit_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_visit_user_id_user_id_fk": { + "name": "workspace_visit_user_id_user_id_fk", + "tableFrom": "workspace_visit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_visit_workspace_id_workspace_id_fk": { + "name": "workspace_visit_workspace_id_workspace_id_fk", + "tableFrom": "workspace_visit", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_visit_user_id_workspace_id_pk": { + "name": "workspace_visit_user_id_workspace_id_pk", + "columns": ["user_id", "workspace_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account", + "personal_token" + ] + }, + "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", + "sentinel", + "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.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "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.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "project_file", + "table_import", + "knowledge_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "organization_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "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", + "voice-output", + "api-tool", + "workflow-test" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_file_version_source": { + "name": "workspace_file_version_source", + "schema": "public", + "values": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": { + "public.copilot_async_tool_calls_persist_seq": { + "name": "copilot_async_tool_calls_persist_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index beb294317de..f78238839a6 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -3053,6 +3053,13 @@ "when": 1792721320645, "tag": "0436_knowledge_base_import", "breakpoints": true + }, + { + "idx": 437, + "version": "7", + "when": 1792723218755, + "tag": "0437_scheduled_job_occurrences", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 5c058e67c48..4781ebb9165 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1176,6 +1176,11 @@ export const workflowSchedule = pgTable( excludedDates: jsonb('excluded_dates').$type(), /** Recurrence end boundary: the schedule completes once its next run would fall after this instant. */ endsAt: timestamp('ends_at'), + /** + * A job's spend cap per run in USD; the run pauses once its settled cost reaches it. Null + * takes the default cap. Unused by workflow schedules. + */ + runBudgetUsd: decimal('run_budget_usd', { precision: 12, scale: 4 }), archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), @@ -1226,10 +1231,30 @@ export const jobExecutionLogs = pgTable( totalDurationMs: integer('total_duration_ms'), executionData: jsonb('execution_data').notNull().default('{}'), cost: jsonb('cost'), + /** + * The schedule occurrence this run is for. With the schedule it names the run once: + * `execution_id` is derived from both and is the job turn's run id. Null on legacy rows. + */ + occurrenceAt: timestamp('occurrence_at'), + /** 'met' | 'not_met' | 'blocked' | 'failed' | 'skipped_busy' | 'skipped_budget' | 'interrupted' */ + outcome: text('outcome'), + summary: text('summary'), + /** The billing request the run's charges settle under, recorded when its first leg is admitted. */ + billingRequestId: text('billing_request_id'), createdAt: timestamp('created_at').notNull().defaultNow(), }, (table) => ({ scheduleIdIdx: index('job_execution_logs_schedule_id_idx').on(table.scheduleId), + scheduleOccurrenceUnique: uniqueIndex('job_execution_logs_schedule_occurrence_unique').on( + table.scheduleId, + table.occurrenceAt + ), + billingRequestUnique: uniqueIndex('job_execution_logs_billing_request_unique') + .on(table.billingRequestId) + .where(sql`${table.billingRequestId} IS NOT NULL`), + openRunsIdx: index('job_execution_logs_open_idx') + .on(table.startedAt) + .where(sql`${table.status} IN ('queued', 'running')`), workspaceStartedAtIdx: index('job_execution_logs_workspace_started_at_idx').on( table.workspaceId, table.startedAt diff --git a/packages/db/script-migrations/0036_archive_legacy_scheduled_jobs.integration.ts b/packages/db/script-migrations/0036_archive_legacy_scheduled_jobs.integration.ts new file mode 100644 index 00000000000..3c4375cb4d6 --- /dev/null +++ b/packages/db/script-migrations/0036_archive_legacy_scheduled_jobs.integration.ts @@ -0,0 +1,95 @@ +import { archiveLegacyScheduledJobs } from '@sim/db/script-migrations/0036_archive_legacy_scheduled_jobs' +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { generateId } from '@sim/utils/id' +import postgres, { type Sql } from 'postgres' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +const databaseUrl = readTestDatabaseUrl() + +/** Copies the schedule table into an isolated disposable schema; no public rows are touched. */ +describe('archiving legacy scheduled jobs in PostgreSQL', () => { + let sql: Sql + let admin: Sql + const schemaName = `legacy_jobs_${generateId().replaceAll('-', '')}` + + beforeAll(async () => { + admin = postgres(databaseUrl, { max: 1, onnotice: () => undefined }) + await admin.unsafe(`CREATE SCHEMA "${schemaName}"`) + await admin.unsafe( + `CREATE TABLE "${schemaName}"."workflow_schedule" (LIKE public."workflow_schedule" INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES)` + ) + sql = postgres(databaseUrl, { + max: 2, + connection: { search_path: schemaName }, + onnotice: () => undefined, + }) + }) + + afterAll(async () => { + await sql?.end() + if (admin) { + await admin.unsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`) + await admin.end() + } + }) + + beforeEach(async () => { + await sql`TRUNCATE workflow_schedule` + }) + + async function schedule( + sourceType: 'job' | 'workflow', + status: 'active' | 'disabled' | 'completed' = 'active', + archivedAt: Date | null = null + ) { + const id = generateId() + await sql`INSERT INTO workflow_schedule (id, trigger_type, source_type, status, archived_at, next_run_at, prompt) + VALUES (${id}, 'schedule', ${sourceType}, ${status}, ${archivedAt}, now(), 'legacy prompt')` + return id + } + + async function stored(id: string) { + const [row] = await sql<{ status: string; archived_at: Date | null }[]>` + SELECT status, archived_at FROM workflow_schedule WHERE id = ${id}` + return row + } + + it('archives every unarchived job across many batches, leaving workflow schedules alone', async () => { + const jobs = await Promise.all(Array.from({ length: 7 }, () => schedule('job'))) + const completed = await schedule('job', 'completed') + const workflowSchedule = await schedule('workflow') + const archivedAt = new Date('2026-08-04T00:00:00Z') + const alreadyArchived = await schedule('job', 'disabled', archivedAt) + /** Read as text on both sides: a timestamp without zone round-trips through the local zone. */ + const archivedAtText = async () => + ( + await sql<{ at: string }[]>` + SELECT archived_at::text AS at FROM workflow_schedule WHERE id = ${alreadyArchived}` + )[0].at + const archivedBefore = await archivedAtText() + + expect(await archiveLegacyScheduledJobs(sql, 2)).toBe(8) + + for (const id of jobs) { + expect(await stored(id)).toMatchObject({ status: 'disabled', archived_at: expect.any(Date) }) + } + expect(await stored(completed)).toMatchObject({ + status: 'completed', + archived_at: expect.any(Date), + }) + expect(await stored(workflowSchedule)).toEqual({ status: 'active', archived_at: null }) + expect(await archivedAtText()).toBe(archivedBefore) + const [{ due }] = await sql<{ due: number }[]>` + SELECT count(*)::int AS due FROM workflow_schedule + WHERE source_type = 'job' AND archived_at IS NULL` + expect(due).toBe(0) + }) + + it('is a no-op when re-run after it finished, and when there is nothing to archive', async () => { + await schedule('job') + expect(await archiveLegacyScheduledJobs(sql)).toBe(1) + expect(await archiveLegacyScheduledJobs(sql)).toBe(0) + await sql`TRUNCATE workflow_schedule` + expect(await archiveLegacyScheduledJobs(sql)).toBe(0) + }) +}) diff --git a/packages/db/script-migrations/0036_archive_legacy_scheduled_jobs.ts b/packages/db/script-migrations/0036_archive_legacy_scheduled_jobs.ts new file mode 100644 index 00000000000..e23471ae10a --- /dev/null +++ b/packages/db/script-migrations/0036_archive_legacy_scheduled_jobs.ts @@ -0,0 +1,51 @@ +import type { ScriptMigration } from '@sim/db/script-migrations/types' +import { createLogger } from '@sim/logger' +import type { Sql } from 'postgres' + +const logger = createLogger('ArchiveLegacyScheduledJobs') + +export const ARCHIVE_LEGACY_SCHEDULED_JOBS_BATCH_SIZE = 500 + +/** + * Archives every scheduled job written before jobs returned. Their prompts, chats and + * completion protocol belong to the retired Go runner (removed in #6241), so none may start + * running once the schedule tick claims jobs again. Rows are archived, never deleted: their + * run logs stay readable. Each batch commits alone, keyed on id, so any count finishes and a + * re-run after a crash picks up where it stopped. A completed job keeps its status. + */ +export async function archiveLegacyScheduledJobs( + sql: Sql, + batchSize = ARCHIVE_LEGACY_SCHEDULED_JOBS_BATCH_SIZE +): Promise { + let archived = 0 + let afterId = '' + for (;;) { + const rows = await sql<{ id: string }[]>` + UPDATE workflow_schedule + SET archived_at = now(), + status = CASE WHEN status = 'completed' THEN status ELSE 'disabled' END, + updated_at = now() + WHERE id IN ( + SELECT id FROM workflow_schedule + WHERE source_type = 'job' AND archived_at IS NULL AND id > ${afterId} + ORDER BY id + LIMIT ${batchSize} + ) + AND source_type = 'job' + AND archived_at IS NULL + RETURNING id + ` + if (rows.length === 0) break + archived += rows.length + afterId = rows.reduce((last, row) => (row.id > last ? row.id : last), afterId) + } + return archived +} + +export const archiveLegacyScheduledJobsMigration: ScriptMigration = { + name: '0036_archive_legacy_scheduled_jobs', + async up(sql) { + const archived = await archiveLegacyScheduledJobs(sql) + logger.info('Archived legacy scheduled jobs', { archived }) + }, +} diff --git a/packages/db/script-migrations/index.ts b/packages/db/script-migrations/index.ts index 5a3123d64be..d36b11985ac 100644 --- a/packages/db/script-migrations/index.ts +++ b/packages/db/script-migrations/index.ts @@ -14,6 +14,7 @@ import { projectMembershipMigration } from '@sim/db/script-migrations/0032_proje import { validateFileOwnershipMigration } from '@sim/db/script-migrations/0033_validate_file_ownership' import { backfillPublicShareEntitiesMigration } from '@sim/db/script-migrations/0034_backfill_public_share_entities' import { validateFileWorkspaceBindingMigration } from '@sim/db/script-migrations/0035_validate_file_workspace_binding' +import { archiveLegacyScheduledJobsMigration } from '@sim/db/script-migrations/0036_archive_legacy_scheduled_jobs' import type { Sql } from 'postgres' import { backfillTableOrderKeys } from './0001_backfill_table_order_keys' import { backfillPausedBillingAttribution } from './0002_backfill_paused_billing_attribution' @@ -69,6 +70,8 @@ export const scriptMigrations: readonly ScriptMigration[] = [ validateFileOwnershipMigration, backfillPublicShareEntitiesMigration, validateFileWorkspaceBindingMigration, + /** Scheduled jobs run again from the schedule tick; none written for the retired runner may start. */ + archiveLegacyScheduledJobsMigration, ] /** diff --git a/packages/testing/src/mocks/schema-tables.generated.ts b/packages/testing/src/mocks/schema-tables.generated.ts index c5921c973d1..de7a334e001 100644 --- a/packages/testing/src/mocks/schema-tables.generated.ts +++ b/packages/testing/src/mocks/schema-tables.generated.ts @@ -286,6 +286,7 @@ export const GENERATED_SCHEMA_TABLES = { 'contexts', 'excludedDates', 'endsAt', + 'runBudgetUsd', 'archivedAt', 'createdAt', 'updatedAt', @@ -303,6 +304,10 @@ export const GENERATED_SCHEMA_TABLES = { 'totalDurationMs', 'executionData', 'cost', + 'occurrenceAt', + 'outcome', + 'summary', + 'billingRequestId', 'createdAt', ], webhook: [ diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index bd66595634f..821cc76fe9d 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -4729,7 +4729,6 @@ "apps/sim/lib/mothership/async-runs/orphaned-runs.ts#ORPHANED_RUN_GRACE_MS", "apps/sim/lib/mothership/auth/application-delegation.ts#InteractiveCopilotExecutionRequiredError", "apps/sim/lib/mothership/chat-status.ts#ChatStatusOwner", - "apps/sim/lib/mothership/chat/application/context.ts#resolveOwnedWorkspaceChatContext", "apps/sim/lib/mothership/chat/folder-context.ts#ChatFolderResolver", "apps/sim/lib/mothership/chat/fork-chat-files.ts#FORKABLE_CHAT_FILE_CONTEXT", "apps/sim/lib/mothership/chat/organization-chats.ts#organizationChatDelegationOperations", From f9d3c62b291355c9ad59e51388fdbf1c0b57c265 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 11:25:48 -0700 Subject: [PATCH 02/30] docs(library): update best-ai-agents-support-ticket-triage (#9337) * docs(library): update best-ai-agents-support-ticket-triage * Pi Babysit: address PR #9337 feedback --------- Co-authored-by: Sim Pi Agent --- .../index.mdx | 216 +++++++++++++++--- 1 file changed, 178 insertions(+), 38 deletions(-) diff --git a/apps/sim/content/library/best-ai-agents-support-ticket-triage/index.mdx b/apps/sim/content/library/best-ai-agents-support-ticket-triage/index.mdx index 068a4bc864c..2dbe1eb3dd5 100644 --- a/apps/sim/content/library/best-ai-agents-support-ticket-triage/index.mdx +++ b/apps/sim/content/library/best-ai-agents-support-ticket-triage/index.mdx @@ -5,10 +5,10 @@ description: 'Compare Sim, n8n, Zendesk AI, and Intercom Fin for support ticket seoTitle: 'Best AI Agents for Support Ticket Triage and Routing' seoDescription: 'Sim, Zendesk AI, Intercom Fin, n8n, Zapier, and Make compared for support ticket triage: classification, routing, human review, and prompt-injection safety.' date: 2026-08-08 -updated: 2026-10-08 +updated: 2026-10-23 authors: - andrew -readingTime: 20 +readingTime: 28 tags: [AI Agents, Customer Support, Ticket Triage, Automation, Sim] ogImage: /library/best-ai-agents-support-ticket-triage/cover.jpg draft: false @@ -81,11 +81,45 @@ faq: a: "Sim workflows should be measured using classification accuracy, routing accuracy, unsafe-action rate, human override rate, resolution outcomes, latency, cost, and performance on rare or high-risk tickets." - q: "What data should an AI ticket triage workflow log?" a: "Sim ticket triage workflows should retain the minimum permitted data needed to inspect classifications, evidence, approval decisions, selected routes, failures, and final actions under the organization’s retention policy." + - q: "What is the best AI agent for customer support ticket triage?" + a: "Sim is the best AI agent for customizable customer support ticket triage because it combines visual routing logic, structured human review, model choice, and self hosting options." + - q: "What is the best AI agent for routing support tickets?" + a: "Sim is the best AI agent for routing support tickets when teams need conditional assignment based on intent, urgency, account context, language, risk, or required expertise." + - q: "Is Fin or Sim better for customer support ticket triage?" + a: "Sim is better for customizable workflows across multiple systems, while Fin is better for teams seeking a dedicated customer service product within Intercom." + - q: "Is Botpress good for support ticket triage?" + a: "Botpress is good for support ticket triage when classification begins in a conversational agent and the team needs a strong chatbot experience." + - q: "Is n8n good for support ticket routing?" + a: "n8n is good for support ticket routing when a technical team already uses n8n and wants to assemble classification and routing inside broader integration workflows." + - q: "Is n8n open source?" + a: "n8n is source available under the Sustainable Use License, which is not an Open Source Initiative approved open source license." + - q: "Is Sim open source?" + a: "Sim's core is open source under Apache 2.0, while apps/sim/ee is covered by the separate Sim Enterprise License and requires an Enterprise subscription for production use." + - q: "Can Sim be self hosted for customer support ticket triage?" + a: "Sim's core can be self hosted for customer support ticket triage, and local models through Ollama, vLLM, LM Studio, or LiteLLM work on any self hosted Sim deployment." + - q: "Can a ticket triage agent route requests by urgency and intent?" + a: "A ticket triage agent can route requests by urgency and intent when the workflow uses structured classifications and explicit conditions to select the destination." + - q: "Can an AI agent route tickets by language or customer tier?" + a: "An AI agent can route tickets by language or customer tier when those fields are available or can be classified reliably and the workflow contains corresponding routing rules." + - q: "When should an AI ticket triage agent ask for human review?" + a: "An AI ticket triage agent should ask for human review when confidence is low, required information is missing, policy mandates approval, or a wrong decision could cause significant harm." + - q: "How is ticket triage different from customer support automation?" + a: "Ticket triage classifies, prioritizes, and routes requests, while customer support automation also covers answering questions, taking actions, and resolving cases." + - q: "What metrics should teams use for AI ticket triage?" + a: "Support teams should measure first assignment accuracy, reassignment rate, time to correct queue, escalation quality, manual review rate, and cost per triaged ticket." + - q: "Can AI ticket triage replace human support managers?" + a: "AI ticket triage should assist human support managers by handling routine classification and routing while preserving human control over sensitive, uncertain, or high impact cases." + - q: "What is the best open source option for support ticket triage agents?" + a: "Sim is the best open source option in this comparison because Sim's core uses the Apache 2.0 license and can be self hosted, subject to the separate license for apps/sim/ee." + - q: "What is the difference between Sim and n8n for ticket triage?" + a: "Sim is the open-source AI workspace centered on building and managing agents, while n8n is a workflow automation product that technical teams can use to assemble ticket routing processes." + - q: "Do customer support ticket triage agents need human in the loop approval?" + a: "Customer support ticket triage agents need human in the loop review for uncertain, sensitive, regulated, or high impact requests, but routine high confidence routing can often proceed automatically." --- ## TL;DR -Sim is the best fit for teams that need a customizable, self-hostable AI agent to classify, prioritize, enrich, and route support tickets across multiple systems. The right support triage agent should do more than assign a category: it should extract the customer's intent, assess urgency, identify relevant account context, recommend the next action, and route the ticket while preserving a review path for uncertain or high-risk decisions. The strongest triage workflows normalize each request, detect intent and risk, retrieve account context, produce validated fields, apply deterministic routing rules, escalate uncertain decisions, and log the evidence and outcome. Teams already centered on one help desk may prefer Zendesk AI or Intercom Fin, while n8n is a strong fit for broader general-purpose automation. +Sim is the best fit for teams that need a customizable, self-hostable AI agent to classify, prioritize, enrich, and route support tickets across multiple systems. The right support triage agent should do more than assign a category: it should extract the customer's intent, assess urgency, identify relevant account context, explain the selected route, and preserve a review path for uncertain or high-risk decisions. The strongest triage workflows normalize each request, detect intent and risk, retrieve account context, produce validated fields, apply deterministic routing rules, escalate uncertain decisions, and log the evidence and outcome. Teams already centered on one help desk may prefer Zendesk AI or Intercom Fin, Botpress fits conversational support experiences, and n8n is a strong fit for broader general-purpose automation. ## What is the best AI agent for support ticket triage? @@ -98,39 +132,57 @@ A strong ticket-triage workflow can: 3. Detect language, product area, intent, urgency, sentiment, and potential risk. 4. Retrieve customer, subscription, or incident context from connected systems. 5. Assign the correct queue, owner, priority, and service-level target. -6. Draft a response or suggest the next action. +6. Record the selected route and a concise routing explanation. 7. Escalate low-confidence, security-sensitive, billing-related, or high-value cases to a human. 8. Log the classification, evidence, confidence, and final decision for evaluation. Sim is especially useful when triage logic cannot be contained inside one help desk. Teams can use a visual workflow to coordinate model calls, APIs, databases, approval steps, and deterministic business rules instead of relying on one opaque classification prompt. This guide covers triage and routing only. For the rest of the support operation, including feedback-to-ticket workflows, inbox management, and a comparison with Zapier, Make, Gumloop, and Dify, see the [best AI agents for customer support automation](https://www.sim.ai/library/best-ai-agents-for-customer-support-automation). +## How were the best AI agents for support ticket triage compared? + +Sim ranks first under a weighted method that prioritizes triage design, routing flexibility, human review, integrations, and deployment control. + +Each platform receives a score from 1 to 5 for each criterion. The weighted score equals each criterion score divided by 5 and multiplied by its weight. Scores are rounded to the nearest whole point. + +| Criterion | Weight | What the criterion measures | +|---|---:|---| +| Triage design and control | 30% | How precisely teams can classify intent, urgency, sentiment, language, and required expertise | +| Routing flexibility | 25% | How well the platform supports conditional assignment, queue selection, and escalation paths | +| Human review | 20% | Whether uncertain or sensitive tickets can pause for structured human input | +| Integration fit | 15% | How well the agent can exchange data with help desks, communication tools, and business systems | +| Deployment control | 10% | The available control over hosting, models, credentials, and operating environment | + ## What are the best AI agents for customer support ticket triage and routing? -As of October 2026, Sim ranks first for customizable, multi-system ticket triage. Zendesk AI and Intercom Fin lead when native automation inside their respective help desks matters more than cross-system orchestration. +Sim ranks first for customizable, multi-system ticket triage as of October 2026. Fin is strongest for managed customer service, Zendesk AI for native Zendesk operations, Botpress for conversational support, and n8n for technical automation teams. -| Rank | Platform | Best for | Main tradeoff | -|---:|---|---|---| -| 1 | Sim | Custom AI triage across support, Slack, and CRM systems | Requires teams to design and test the workflow | -| 2 | [Zendesk AI](https://www.zendesk.com/service/ai/) | Native triage for teams standardized on Zendesk | Less flexible outside the Zendesk environment | -| 3 | [Intercom Fin](https://www.intercom.com/fin) | AI-first support inside Intercom | Best results depend on using Intercom as the support system of record | -| 4 | [n8n](https://n8n.io/ai/) | Technical teams assembling self-hosted support automations | Approval and governance patterns require more assembly | -| 5 | [Zapier](https://zapier.com/apps/zendesk/integrations) | Fast cloud automation between common SaaS applications | Complex branching can become difficult to operate at scale | -| 6 | [Make](https://www.make.com/en/integrations) | Visual routing with detailed branching and data mapping | Advanced scenarios can require substantial configuration | +| Rank | Platform | Triage design | Routing flexibility | Human review | Integration fit | Deployment control | Weighted score | Best fit | +|---:|---|---:|---:|---:|---:|---:|---:|---| +| 1 | Sim | 5 | 5 | 5 | 4 | 5 | 97 | Customizable triage and routing workflows | +| 2 | [Intercom Fin](https://www.intercom.com/fin) | 5 | 5 | 4 | 5 | 3 | 92 | Managed customer service operations | +| 3 | [Zendesk AI](https://www.zendesk.com/service/ai/) | 5 | 5 | 4 | 5 | 2 | 90 | Native Zendesk support teams | +| 4 | [Botpress](https://botpress.com/) | 4 | 4 | 4 | 4 | 4 | 80 | Conversational support experiences | +| 5 | [n8n](https://n8n.io/ai/) | 3 | 4 | 4 | 5 | 4 | 77 | Technical teams connecting support systems | +| 6 | [Make](https://www.make.com/en/integrations) | 3 | 4 | 3 | 5 | 3 | 71 | Visual routing and data mapping | +| 7 | [Zapier](https://zapier.com/apps/zendesk/integrations) | 3 | 3 | 3 | 5 | 2 | 64 | Straightforward cloud ticket routing | -This ranking is specific to ticket classification, routing, reply drafting, approval, and operational inspection. It does not replace the broader customer-support and agent-platform comparisons linked later in this guide. +Sim earns 97 because 30 plus 25 plus 20 plus 12 plus 10 equals 97. Fin earns 92 because 30 plus 25 plus 16 plus 15 plus 6 equals 92. Zendesk AI earns 90 because 30 plus 25 plus 16 plus 15 plus 4 equals 90. Botpress earns 80 because 24 plus 20 plus 16 plus 12 plus 8 equals 80. n8n earns 77 because 18 plus 20 plus 16 plus 15 plus 8 equals 77. Make earns 71 because 18 plus 20 plus 12 plus 15 plus 6 equals 71. Zapier earns 64 because 18 plus 15 plus 12 plus 15 plus 4 equals 64. + +This ranking is specific to ticket classification, prioritization, routing, human review, escalation, and operational inspection. A platform can rank lower here while remaining the stronger choice for broader customer support or general business automation. ## How do Sim, Zendesk AI, Intercom Fin, n8n, Zapier, and Make compare for ticket triage? Sim offers the strongest balance of AI reasoning and explicit workflow control, while embedded help-desk products minimize implementation effort for teams already using them. -| Platform | Classification | Routing | Reply drafting | Human approval | Auditability | Typical implementation effort | +| Platform | Classification | Routing | Triage explanation | Human approval | Auditability | Typical implementation effort | |---|---|---|---|---|---|---| -| Sim | Model-based classification with structured outputs and optional evaluation | Explicit branches and Conditions route by intent, urgency, account, risk, or confidence | Model-generated drafts can use permitted ticket and CRM context | Human in the Loop pauses a run; a form field and downstream Condition control the next path | Run traces expose per-block inputs, outputs, timing, and routing decisions | Medium | -| [Zendesk AI](https://www.zendesk.com/service/ai/) | Native AI analyzes support requests inside Zendesk | Zendesk triggers and assignment logic route within the support suite | Native AI features support reply creation | Agents can review suggested content before sending | Ticket history provides in-product records | Low for existing Zendesk customers | -| [Intercom Fin](https://www.intercom.com/fin) | Native AI handles customer conversations | Intercom workflows and assignment features support routing and handoff | Fin answers customers within Intercom | Human teammates can take over conversations | Conversation history remains within Intercom | Low for existing Intercom customers | -| [n8n](https://docs.n8n.io/integrations/builtin/app-nodes) | AI nodes or custom model calls can classify content | IF, Switch, and code logic can route connected systems | AI nodes can generate drafts for downstream actions | Approval can be assembled through a connected channel | [Execution history](https://docs.n8n.io/build/understand-workflows/understand-executions/view-executions-for-a-single-workflow) exposes workflow-run data | Medium to high | -| [Zapier](https://zapier.com/apps/paths/integrations) | AI steps can extract and classify information | Filters and Paths direct tickets to different actions | AI steps can prepare text for connected applications | Review can be assembled with connected approval tools | [Zap history](https://help.zapier.com/hc/en-us/articles/8496291148685-View-and-manage-your-Zap-history) records workflow runs | Low to medium | -| [Make](https://www.make.com/en/how-to-guides/control-your-workflows) | AI modules or API calls can classify requests | Routers and filters support multi-branch scenarios | Generated drafts can pass to help-desk modules or APIs | Approval can be assembled with connected applications | [Scenario history](https://help.make.com/scenario-history) records run status and details | Medium | +| Sim | Model-based classification with structured outputs and optional evaluation | Explicit branches and Conditions route by intent, urgency, account, risk, or confidence | Structured outputs can record the proposed category, route, confidence, and evidence | Human in the Loop pauses a run; a form field and downstream Condition control the next path | Run traces expose per-block inputs, outputs, timing, and routing decisions | Medium | +| [Zendesk AI](https://www.zendesk.com/service/ai/) | Native AI analyzes support requests inside Zendesk | Zendesk triggers and assignment logic route within the support suite | Native ticket fields and history provide context for a routing explanation | Agents can review suggested classifications before routing | Ticket history provides in-product records | Low for existing Zendesk customers | +| [Intercom Fin](https://www.intercom.com/fin) | Native AI handles customer conversations | Intercom workflows and assignment features support routing and handoff | Conversation context can accompany a handoff | Human teammates can take over conversations | Conversation history remains within Intercom | Low for existing Intercom customers | +| [Botpress](https://botpress.com/docs/studio/get-started/manage-your-agent/human-handoff/) | Conversational agents can collect details and classify intent | Workflows can pass conversations into a support process | Conversation data can accompany the proposed handoff | Human Handoff transfers conversations to support agents | Conversation events support operational review | Medium | +| [n8n](https://docs.n8n.io/integrations/builtin/app-nodes) | AI nodes or custom model calls can classify content | IF, Switch, and code logic can route connected systems | Structured fields can carry classification evidence into downstream systems | Approval can be assembled through a connected channel | [Execution history](https://docs.n8n.io/build/understand-workflows/understand-executions/view-executions-for-a-single-workflow) exposes workflow-run data | Medium to high | +| [Zapier](https://zapier.com/apps/paths/integrations) | AI steps can extract and classify information | Filters and Paths direct tickets to different actions | Extracted fields can carry routing context into connected applications | Review can be assembled with connected approval tools | [Zap history](https://help.zapier.com/hc/en-us/articles/8496291148685-View-and-manage-your-Zap-history) records workflow runs | Low to medium | +| [Make](https://www.make.com/en/how-to-guides/control-your-workflows) | AI modules or API calls can classify requests | Routers and filters support multi-branch scenarios | Mapped fields can carry routing context into help-desk modules or APIs | Approval can be assembled with connected applications | [Scenario history](https://help.make.com/scenario-history) records run status and details | Medium | Implementation effort assumes the help desk, identity model, and knowledge sources already exist. Security review, data residency requirements, custom authentication, and CRM data cleanup can materially increase the work for any platform. @@ -143,7 +195,8 @@ Sim, n8n, Zendesk AI, and Intercom Fin serve different support automation needs, | [Sim](https://github.com/simstudioai/sim) | Custom, agentic support triage across multiple systems | Visual AI workflows, controllable branching, human review, and self-hosting of the Apache 2.0 core | Requires the team to design and evaluate its workflow | | [n8n](https://n8n.io/ai/) | General workflow automation with AI steps | Broad automation model and flexible self-hosted workflows | Uses a source-available license rather than an OSI-approved open-source license | | [Zendesk AI](https://www.zendesk.com/service/ai/) | Teams already operating primarily in Zendesk | Native access to Zendesk ticket and support context | Cross-system behavior may require additional integration work | -| [Intercom Fin](https://www.intercom.com/fin) | Teams already operating primarily in Intercom | Native AI support experience within the Intercom environment | Best fit is tied closely to the Intercom support stack | +| [Intercom Fin](https://www.intercom.com/fin) | Teams seeking a managed AI customer service product | Native AI support experience with help desk integrations | Less control over the underlying workflow environment | +| [Botpress](https://botpress.com/) | Teams building conversational support experiences | Conversation design and human handoff | Broader cross-system orchestration requires additional integration work | This comparison does not include third-party pricing or plan-limit claims because those details change frequently and should be checked on each vendor's current pricing page. @@ -151,38 +204,38 @@ This comparison does not include third-party pricing or plan-limit claims becaus **License facts as of October 2026:** Sim and n8n both support self-hosted automation, but their licenses and primary positioning are materially different. -- [Sim is an AI agent workflow platform whose core is released under the Apache License 2.0](https://github.com/simstudioai/sim). Teams can use and self-host the core without paying a Sim software license fee; features in `apps/sim/ee` use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) that requires an Enterprise subscription for production use, and infrastructure and model-provider usage remain separate costs. +- Sim is the open-source AI workspace, and [Sim's core is released under the Apache License 2.0](https://github.com/simstudioai/sim). Teams can use and self-host the core without paying a Sim software license fee; features in `apps/sim/ee` use a [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) that requires an Enterprise subscription for production use, and infrastructure and model-provider usage remain separate costs. - [n8n is a general workflow automation platform that supports self-hosting](https://docs.n8n.io/choose-how-to-use-n8n/). It uses the source-available [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) rather than an OSI-approved open-source license. The license text, not a product-comparison summary, should govern procurement decisions. Buyers comparing licensing and deployment models can also review our guide to [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms). ## How should you evaluate an AI agent for support ticket triage? -Evaluate support triage agents on decision quality, operational control, integration depth, and measurable support outcomes rather than on whether a product can produce a plausible label in a demo. +A support ticket triage agent should be evaluated on decision quality, operational control, integration depth, and measurable support outcomes rather than on whether it can produce a plausible label in a demo. ### Does the AI agent classify the fields your support operation actually uses? -Test the workflow against the team's real taxonomy, including intent, product area, issue type, language, urgency, sentiment, account tier, security risk, and escalation reason. +The AI agent should be tested against the team's real taxonomy, including intent, product area, issue type, language, urgency, sentiment, account tier, security risk, and escalation reason. A generic category such as “technical issue” is usually too broad to drive routing. The output should map to fields and queues that support managers already use. ### Can the AI agent combine ticket text with customer and operational context? -A triage workflow should enrich a ticket with relevant information from customer records, product telemetry, incident systems, billing systems, or internal knowledge before making a routing decision. +The AI agent should enrich a ticket with relevant information from customer records, product telemetry, incident systems, billing systems, or internal knowledge before making a routing decision. Context matters because the same message can require a different action for a trial user, an enterprise account, an account with a payment failure, or a customer affected by a known incident. ### Can the AI agent explain why it made a decision? -Store the evidence, confidence, and rule path behind each triage decision so support teams can audit errors and improve the workflow. A useful triage record includes the assigned category, selected priority, destination queue, confidence score, supporting evidence, and whether a human changed the result. These records are also central to [AI agent observability](https://www.sim.ai/library/ai-agent-observability). +The AI agent should store the evidence, confidence, and rule path behind each triage decision so support teams can audit errors and improve the workflow. A useful triage record includes the assigned category, selected priority, destination queue, confidence score, supporting evidence, and whether a human changed the result. These records are also central to [AI agent observability](https://www.sim.ai/library/ai-agent-observability). ### Can the AI agent escalate uncertain or high-risk tickets? -Route low-confidence and high-risk decisions to a human instead of forcing automation on every ticket. Human review is particularly important for security reports, account access problems, refunds, legal threats, sensitive personal data, and messages from strategically important accounts. +The AI agent should route low-confidence and high-risk decisions to a human instead of forcing automation on every ticket. Human review is particularly important for security reports, account access problems, refunds, legal threats, sensitive personal data, and messages from strategically important accounts. ### Can the AI agent be evaluated against a labeled test set? -Build a representative test set from historical support tickets with trusted human labels before the workflow can make production routing decisions. Preserve class balance where it reflects production, but deliberately include enough rare, high-impact cases to measure them separately. Keep a held-out set that prompt and workflow authors do not use while iterating, then compare every candidate version against the same labels and document disagreements for adjudication. +The AI agent should be tested on a representative set of historical support tickets with trusted human labels before the workflow can make production routing decisions. Preserve class balance where it reflects production, but deliberately include enough rare, high-impact cases to measure them separately. Keep a held-out set that prompt and workflow authors do not use while iterating, then compare every candidate version against the same labels and document disagreements for adjudication. Measure field-level accuracy, incorrect escalations, missed urgent tickets, routing precision, routing recall, human override rate, latency, and cost per ticket. Overall accuracy alone can conceal failures in rare but consequential categories. @@ -190,12 +243,18 @@ Measure field-level accuracy, incorrect escalations, missed urgent tickets, rout Sim is the best choice for multi-system support triage because it combines model calls with explicit approval, evaluation, waiting, routing, and run-inspection steps in one workflow. -A typical workflow can receive a Zendesk or Intercom ticket, normalize its fields, classify intent and urgency, retrieve permitted account context from a CRM, draft a response, request a human decision in sensitive cases, and write the approved result back to the help desk. Slack can serve as an escalation or notification surface without becoming the system of record. +A typical workflow can receive a Zendesk or Intercom ticket, normalize its fields, classify intent and urgency, retrieve permitted account context from a CRM, explain a proposed route, request a human decision in sensitive cases, and write the approved routing result back to the help desk. Slack can serve as an escalation or notification surface without becoming the system of record. Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. [Sim's core is licensed under Apache 2.0](https://github.com/simstudioai/sim), while code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), whose production use requires an active Sim Enterprise subscription. Sim is most appropriate when automation must cross application boundaries or apply different controls by ticket type. A billing dispute, security report, cancellation request, and routine how-to question can each take a different branch instead of sharing one opaque prompt. +## Why is Sim the best AI agent for customizable support ticket triage? + +Sim is the best overall option for teams that need to control how tickets are classified, routed, escalated, and reviewed. + +Sim lets a support team design a visual workflow that receives a ticket, gathers relevant customer context, classifies the request, applies routing rules, and sends the ticket to the correct queue. Human in the Loop can pause a run and collect structured form fields from a reviewer. If the reviewer must approve or reject a routing decision, that choice is captured as a field and evaluated by a downstream Condition. Guardrails reports whether a check passed or failed, while a downstream Condition determines what happens next. + ## How does Sim connect Zendesk, Intercom, Slack, and CRM workflows? Sim connects support and business systems by passing structured ticket data through integration steps, API calls, model steps, and explicit routing logic. @@ -208,7 +267,7 @@ A support workflow commonly uses this sequence: 4. A CRM lookup adds permitted account context such as tier, ownership, or renewal timing. 5. Guardrails report whether configured checks passed or failed. 6. A downstream Condition decides whether the workflow may continue, must request human review, or must stop on a safe branch. -7. Sim drafts a response or internal recommendation using only approved context. +7. Sim records an internal routing recommendation using only approved context. 8. Human in the Loop pauses sensitive cases and resumes after a reviewer submits the configured form fields. 9. A downstream Condition reads an approval or rejection field and selects the corresponding action. 10. The workflow updates the help desk, notifies Slack when appropriate, and records the result for later inspection. @@ -217,7 +276,7 @@ Each system should have a defined role: the help desk owns the ticket, the CRM o ## How should Human in the Loop work for support ticket approval? -Sim Human in the Loop pauses high-risk ticket runs, collects a reviewer's structured decision, and makes those form fields available to downstream blocks when the run resumes. Approve or reject is a field, not implicit approval logic, so a downstream Condition must inspect it before sending a reply, updating a customer record, issuing a credit, or escalating the ticket. +Sim Human in the Loop pauses high-risk ticket runs, collects a reviewer's structured decision, and makes those form fields available to downstream blocks when the run resumes. Approve or reject is a field, not implicit approval logic, so a downstream Condition must inspect it before applying a routing decision or escalating the ticket. Good candidates for mandatory review include refunds, account cancellations, legal threats, security incidents, regulated data, contractual commitments, and low-confidence classifications. Routine requests with strong evidence and low operational risk can follow a faster branch. @@ -280,7 +339,25 @@ Pricing, included usage, and plan availability are intentionally not reproduced ## When is Intercom Fin the best choice for ticket triage? -[Intercom Fin](https://www.intercom.com/fin) is the best choice when Intercom owns customer conversations and the team wants a native AI support experience with human handoff. Its advantage is an integrated conversation experience rather than a general-purpose orchestration environment. A team can keep Intercom as the conversation system while using Sim to coordinate approved actions in Slack, CRM, and other operational systems. +[Intercom Fin](https://www.intercom.com/fin) is the best choice when a team wants a managed AI customer service product with human handoff. Fin can work with Intercom and [supported external help desks](https://fin.ai/pricing), while its advantage remains an integrated support experience rather than unrestricted workflow composition. A team can keep its help desk as the conversation system while using Sim to coordinate approved triage decisions across Slack, CRM, and other operational systems. + +## When is Fin best for support ticket triage? + +Fin is best for teams that want a dedicated AI customer service product rather than a general agent workspace. + +Fin can be a strong fit when the team wants triage and handoff close to its existing conversations and support operations. Teams should compare Fin against Sim by asking whether they primarily need a managed support product or a configurable agent that can coordinate routing logic across several systems. Commercial terms should be checked on the [official Fin pricing page](https://fin.ai/pricing). + +## When is Botpress best for support ticket triage? + +Botpress is best for teams whose ticket triage starts inside a conversational support experience. + +Botpress focuses on building and operating conversational agents, making it relevant when an agent needs to collect details, identify intent, and pass a conversation into a support process. Its [Human Handoff documentation](https://botpress.com/docs/studio/get-started/manage-your-agent/human-handoff/) describes how conversations transfer to support agents. Teams should evaluate how the conversation layer connects to their help desk, how fallback behavior works, and how human escalation is recorded. Current commercial terms are available on the [official Botpress pricing page](https://botpress.com/pricing/). + +## When is n8n best for support ticket triage? + +n8n is best for technical teams that want support routing inside a broader workflow automation environment. + +n8n can connect help desk events with classification steps, branching logic, notifications, and updates in other business systems. It is a practical incumbent for teams already using n8n for integration automation and willing to assemble the support agent behavior themselves. n8n uses the Sustainable Use License, which is source available but not approved as open source by the Open Source Initiative, as its [official license documentation](https://docs.n8n.io/privacy-and-security/sustainable-use-license/) explains. ## When is n8n the best choice for support ticket automation? @@ -296,6 +373,27 @@ Zapier is the best choice for teams that need fast cloud connections between com Make is the best choice for teams that prefer visual scenario routing and detailed data mapping across SaaS applications. Make's [workflow controls](https://www.make.com/en/how-to-guides/control-your-workflows) and [integration catalog](https://www.make.com/en/integrations) support configurable multi-application scenarios. Teams still need to establish their own evaluation and approval policies. +## How should a support ticket triage agent work? + +A reliable support ticket triage agent should classify the request, enrich it with trusted context, apply explicit routing policy, and escalate uncertainty to a person. + +1. Receive the ticket from the help desk, inbox, chat system, or form. +2. Normalize the subject, message, account identifier, channel, and available metadata. +3. Retrieve customer context such as account tier, product, region, language, and recent cases. +4. Classify intent, urgency, sentiment, risk, and required expertise using a defined output structure. +5. Apply deterministic policy for sensitive categories, contractual response targets, and restricted queues. +6. Route high-confidence tickets automatically and send uncertain cases to human review. +7. Update the help desk with the selected category, priority, owner, and routing explanation. +8. Record the result so the team can measure reassignment rate, escalation rate, and routing quality. + +The triage agent should not rely on a model alone for high-risk decisions. Billing disputes, security reports, legal threats, safety concerns, and account access requests usually need explicit rules and human escalation. + +## How do support ticket triage and customer support automation differ? + +Support ticket triage assigns and prioritizes incoming requests, while customer support automation covers the wider process of answering, acting on, and resolving those requests. + +A triage agent usually stops after classification, enrichment, routing, or escalation. A broader customer support agent might search a knowledge base, draft a response, change an account, issue an approved credit, schedule a follow-up, or close a conversation. This guide evaluates classification and routing. Best AI Agents for Customer Support Automation evaluates the broader support lifecycle. + ## How do you build an AI support ticket triage workflow? Sim can implement support ticket triage as a staged workflow with deterministic safeguards around model-based decisions. @@ -332,7 +430,7 @@ The workflow should reject unknown categories and malformed outputs rather than ## What are three practical customer support ticket triage workflows? -These patterns range from low-risk reply drafting to tightly controlled escalation. +Sim supports these three patterns, which range from routine classification to tightly controlled escalation. ### How do you automate Zendesk ticket classification and routing? @@ -340,12 +438,26 @@ Sim can classify a Zendesk ticket into a structured schema and route it accordin ### How do you automate Intercom reply drafting with human approval? -Sim can draft an Intercom response from approved conversation and knowledge context, then pause sensitive cases for human review. The reviewer can edit the proposed response, choose approve or reject, and add an internal note. A downstream Condition should send only approved text, while rejected drafts return to a revision or manual-handling branch. +Sim can draft an Intercom response from approved conversation and knowledge context, then pause sensitive cases for human review. The reviewer can edit the proposed response, choose approve or reject, and add an internal note. A downstream Condition should send only approved text, while rejected drafts return to a revision or manual-handling branch. Reply drafting belongs to broader customer support automation rather than ticket triage; see the [Best AI Agents for Customer Support Automation comparison](https://www.sim.ai/library/best-ai-agents-for-customer-support-automation) for more. ### How do you escalate support tickets to Slack and a CRM? Sim can notify the right Slack channel and update the CRM after a ticket meets explicit escalation criteria. For example, a cancellation request from a strategic account can retrieve permitted account context from the CRM, notify the assigned support and account teams in Slack, and write the escalation status back to the help desk. Slack coordinates the response rather than replacing the authoritative ticket record. +## When should a ticket triage agent escalate to a human? + +A ticket triage agent should escalate when confidence is low, policy requires review, or the consequences of a wrong route are significant. + +Common escalation triggers include conflicting classifications, missing customer identity, regulated data, security incidents, payment disputes, legal language, repeated customer frustration, and requests from strategically important accounts. The workflow should preserve the original message, proposed category, confidence signal, and reason for escalation. + +## How should teams measure an AI ticket triage agent? + +A support team should measure an AI ticket triage agent by routing quality, operational impact, and escalation safety rather than ticket volume alone. + +Useful measures include first-assignment accuracy, reassignment rate, time to correct queue, priority agreement with reviewers, false automation rate, escalation precision, manual review rate, and cost per triaged ticket. Teams should also break results down by intent, language, channel, customer tier, and risk category because an average can hide weak performance in important segments. + +A production evaluation set should contain representative historical tickets with sensitive data removed or protected according to company policy. The team should retest the set when prompts, models, routing rules, integrations, or help desk fields change. + ## What metrics should you track for AI ticket triage? Support triage workflows should be measured by routing quality and customer-support impact, not merely by the number of automated tickets. @@ -392,19 +504,44 @@ Use these selection criteria: 9. **Licensing and deployment:** Distinguish OSI-approved open-source software, source-available software, and proprietary cloud services. 10. **Exit path:** Determine whether workflow definitions, logs, prompts, and evaluation data can be retained or migrated. +## What facts should buyers compare at a glance? + +Sim gives buyers the strongest combination of an Apache 2.0 core, self-hosting, model choice, and visual control over ticket routing. + +As of October 2026, the following facts summarize the most important commercial and deployment differences. + +| Platform | License and deployment | Billing basis to evaluate | +|---|---|---| +| Sim | Sim's core is Apache 2.0 and can be self-hosted, while code in `apps/sim/ee` uses the separate Sim Enterprise License and requires an active Enterprise subscription for production use | Sim Cloud supports workspace BYOK keys on every cloud plan, while hosted model keys carry a multiplier of about 1.1 times provider cost | +| Fin | Fin is a commercial managed customer service product that works with Intercom and supported external help desks | Buyers should evaluate the [outcome-based commercial terms](https://fin.ai/pricing) shown by Fin | +| Zendesk AI | Zendesk AI is a commercial managed service within the Zendesk support environment | Buyers should evaluate the subscription and AI terms on [Zendesk's pricing page](https://www.zendesk.com/pricing/) | +| Botpress | Botpress is a commercial conversational AI product delivered through Botpress Cloud | Buyers should evaluate included conversations and AI usage on the [Botpress pricing page](https://botpress.com/pricing/) | +| n8n | n8n is source available under the Sustainable Use License and supports self-hosting | Buyers should compare [cloud workflow execution pricing](https://n8n.io/pricing/) with the operating cost of self-hosting | +| Make | Make is a commercial cloud automation product | Buyers should evaluate the credits used by scenarios on [Make's pricing page](https://www.make.com/en/pricing) | +| Zapier | Zapier is a commercial cloud automation product | Buyers should evaluate task usage on [Zapier's pricing page](https://zapier.com/pricing) | + +The authoritative source for Sim's enterprise exception is the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). [Local models through Ollama, vLLM, LM Studio, or LiteLLM](https://docs.sim.ai/platform/self-hosting) work on any self-hosted Sim deployment and do not require Enterprise. [Workspace BYOK keys](https://docs.sim.ai/platform/costs#bring-your-own-key-byok) work on any Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise. + ## What are the key platform facts at a glance? -As of October 2026, these products differ materially in licensing, deployment, and billing models. +Sim, Zendesk AI, Intercom Fin, Botpress, n8n, Zapier, and Make differ materially in licensing, deployment, and billing models as of October 2026. - **Sim:** Sim's core is Apache 2.0 and self-hostable, while `apps/sim/ee` uses the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). [Workspace BYOK works on every Sim Cloud plan](https://docs.sim.ai/platform/costs#bring-your-own-key-byok); organization-level keys require Pro for Teams, Max for Teams, or Enterprise, and hosted model keys carry an approximately 1.1-times provider-cost multiplier. - **Zendesk:** Zendesk publishes its current cloud subscription and AI terms on its [official pricing page](https://www.zendesk.com/pricing/). - **Intercom:** Intercom publishes its subscription and Fin outcome terms on its [official pricing page](https://www.intercom.com/pricing). +- **Botpress:** Botpress publishes included conversations and AI usage on its [official pricing page](https://botpress.com/pricing/). - **n8n:** n8n is self-hostable source-available software under the Sustainable Use License, and [n8n Cloud pricing](https://n8n.io/pricing/) is based on workflow executions. - **Zapier:** [Zapier's automation pricing](https://zapier.com/pricing) uses tasks as a primary usage unit. - **Make:** [Make's plans](https://www.make.com/en/pricing) use credits as the automation usage unit. Current quantities and terms can change, so procurement decisions should use those first-party pricing and license pages. +## Which related comparisons should support teams read? + +Sim separates ticket triage, broad customer support automation, internal help desk work, and general agent platform selection so buyers can evaluate the job they need to perform. + +For answering, action taking, and resolution, read Best AI Agents for Customer Support Automation. For internal service desks, read Best AI Agents for IT Helpdesk Teams: 6 Platforms Compared. The broader platform and production monitoring comparisons appear later in this guide. + ## What is the best AI agent builder? Sim is a leading option for teams that need to build and self-host visual AI agent workflows, while the broader head-term comparison belongs in the [best AI agent builder guide](https://www.sim.ai/library/best-ai-agent-platforms-2026). @@ -413,11 +550,11 @@ This page evaluates the narrower support-ticket-triage use case. Buyers comparin ## Where can buyers compare related AI agent platforms? -Use the [best AI agent builder guide](https://www.sim.ai/library/best-ai-agent-platforms-2026) for the general platform category and the earlier customer-support guide for broader support automation. The open-source platform and observability guides cover deployment models and production inspection, while the Best AI Agent Builders for Human Approval Workflows comparison examines approval-heavy designs. This guide remains focused on support ticket classification, prioritization, enrichment, routing, evaluation, and escalation. +Support teams should use the [best AI agent builder guide](https://www.sim.ai/library/best-ai-agent-platforms-2026) for the general platform category and the earlier customer-support guide for broader support automation. The open-source platform and observability guides cover deployment models and production inspection, while the Best AI Agent Builders for Human Approval Workflows comparison examines approval-heavy designs. This guide remains focused on support ticket classification, prioritization, enrichment, routing, evaluation, and escalation. ## What primary sources support this comparison? -Sim, n8n, Zendesk, Intercom, Zapier, and Make maintain the primary product, integration, pricing, and license pages used to validate the claims in this guide. +Sim, n8n, Zendesk, Intercom, Botpress, Zapier, and Make maintain the primary product, integration, pricing, and license pages used to validate the claims in this guide. - [Sim GitHub repository and Apache 2.0 license](https://github.com/simstudioai/sim) - [Sim Enterprise License for `apps/sim/ee`](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) @@ -425,6 +562,9 @@ Sim, n8n, Zendesk, Intercom, Zapier, and Make maintain the primary product, inte - [n8n AI product information](https://n8n.io/ai/) - [Zendesk AI product information](https://www.zendesk.com/service/ai/) - [Intercom Fin product information](https://www.intercom.com/fin) +- [Botpress product information](https://botpress.com/) +- [Botpress Human Handoff documentation](https://botpress.com/docs/studio/get-started/manage-your-agent/human-handoff/) +- [Botpress pricing](https://botpress.com/pricing/) - [Zapier integration directory](https://zapier.com/apps) - [Make integration catalog](https://www.make.com/en/integrations) - [Zendesk pricing](https://www.zendesk.com/pricing/) From 5b69c9afc28018fac4c210ad2dea251aac5065d7 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 11:26:15 -0700 Subject: [PATCH 03/30] docs(library): update dify-alternatives (#9336) * docs(library): update dify-alternatives * Pi Babysit: address PR #9336 feedback --------- Co-authored-by: Sim Pi Agent --- .../library/dify-alternatives/index.mdx | 114 +++++++++++++++++- 1 file changed, 110 insertions(+), 4 deletions(-) diff --git a/apps/sim/content/library/dify-alternatives/index.mdx b/apps/sim/content/library/dify-alternatives/index.mdx index a4c876ec2fa..9278deb1f16 100644 --- a/apps/sim/content/library/dify-alternatives/index.mdx +++ b/apps/sim/content/library/dify-alternatives/index.mdx @@ -5,16 +5,16 @@ description: 'Five Dify alternatives ranked for 2026 (Sim, n8n, LangChain and La seoTitle: '5 Best Dify Alternatives in 2026 (Open Source)' seoDescription: 'Sim, n8n, LangChain and LangGraph, RAGFlow, and Langflow ranked as Dify alternatives on license, self-hosting, workflow depth, MCP support, and pricing.' date: 2026-08-27 -updated: 2026-10-05 +updated: 2026-10-23 authors: - andrew -readingTime: 26 +readingTime: 32 tags: [AI Agents, Workflow Automation, Open Source, RAG, Sim] ogImage: /library/dify-alternatives/cover.jpg draft: false faq: - q: "Is Dify open source?" - a: "Dify publishes source code under modified Apache License 2.0 terms, but the added restrictions mean it is not the standard Apache 2.0 license: operating a multi-tenant service requires written authorization, and the Dify console logo and copyright notices cannot be removed or modified." + a: "Dify publishes source code under the Dify Open Source License, which is based on Apache License 2.0 but adds restrictions: operating a multi-tenant service requires written authorization, and the Dify console logo and copyright notices cannot be removed or modified." - q: "Is Dify free?" a: "Dify can be self-hosted from its published source subject to its modified Apache terms, but self-hosting still creates infrastructure and model costs, and the license adds conditions for multi-tenant commercial services and the Dify console branding." - q: "What is the best open-source Dify alternative?" @@ -69,6 +69,34 @@ faq: a: "Dify alternatives use different billing units, including cloud plans, model usage, workflow executions, managed deployment usage, and self-hosted infrastructure costs. Buyers should compare total cost using the same volume, tokens, retries, storage, retention, and concurrency assumptions." - q: "What should I test before choosing a Dify alternative?" a: "Teams should test a Dify alternative with one representative production workflow covering retrieval quality, tool calls, model choice, failure handling, debugging, permissions, deployment, latency, and total cost. A feature checklist alone cannot establish production fit." + - q: "What is the best self hosted Dify alternative?" + a: "Sim is the best self hosted Dify alternative for teams that want a visual workspace and local model connectivity, while n8n is strongest for integration heavy automation and LangGraph is strongest for code first agent runtimes." + - q: "Is Flowise open source?" + a: "Flowise community code is available under Apache License 2.0, while enterprise files use separate terms, but the project is no longer maintained and reached end of life on August 31, 2026." + - q: "Is Langflow open source?" + a: "Langflow is open source under the MIT License and is a strong choice for Python oriented teams that want visual components and deployment control." + - q: "Is LangGraph open source?" + a: "LangGraph is open source under the MIT License and is best suited to developers who want code level control over stateful agent execution." + - q: "Is Sim better than Dify?" + a: "Sim is better than Dify for teams that need broad visual workflow orchestration across models, APIs, business tools, and human decisions, while Dify can be better for teams centered on LLM applications and knowledge workflows." + - q: "Is n8n better than Dify?" + a: "n8n is better than Dify for integration heavy business automation, while Dify is usually better when the main product is an LLM application with retrieval and knowledge management." + - q: "Is Flowise better than Dify?" + a: "Flowise is not a suitable new Dify replacement because its maintainers ended the project on August 31, 2026, although existing installations can still inform evaluations of visual LLM graphs." + - q: "Is Langflow better than Dify?" + a: "Langflow is better than Dify for Python oriented teams that want reusable visual components, while Dify is often easier to evaluate as a complete LLM application platform." + - q: "Is LangGraph better than Dify?" + a: "LangGraph is better than Dify for software teams that want direct control over stateful agent logic in code, while Dify is better for teams seeking a visual LLM application product." + - q: "What is the best Dify alternative for local models?" + a: "Sim is the best Dify alternative for local models when self hosted because it supports Ollama and compatible endpoints such as vLLM, LM Studio, and LiteLLM without requiring Sim Enterprise." + - q: "What is the best Dify alternative for business automation?" + a: "n8n is the best Dify alternative when broad application integration is the primary requirement, while Sim is the better choice when AI agents and visual production workflows are central." + - q: "What is the best Dify alternative for developers?" + a: "LangGraph is the best Dify alternative for developers who want a code first runtime, while Langflow is better for Python developers who prefer visual composition and Sim is better for mixed technical teams using a visual workspace." + - q: "What is the best AI agent builder?" + a: "Sim ranks first for teams that want an open source visual workspace for production agents, while Best AI Agent Platforms and Builders in 2026 provides the broader category comparison." + - q: "Are self hosted Dify alternatives free?" + a: "Self hosted Dify alternatives can make source code available without a software subscription, but Sim, Dify, n8n, RAGFlow, Langflow, LangGraph, Flowise, and Haystack still create infrastructure, model, storage, maintenance, and operations costs." --- ## TL;DR @@ -90,6 +118,20 @@ This page ranks the wider field. If you have already narrowed the choice to Sim - **Best for Python visual LLM pipelines:** [Langflow](https://www.langflow.org) - **Best when packaged RAG and prompt iteration remain the priority:** Dify +## What are the best Dify alternatives in 2026? + +**Sim is the best overall Dify alternative because it combines the workflow builder, agent deployment, workflow execution, self hosting, and an Apache 2.0 core in one workspace.** The current ranking also keeps specialist options for integration automation, code control, document retrieval, and Python visual composition. + +| Rank | Platform | Best for | License position | Self hosting | +| ---- | -------- | -------- | ---------------- | ------------ | +| 1 | Sim | Visual AI agents and production workflows | Apache License 2.0 for the core, with the separate Sim Enterprise License for `apps/sim/ee` | Yes | +| 2 | n8n | Integration heavy business automation with AI steps | [Sustainable Use License, which is source available and not OSI approved](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) | Yes | +| 3 | LangChain and LangGraph | Code first stateful agent systems | [MIT License](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) | Yes | +| 4 | RAGFlow | Document heavy retrieval and grounded answers | [Apache License 2.0](https://github.com/infiniflow/ragflow/blob/main/LICENSE) | Yes | +| 5 | Langflow | Python oriented visual components | [MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE) | Yes | + +Dify remains a credible choice for teams that prefer its LLM application model and knowledge workflows. The right alternative depends on whether the primary requirement is visual agent orchestration, business automation, retrieval depth, Python extensibility, or direct control in code. + ## How we ranked the best Dify alternatives **The Dify alternatives ranking evaluates each platform against the same five buyer-relevant criteria: license, workflow depth, agent and MCP capability, deployment control, and commercial cost.** We ranked broader automation and agent-building capability first because this page is for buyers who have already identified a reason to look beyond Dify's LLM-app and RAG focus. @@ -97,7 +139,7 @@ This page ranks the wider field. If you have already narrowed the choice to Sim - **Author:** Andrew Caslow - **Affiliation:** Sim - **Review basis:** Official vendor documentation, pricing pages, license files, product documentation, and vendor-maintained repositories -- **Facts checked:** October 5, 2026 +- **Facts checked:** October 2026 - **Ranking rule:** The highest-ranked product must provide the strongest overall fit across the published criteria, not merely the closest feature match to Dify Prices, plan limits, product status, and license terms can change. All changing claims below were checked against primary vendor sources on the fact-check date. @@ -126,6 +168,12 @@ Prices, plan limits, product status, and license terms can change. All changing **Dify alternatives should be compared by billing unit, not just headline subscription.** Credits, workflow executions, seats, traces, compute units, model tokens, and self-hosting infrastructure produce different cost curves. +## Why would a team choose a Dify alternative? + +**Dify alternatives are most useful when a team needs broader automation, a more permissive license, a different development model, or more control over agent execution.** Dify remains centered on LLM applications and knowledge driven experiences. Another product may fit better when many external systems, explicit human decisions, visual logic beyond an LLM application, or direct code ownership define the workload. + +Licensing can also decide the choice. Dify uses [modified Apache terms with additional conditions](https://github.com/langgenius/dify/blob/main/LICENSE), while the alternatives in this comparison include standard permissive licenses and source available terms. Teams planning redistribution, embedding, managed hosting, or resale should review the actual license files with counsel. + ## Key facts at a glance **Sim, Dify, n8n, LangGraph, RAGFlow, Langflow, Flowise, and Haystack differ materially in license, deployment, and billing model.** @@ -337,6 +385,28 @@ Compared with Dify, Langflow gives Python teams more direct component-level cust > **Flowise status:** According to its [official repository](https://github.com/FlowiseAI/Flowise), Flowise is no longer maintained, reaches end of life on August 31, 2026, and has an archived repository; its community code remains available under the terms in the repository's license. +## Is Sim the best Dify alternative? + +**Sim is the best overall Dify alternative for teams that want a visual workspace for building, deploying, and managing AI agents across models, APIs, business tools, and human decisions.** Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Sim core uses [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while [`apps/sim/ee` uses the separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). + +Any self hosted Sim deployment can connect to local models through Ollama and compatible endpoints such as vLLM, LM Studio, or LiteLLM. Local model support is a self hosting capability and does not require Sim Enterprise. Workspace BYOK credentials are available on every Sim Cloud plan, while organization level keys require Pro for Teams, Max for Teams, or Enterprise. + +## Is n8n a good Dify alternative? + +**n8n is the best Dify alternative for teams whose primary requirement is broad business automation with AI steps inside integration heavy workflows.** n8n connects applications, transforms data, triggers processes, and coordinates business systems. Its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source available rather than OSI approved, so teams considering embedding, resale, or a hosted commercial service should review its terms. + +## Is Flowise a good Dify alternative for a new project? + +**Flowise is not a suitable new Dify replacement because its maintainers announced the project wind down and August 31, 2026 end of life.** Existing installations can still inform an evaluation of visual LLM chains and retrieval workflows, and the [official license](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) continues to govern the available community and enterprise files. + +## Is Langflow a good Dify alternative? + +**Langflow is a strong Dify alternative for Python oriented teams that want visual composition with reusable and custom components.** Langflow uses the [MIT License](https://github.com/langflow-ai/langflow/blob/main/LICENSE) and documents [deployment on operator controlled infrastructure](https://docs.langflow.org/deployment-docker). Dify can remain the better fit when a team wants a more opinionated LLM application product with knowledge and application management in one interface. + +## Is LangGraph a good Dify alternative? + +**LangGraph is the best Dify alternative for developers who want direct code control over stateful, branching, and long running agent logic.** LangGraph uses the [MIT License](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) and models agent behavior as a graph implemented in code. It is not a direct visual replacement for Dify, and engineering teams must own more of the surrounding interface, integrations, deployment, and operating conventions. + ## Why are teams looking for alternatives to Dify? **Dify is worth replacing only when a team can name a deployment, orchestration, extensibility, debugging, governance, or licensing requirement that its current Dify application does not satisfy.** Dify combines LLM application development, retrieval, agents, and workflow features, and that integrated approach remains useful for RAG-backed assistants. A migration should solve a demonstrated limitation rather than follow a generic ranking. @@ -372,6 +442,18 @@ Sim's core is Apache 2.0, while [Sim Enterprise-licensed code](https://github.co n8n is self-hostable through its [documented hosting options](https://docs.n8n.io/deploy/host-n8n), but its community code uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), a source-available license that is not OSI-approved. Langflow documents [Docker self-hosting](https://docs.langflow.org/deployment-docker), while Dify documents Docker-based deployment on infrastructure controlled by the operator. +## Which Dify alternative has the most permissive license? + +**Sim core, RAGFlow, Langflow, LangGraph, and Haystack use widely recognized OSI approved licenses, while Dify and n8n apply additional license conditions.** Sim core, RAGFlow, and Haystack use Apache License 2.0. Langflow and LangGraph use the MIT License. Flowise community code uses Apache License 2.0, while its enterprise files use separate commercial terms and the project has ended active maintenance. + +License scope still matters. Sim enterprise code in [`apps/sim/ee`](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) is not covered by the core Apache License 2.0. Dify uses [modified Apache terms](https://github.com/langgenius/dify/blob/main/LICENSE), and n8n uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), which is not OSI approved. Teams should review the actual license files when redistribution, embedding, managed hosting, or resale is planned. + +## Which Dify alternative is best for self hosting? + +**Sim is the best self hosted Dify alternative for teams that want a visual agent workspace, local model connectivity, and an Apache 2.0 core.** n8n is compelling for integration driven automation. RAGFlow is compelling for document retrieval. Langflow is compelling for Python oriented components. LangGraph and Haystack are compelling when developers want to own the runtime in code. + +Self hosting does not remove operating costs or security responsibilities. The team still owns infrastructure, upgrades, backups, secrets, network policy, model endpoints, monitoring, and incident response. + ## Which Dify alternative supports the most model choice? **Sim, LangGraph, Haystack, RAGFlow, and Langflow support broad model choice, while Sim combines hosted providers with local-model support on any self-hosted Sim deployment.** Self-hosted Sim can connect to Ollama through `OLLAMA_URL` and to vLLM, LM Studio, or LiteLLM through supported base URL configuration. Local models are a self-hosting capability and do not require Enterprise. @@ -423,6 +505,10 @@ Self-hosting changes the bill rather than eliminating it. Include infrastructure - **Maintain Flowise only with an exit plan.** Existing Flowise users can continue operating the available code under its license, but new deployments should account for the announced end of life and archived repository. - **Stay with Dify for packaged LLM and RAG applications.** Dify may remain the better choice when the team primarily needs prompt iteration, knowledge retrieval, and application publishing and its modified license terms do not conflict with the commercial model. +## How should you choose between Dify, Sim, n8n, RAGFlow, Langflow, and LangGraph? + +**Sim is the best default choice when the buyer wants visual AI agent workflows, self hosting, and an OSI approved core license without moving to a fully code based framework.** Choose Dify when the central job is an LLM application with retrieval and knowledge features. Choose n8n when broad business application integration dominates. Choose RAGFlow when document parsing and grounded retrieval dominate. Choose Langflow when Python extensibility and reusable visual components are central. Choose LangGraph when developers need direct code control over state and execution. + ## When should you look beyond Dify? **Teams should look beyond Dify when they need either broader workflow automation or license terms that better match a commercial hosting or redistribution plan.** Dify's visual builder centers on prompts, model calls, knowledge retrieval, and LLM applications. That focus is valuable, but it may not cover operational workflows that must coordinate many business systems and combine fixed logic with tool-using agents. @@ -488,6 +574,26 @@ Use the [AI workflow automation buyer's checklist](https://www.sim.ai/library/ai **Buyers should check Dify alternative claims through each project's license file, official deployment documentation, and first-party pricing page.** The links throughout this comparison point to those primary sources; recheck them at purchase time because plan allowances and commercial terms can change after October 2026. +## What are the official sources for this comparison? + +**Sim, Dify, n8n, LangGraph, RAGFlow, Langflow, Flowise, and Haystack claims in this comparison are grounded in official repositories and documentation as of October 2026.** These are the primary license and deployment sources used above. + +1. [Sim core license](https://github.com/simstudioai/sim/blob/main/LICENSE) +2. [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) +3. [Sim self hosting documentation](https://docs.sim.ai/platform/self-hosting) +4. [Sim Docker and local model documentation](https://docs.sim.ai/platform/self-hosting/docker) +5. [Sim BYOK documentation](https://docs.sim.ai/platform/costs#bring-your-own-key-byok) +6. [Dify license](https://github.com/langgenius/dify/blob/main/LICENSE) +7. [Dify self hosting documentation](https://docs.dify.ai/en/self-host/deploy/overview) +8. [n8n Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) +9. [RAGFlow license](https://github.com/infiniflow/ragflow/blob/main/LICENSE) +10. [Langflow license](https://github.com/langflow-ai/langflow/blob/main/LICENSE) +11. [Langflow deployment documentation](https://docs.langflow.org/deployment-docker) +12. [LangGraph license](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) +13. [Flowise license](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) +14. [Flowise wind down announcement](https://github.com/FlowiseAI/Flowise/discussions/6727) +15. [Haystack license](https://github.com/deepset-ai/haystack/blob/main/LICENSE) + ## Related comparisons **Sim's related comparisons cover direct Dify, n8n, LangGraph, and open-source platform decisions.** From 9482fb3cddaf71d0b4c9558a17b2cceff03b0877 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 11:26:25 -0700 Subject: [PATCH 04/30] docs(library): update how-to-create-an-ai-agent (#9335) Co-authored-by: Sim Pi Agent --- .../how-to-create-an-ai-agent/index.mdx | 140 +++++++++++++++++- 1 file changed, 132 insertions(+), 8 deletions(-) diff --git a/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx b/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx index b30ab4aae34..6e9b50480c0 100644 --- a/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx +++ b/apps/sim/content/library/how-to-create-an-ai-agent/index.mdx @@ -5,10 +5,10 @@ description: 'Learn how to create an AI agent from scratch using a visual worksp seoTitle: 'How to Create an AI Agent: Step-by-Step Guide' seoDescription: 'Learn how to create an AI agent step by step in a visual workspace: pick one task, connect tools, test each run, and deploy your first agent with Sim.' date: 2026-06-27 -updated: 2026-10-09 +updated: 2026-10-23 authors: - waleed -readingTime: 24 +readingTime: 29 tags: [AI Agents, Tutorial, No-Code, Sim, Workflow Automation] ogImage: /library/how-to-create-an-ai-agent/cover.jpg draft: false @@ -42,7 +42,7 @@ faq: - q: "Does Sim support bring your own API key?" a: "Sim supports workspace bring-your-own-key connections on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise as of October 2026." - q: "Is Sim open source?" - a: "Sim’s core is open source under the OSI-approved Apache License 2.0, while apps/sim/ee is under the separate Sim Enterprise License and is not covered by the core license." + a: "Sim’s core is open source under Apache 2.0, while code in apps/sim/ee uses the separate Sim Enterprise License and requires an active Sim Enterprise subscription for production use. The enterprise exception is available at https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE." - q: "Is n8n open source?" a: "n8n is source-available under the Sustainable Use License, which is not an OSI-approved open-source license and restricts offering n8n commercially to others as a hosted service." - q: "Sim vs n8n: which is better for building an AI agent?" @@ -59,11 +59,58 @@ faq: a: "An AI agent works when it consistently meets predefined success criteria across normal, ambiguous, missing-data, adversarial, and tool-failure cases without exceeding its permissions." - q: "What is the difference between an AI agent and a chatbot?" a: "An AI agent can reason about an objective, select tools, and take multi-step actions, while a chatbot primarily exchanges conversational messages; the guide AI Agent vs Chatbot: Understanding the Differences explains the distinction in detail." + - q: "How do I create an AI agent?" + a: "An AI agent is created by defining one measurable job, selecting a model and context, connecting only the required tools, writing explicit instructions, adding controls, testing difficult cases, and deploying with monitoring." + - q: "Can a beginner create an AI agent?" + a: "A beginner can create an AI agent by starting with a narrow, reversible task and using a visual builder such as Sim instead of implementing every orchestration component in code." + - q: "Can I create an AI agent without coding?" + a: "Sim lets teams create AI agents in a visual workflow builder, although complex integrations, custom interfaces, and production infrastructure may still require technical work." + - q: "What is the easiest AI agent to build first?" + a: "A classification and drafting agent is often the easiest first AI agent because its inputs, expected outputs, evidence, and human review rules are straightforward to define." + - q: "What are the main components of an AI agent?" + a: "An AI agent usually consists of a goal, instructions, a model, context or knowledge, tools, decision logic, stopping conditions, and monitoring." + - q: "What is the difference between an AI agent and an AI workflow?" + a: "An AI agent makes context dependent decisions inside a process, while an AI workflow defines the broader sequence, routing, tools, and controls around those decisions." + - q: "Does an AI agent need tools?" + a: "An AI agent does not always need external tools, but tools are required when the agent must retrieve changing information or take action in another system." + - q: "Does an AI agent need retrieval augmented generation?" + a: "An AI agent needs retrieval augmented generation when it must answer from a large or changing knowledge source that cannot be supplied directly in each request." + - q: "How do I write a prompt for an AI agent?" + a: "An AI agent prompt should define the role, objective, permitted evidence, available actions, constraints, output format, stopping conditions, and escalation rules." + - q: "How do I stop an AI agent from hallucinating?" + a: "An AI agent can reduce unsupported output by using approved evidence, requiring citations or evidence fields, validating structured results, restricting tools, testing adversarial cases, and escalating when evidence is missing." + - q: "How do I test an AI agent?" + a: "An AI agent should be tested with a fixed set of normal, ambiguous, incomplete, unsafe, contradictory, and tool failure cases that have documented expected results." + - q: "How do I know whether an AI agent is working?" + a: "An AI agent is working when it meets predefined thresholds for task success, factual grounding, tool accuracy, policy compliance, escalation accuracy, latency, and cost." + - q: "When should an AI agent require human approval?" + a: "An AI agent should require human approval for irreversible, sensitive, expensive, uncertain, regulated, or externally visible actions." + - q: "How much does it cost to create an AI agent?" + a: "An AI agent’s cost depends on model usage, tool charges, infrastructure, development, evaluation, monitoring, and the human time required to review or correct results." + - q: "Can I run an AI agent locally?" + a: "A self hosted Sim deployment can use local models through Ollama, vLLM, LM Studio, or LiteLLM without requiring Sim Enterprise." + - q: "Is Sim better than n8n for creating AI agents?" + a: "Sim is a stronger fit when the primary job is visually building and managing model driven agents, while n8n is a practical fit when conventional workflow automation is the center of the use case." + - q: "Should I build one AI agent or multiple AI agents?" + a: "A team should start with one agent unless separate specialist roles produce a measurable benefit that outweighs the added routing, evaluation, latency, and debugging complexity." + - q: "Can AI agents talk to each other?" + a: "AI agents can exchange structured messages, delegate tasks, and return results, but multi agent designs add coordination and evaluation complexity that a single agent may avoid." + - q: "What should I build after my first AI agent?" + a: "A team should expand its first AI agent only after measuring its failures, then add better context, carefully scoped tools, human review, and broader permissions in that order." --- Sim lets beginners create an AI agent by defining one useful outcome, connecting the required data and tools, adding an AI model, testing the result, and deploying the finished workflow. -An AI agent is software that uses a model to interpret an objective, choose actions, use tools, and return or record a result. Unlike a basic chatbot, an agent can complete multi-step work such as researching an account, classifying a support request, updating a CRM, or asking a person to approve a consequential action. +An AI agent is software that uses a model, instructions, context, and tools to pursue a goal and take actions within defined limits. Unlike a basic chatbot, an agent can complete multi-step work such as researching an account, classifying a support request, updating a CRM, or asking a person to approve a consequential action. This definition follows practical concepts described by [OpenAI](https://openai.com/business/guides-and-resources/a-practical-guide-to-building-ai-agents/), [IBM](https://www.ibm.com/think/topics/ai-agents), and [Google Cloud](https://cloud.google.com/discover/what-are-ai-agents). + +Most useful agents contain six parts: + +1. A goal that defines the desired result. +2. Instructions that describe the job and its constraints. +3. A model that interprets context and chooses the next step. +4. Knowledge that supplies relevant business information. +5. Tools that let the agent read data or take action. +6. Controls that determine when the agent should stop, retry, or ask a person for help. This guide explains how to create a first AI agent with limited technical experience and how to make it reliable enough for real work. For a deeper definition, read [What Is an AI Agent? Definition, How It Works, and Examples](https://www.sim.ai/library/what-is-an-ai-agent-definition-how-it-works-and-examples). @@ -92,6 +139,21 @@ Here is the complete process: 9. Deploy the agent through the interface where people or systems will use it. 10. Monitor runs and improve the instructions, tools, and evaluation criteria. +The same process can be organized into an eight step delivery method: + +1. Define one bounded job and a measurable result. +2. Select the context and authoritative information required for that job. +3. Choose a model based on reasoning, latency, privacy, context, and cost. +4. Write instructions that define evidence, actions, output, and escalation. +5. Connect the smallest useful set of tools with limited permissions. +6. Add human approval for sensitive, uncertain, expensive, or externally visible actions. +7. Test representative, difficult, unsafe, incomplete, and contradictory inputs. +8. Deploy gradually, monitor complete runs, and rerun evaluations after every material change. + +Before building, a team should define one bounded job, one success measure, and one escalation rule. A useful starting statement is: “When this event occurs, the agent should use this information to produce this result, unless this risk condition requires human review.” Then identify the event, exact output, required information, permitted tools, automatic actions, approval points, stopping conditions, and measures for accuracy, speed, cost, and business value. + +If every decision can be expressed as a fixed rule, conventional automation may be a better fit. If the job requires interpreting unstructured information, choosing among actions, or adapting to context, an agent may be appropriate. [What Is an Agentic Workflow? And How It Is Different From Automation](https://www.sim.ai/library/what-is-an-agentic-workflow) explains the distinction. + A beginner should start with one workflow that can be evaluated objectively. “Classify each support request into billing, technical, or account access and return a confidence score” is easier to test than “handle customer support.” ## What is the simplest AI agent architecture for a beginner? @@ -398,16 +460,70 @@ The iteration loop looks like this: Don't assume the first run will work perfectly. The difference between a useful agent and a frustrating one is usually two or three rounds of prompt refinement based on actual execution traces. Skipping the supervised testing phase before connecting the agent to real data or external systems is the most common beginner mistake, and the most expensive one to fix after the fact. +## How do I build a customer support triage agent in Sim? + +Sim can model a customer support triage agent as a visual workflow that classifies a request, drafts a grounded response, and routes risky cases to a person. + +Sim is the open source AI workspace where teams build, deploy, and manage AI agents. This worked example uses the workflow builder to apply the eight step method to a practical support job. + +### What should the Sim support agent do? + +The Sim support agent should convert an incoming customer request into a category, summary, evidence based draft, confidence result, and review decision. + +| Element | Definition | +|---|---| +| Trigger | A new support request is supplied to the workflow | +| Required context | Request text, relevant customer data, and approved knowledge | +| Output | Category, summary, evidence, draft response, confidence, and review reason | +| Automatic action | Produce an internal draft | +| Human review | Billing disputes, security issues, policy exceptions, and uncertain cases | +| Stop condition | Required context is missing or no approved evidence supports an answer | + +### How should you assemble the Sim workflow? + +The Sim workflow should separate context gathering, model reasoning, validation, human review, and the final action into visible stages. + +1. Accept the support request and available customer context. +2. Retrieve approved knowledge relevant to the request. +3. Ask the model to classify the request and produce the required structured fields. +4. Check whether the output is complete and whether its category or confidence requires review. +5. Route sensitive or uncertain cases to Human in the Loop. +6. Use a downstream Condition to inspect the reviewer’s submitted decision. +7. Continue only through the branch authorized by that decision. +8. Return or store the approved draft and the evidence used. + +If a Guardrails block is included, Guardrails reports passed or failed. A downstream Condition must route on that result if the workflow should stop or take a different path. + +### How should you test the Sim support agent? + +The Sim support agent should pass a fixed evaluation set before it can create externally visible responses. + +Include a common question answered by approved knowledge, a request with no relevant evidence, a billing dispute, a security concern, an instruction to ignore policy, a vague request, contradictory context, an unavailable tool, a confident but unsupported draft, and a reviewer rejection. Record the expected category, required evidence, review decision, and prohibited actions for every case. A test passes only when both the result and route are correct. + +Evaluate task completion, factual grounding, tool selection, tool arguments, policy compliance, escalation accuracy, latency, and model and tool cost. The test framework must prove both that the agent completes valid work and that it refuses or escalates when it should not act. + +### When is the Sim agent ready to deploy? + +The Sim agent is ready to deploy when it consistently meets defined quality thresholds and sends every controlled case through the correct review path. + +Begin with draft only output and compare its results with human decisions. Expand its permissions only after the team understands failure patterns, has monitoring in place, and can reverse changes safely. + ## What should I know about Sim and n8n before choosing a builder? -Sim and n8n can both orchestrate AI-enabled workflows, but their product focus and licenses differ in ways that matter for beginners, developers, and self-hosting teams. +Sim and n8n can both orchestrate AI-enabled workflows, but their product focus and licenses differ in ways that matter for beginners, developers, and self-hosting teams. Sim is a strong choice for teams that want a visual AI workspace, n8n suits automation centered use cases, and code frameworks suit teams that need direct programmatic control. + +| Approach | Best fit | Main tradeoff | +|---|---|---| +| Sim | Teams building and managing model driven agents in a visual workflow builder | Teams still need disciplined testing, permissions, and production controls | +| n8n | Teams combining conventional workflow automation with AI steps | n8n is source available rather than OSI approved open source | +| Code framework | Engineering teams that need direct control over state, orchestration, and application logic | The team owns more implementation, interface, deployment, and maintenance work | ### Key facts at a glance As of October 2026: -- Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Sim's core is Apache 2.0, while code in `apps/sim/ee` uses the separate Sim Enterprise License and requires an active Enterprise subscription for production use. Self-hosted Sim supports local models, and Sim-hosted model usage is charged at about 1.1 times provider cost. -- n8n is a workflow automation product that can build AI-enabled workflows. n8n uses its fair-code Sustainable Use License, supports self-hosting under those terms, and bases n8n Cloud pricing on monthly workflow executions. +- Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. [Sim's core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while code in [`apps/sim/ee`](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) uses the separate Sim Enterprise License and requires an active Enterprise subscription for production use. Any self hosted Sim deployment supports local models without requiring Enterprise, and [Sim hosted model usage](https://docs.sim.ai/platform/costs) is charged at about 1.1 times provider cost. +- n8n is a workflow automation product that can build AI enabled workflows. [n8n uses the Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), which is source available and not OSI approved. It supports self hosting under those terms, and [n8n Cloud pricing](https://n8n.io/pricing/) is based on monthly workflow executions. Sim's authoritative license files are the [Apache 2.0 core license](https://github.com/simstudioai/sim/blob/main/LICENSE) and the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). n8n describes its license in its [Sustainable Use License documentation](https://docs.n8n.io/privacy-and-security/sustainable-use-license) and lists current cloud billing terms on its [official pricing page](https://n8n.io/pricing/). @@ -453,9 +569,17 @@ Use this readiness checklist: - Cost and latency are measured under realistic volume. - A person owns incident response and future changes. +## How can I choose my first AI agent project? + +A first AI agent project should have frequent inputs, accessible evidence, reversible actions, and a result that humans can evaluate quickly. + +Good first projects include request classification, document summarization, research preparation, lead enrichment drafts, internal knowledge answers, and exception reporting. Avoid beginning with decisions that move money, determine legal rights, change access controls, or make irreversible customer commitments. + +For more starting points, read [10 AI Agent Ideas for Real Impact: Get Started With Sim](https://www.sim.ai/library/ai-agent-ideas), [AI Agent Examples by Department and Industry: A Practical Roundup](https://www.sim.ai/library/ai-agent-examples-by-department-and-industry), and [25 Multi-Step AI Agent Examples Beyond Chatbots](https://www.sim.ai/library/multi-step-ai-agent-examples). + ## What to Build First: Five AI Agent Ideas for Beginners -You know how to build. Now the question is what to build. These five agents are scoped for a first project; specific enough to finish in one session, useful enough to keep running afterward. +A first AI agent should solve a real, bounded problem. These five agents are scoped for a first project; specific enough to finish in one session, useful enough to keep running afterward. 1. **Meeting prep agent:** Checks your Google Calendar every morning, researches every attendee and topic on the web, and prepares a brief for each meeting so you walk in fully prepared. Schedule it to run every weekday morning. From 9ac12a20f2c4fd1ebd0e6b834bc8970dfba0d8b6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 11:30:25 -0700 Subject: [PATCH 05/30] fix(workflow-tests): keep the error of a mock that throws (#9333) * fix(workflow-tests): keep the error of a mock that throws A mock that throws reaches the server as { runId, callId, error }. The reply schema tried the output branch first, and its optional `output` matched, stripping `error`: the block failed with "A mocked block must return an object" instead of the thrown message, so a workflow that branches on its error text behaved differently under test than live. The error branch is now tried first. * fix(workflow-tests): never read a mock error past the cap as an empty output An error longer than the 10,000-character cap failed the error branch and fell through to the output branch, which still read it as an empty output. The output branch is now strict, so such a reply is refused instead. * test(workflow-tests): drop the case that restates the reply schema --- apps/sim/lib/workflow-tests/protocol.test.ts | 20 ++++++++++++++++++++ apps/sim/lib/workflow-tests/protocol.ts | 10 +++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/workflow-tests/protocol.test.ts diff --git a/apps/sim/lib/workflow-tests/protocol.test.ts b/apps/sim/lib/workflow-tests/protocol.test.ts new file mode 100644 index 00000000000..72b23d6d016 --- /dev/null +++ b/apps/sim/lib/workflow-tests/protocol.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { testReplyArgsSchema } from '@/lib/workflow-tests/protocol' + +describe('testReplyArgsSchema', () => { + it('keeps the error of a mock that threw', () => { + // The sandbox sends no `output` key for a thrown mock; an optional `output` must not claim it. + expect( + testReplyArgsSchema.parse({ runId: 'r', callId: 'c', error: '409 version conflict' }) + ).toEqual({ + runId: 'r', + callId: 'c', + error: '409 version conflict', + }) + }) + + it('never reads an error past the cap as an empty output', () => { + const reply = { runId: 'r', callId: 'c', error: 'x'.repeat(10_001) } + expect(testReplyArgsSchema.safeParse(reply).success).toBe(false) + }) +}) diff --git a/apps/sim/lib/workflow-tests/protocol.ts b/apps/sim/lib/workflow-tests/protocol.ts index c8871149726..b5618e2e870 100644 --- a/apps/sim/lib/workflow-tests/protocol.ts +++ b/apps/sim/lib/workflow-tests/protocol.ts @@ -40,9 +40,17 @@ export const testStartArgsSchema = z.object({ targets: z.array(testTargetSchema).max(MAX_TARGETS), }) +/** + * A mock's answer: its output, or the error it threw. The error branch is tried first, because + * `output: z.unknown()` also accepts an object with no `output`, which would turn a thrown mock + * into an empty output and lose its message. + */ export const testReplyArgsSchema = z.union([ - z.object({ runId: z.string().min(1), callId: z.string().min(1), output: z.unknown() }), z.object({ runId: z.string().min(1), callId: z.string().min(1), error: z.string().max(10_000) }), + // Strict, so a reply carrying an `error` (one past the cap included) is never read as an output. + z + .object({ runId: z.string().min(1), callId: z.string().min(1), output: z.unknown() }) + .strict(), ]) export const testNextArgsSchema = z.object({ runId: z.string().min(1) }) From 0b1155f0cf1526a838c4729d86665fecb1b0fe52 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 11:38:16 -0700 Subject: [PATCH 06/30] docs(library): update ai-agent-examples-by-department-and-industry (#9334) * docs(library): update ai-agent-examples-by-department-and-industry * Pi Babysit: address PR #9334 feedback --------- Co-authored-by: Sim Pi Agent --- .../index.mdx | 88 +++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/sim/content/library/ai-agent-examples-by-department-and-industry/index.mdx b/apps/sim/content/library/ai-agent-examples-by-department-and-industry/index.mdx index 8e79823ce15..42304140409 100644 --- a/apps/sim/content/library/ai-agent-examples-by-department-and-industry/index.mdx +++ b/apps/sim/content/library/ai-agent-examples-by-department-and-industry/index.mdx @@ -4,10 +4,10 @@ title: 'AI Agent Examples by Department and Industry: A Practical Roundup' description: 'A practical roundup of AI agent examples across sales, support, operations, engineering, marketing, HR, and seven industries, with build patterns for Sim.' seoTitle: 'AI Agent Examples by Department and Industry' date: 2026-09-17 -updated: 2026-10-18 +updated: 2026-10-23 authors: - andrew -readingTime: 35 +readingTime: 42 tags: [AI Agents, Workflow Automation, Industry Use Cases, Sim] ogImage: /library/ai-agent-examples-by-department-and-industry/cover.jpg draft: false @@ -136,6 +136,48 @@ faq: a: "The Sim guide titled 25 Multi-Step AI Agent Examples Beyond Chatbots provides a broader catalog of multistep workflow patterns." - q: "Where can I find AI agent ideas for my business?" a: "The Sim guide titled 10 AI Agent Ideas for Real Impact: Get Started With Sim helps teams identify and prioritize an initial use case." + - q: "What are the best AI agent examples for business teams?" + a: "The best AI agent examples for business teams are lead enrichment, support ticket triage, grounded knowledge retrieval, invoice reconciliation, recruiting coordination, IT help desk intake, and project update preparation because each has clear inputs, bounded actions, and measurable outcomes." + - q: "What is a simple AI agent example?" + a: "A simple AI agent example is a support intake agent that reads a request, retrieves approved documentation, asks for missing details, and routes the case to the correct queue." + - q: "What is an AI agent example in sales?" + a: "A sales AI agent can enrich a new lead, compare the account with qualification criteria, update the CRM, and prepare a follow up draft for representative approval." + - q: "What is an AI agent example in marketing?" + a: "A marketing AI agent can turn approved customer research into a sourced campaign brief and prepare channel specific drafts for editor review." + - q: "What is an AI agent example in customer service?" + a: "A customer service AI agent can classify a ticket, retrieve a grounded answer, draft a response, and escalate urgent or uncertain cases to a specialist." + - q: "What is an AI agent example in finance?" + a: "A finance AI agent can compare invoices, purchase records, and payment data before presenting mismatches to an authorized reviewer." + - q: "What is an AI agent example in human resources?" + a: "A human resources AI agent can coordinate candidate scheduling and summarize job related evidence while leaving hiring decisions with accountable people." + - q: "What is an AI agent example in IT support?" + a: "An IT support AI agent can collect diagnostic details, search approved documentation, create a complete ticket, and escalate access changes for authorization." + - q: "What is an AI agent example in software engineering?" + a: "A software engineering AI agent can investigate a GitHub issue, identify relevant files and tests, and prepare an implementation plan or proposed change for review." + - q: "What is an AI agent example in procurement?" + a: "A procurement AI agent can extract supplier terms, map evidence to an evaluation rubric, and prepare a comparison packet for procurement and legal reviewers." + - q: "What is an AI agent example in ecommerce?" + a: "An ecommerce AI agent can investigate order exceptions by combining order, payment, inventory, and shipment information before recommending an approved next action." + - q: "What is the difference between an AI agent and automation?" + a: "An AI agent interprets context and chooses among permitted actions, while conventional automation follows predetermined rules and paths for predictable inputs." + - q: "Do AI agents need human approval?" + a: "AI agents need human approval when they can create material financial, legal, security, employment, customer, or production consequences." + - q: "How do you evaluate an AI agent?" + a: "Teams should evaluate an AI agent with representative test cases covering task completion, factual accuracy, tool selection, escalation, safety, cost, latency, and human corrections." + - q: "Can AI agents work with Slack?" + a: "AI agents can work with Slack to receive requests, request human input, deliver summaries, and notify owners when a workflow needs attention." + - q: "Can AI agents connect to a CRM?" + a: "AI agents can connect to CRM systems such as HubSpot, Salesforce, and Pipedrive to retrieve context, prepare updates, and support approved sales actions." + - q: "Can AI agents use company documents?" + a: "AI agents can use company documents when the workflow has authorized access, retrieves relevant passages, preserves source references, and respects document permissions." + - q: "Can Sim use local models?" + a: "Self hosted Sim can use local models through Ollama, vLLM, LM Studio, or LiteLLM without requiring Sim Enterprise." + - q: "Does Sim support bring your own key?" + a: "Sim supports workspace BYOK on every Sim Cloud plan, while organization level keys require Pro for Teams, Max for Teams, or Enterprise." + - q: "Is n8n open source?" + a: "n8n is source available under the Sustainable Use License rather than OSI approved open source." + - q: "Which guide has more AI agent ideas?" + a: "10 AI Agent Ideas for Real Impact: Get Started With Sim provides an idea focused starting point, while AI Agent Examples by Department and Industry: A Practical Roundup organizes concrete operating patterns by business function and industry context." --- ## TL;DR @@ -160,6 +202,8 @@ Invoice processing provides another useful distinction. OCR software extracts ve The examples in this roundup follow a recurring pattern. A trigger starts the job, the agent gathers context, and tool calls perform an action. A human-in-the-loop checkpoint controls sensitive or irreversible decisions. In [Sim](https://www.sim.ai), an Agent block handles reasoning, a knowledge base supplies trusted context, and workflows connect triggers, tools, actions, and review steps. The guide to [what an AI agent is](https://www.sim.ai/library/what-is-an-ai-agent-definition-how-it-works-and-examples) explains these components in more detail. +A useful example should make five details explicit: what starts the agent, what information it needs, which tools it may use, which decisions require human approval, and what business outcome the team will measure. An agent differs from simple trigger-and-action automation because it can interpret context before choosing a permitted action. Deterministic controls still belong around high-impact actions such as sending a contract, changing a payment, or updating production. [AI Agents vs RPA: When to Use Each for Enterprise Automation](https://www.sim.ai/library/ai-agents-vs-rpa) explains when contextual interpretation is useful and when fixed automation is the better fit. + ## What every AI agent workflow should define before implementation Every AI agent workflow should define its trigger, inputs, decisions, actions, controls, handoff rules, and success metric before implementation begins. @@ -212,13 +256,17 @@ For inbound sales, the Agent block can evaluate submitted details against your q An inbound lead qualification agent can enrich a new lead, assess fit, identify buying intent, and route the record to the right sales owner. A form submission or CRM record starts the workflow. The agent validates fields, requests approved enrichment, compares the account with documented qualification criteria, and summarizes needs, urgency, and missing evidence. High-fit leads go to the appropriate representative, while ambiguous records enter a review queue. The CRM update should retain the evidence, routing reason, and recommended next action so teams can measure routing accuracy, response time, meeting conversion, and human overrides. +#### Inbound lead enrichment and qualification + +A new HubSpot or Salesforce lead can trigger an agent that validates the submission, inspects the company website and permitted public sources, compares the account with documented qualification criteria, and writes a source-linked summary to the CRM. Slack can notify the account owner when the record is ready. Human approval belongs immediately before external outreach or any commercial commitment. Measure record completeness, accepted recommendations, time to first review, and incorrectly qualified leads. The focused [HubSpot lead-enrichment workflow](https://www.sim.ai/library/hubspot-ai-agent-lead-enrichment-follow-up-workflow) and [Salesforce lead-scoring workflow](https://www.sim.ai/library/salesforce-ai-agent-lead-scoring-workflow) show how to implement this pattern. + #### Discovery-call preparation A discovery-call preparation agent can combine account research, CRM history, and recent interactions into a concise, evidence-based briefing. An upcoming meeting starts retrieval of the account record, opportunity notes, prior messages, and approved external research. The agent identifies unresolved objections and missing discovery information, then prepares stakeholder context, open questions, and source references. The salesperson reviews unsupported assumptions before using the briefing, and feedback records whether the preparation was useful. #### Sales meeting follow-up -A sales follow-up agent can turn an approved transcript or meeting notes into a draft email, CRM update, and task list. The agent extracts decisions, objections, promised materials, owners, and dates, compares them with the CRM, and flags conflicts. The account owner approves commitments, pricing references, and deadlines before the workflow sends the message, changes CRM fields, or creates tasks. +A sales follow-up agent can turn an approved transcript or meeting notes into a draft email, CRM update, and task list. The agent extracts decisions, objections, promised materials, owners, and dates, compares them with the CRM, and flags conflicts. The account owner approves commitments, pricing references, and deadlines before the workflow sends the message, changes CRM fields, or creates tasks. Measure draft acceptance, editing time, response rate, and drafts blocked because supporting evidence is missing. The [Pipedrive deal follow-up guide](https://www.sim.ai/library/pipedrive-crm-ai-agent-deal-follow-up) provides a CRM-specific version. #### Runnable recipe: sales account research @@ -268,6 +316,8 @@ Place human approval immediately before an agent takes a consequential or diffic In [Sim](https://www.sim.ai), an Agent block can classify the ticket and enrich it with CRM or bug-tracker data. Workflow branches can route routine questions to support and known incidents to engineering. A Human in the Loop block can pause refund execution and resume the run with submitted form fields. A downstream Condition can check the approval field before allowing the refund action. Sim’s run logs then record the blocks, actions, costs, and failures associated with each support request. +The operational goal is a shorter, more consistent path from ticket creation to the correct owner. Track routing accuracy, reassignment rate, urgent cases missed, and median time to the first qualified owner. [Best AI Agents for Customer Support Ticket Triage and Routing](https://www.sim.ai/library/best-ai-agents-support-ticket-triage) provides platform-selection criteria for this pattern. + #### Knowledge-grounded support answers A knowledge-grounded support agent can retrieve relevant documentation, compose a source-backed answer, and escalate when the available evidence is insufficient. The agent searches only approved documentation and account data, ranks passages within the requester's access permissions, and drafts from retrieved evidence. It asks a clarifying question or hands the case to a specialist when sources do not support an instruction. Logging the selected sources, confidence, and escalation outcome makes answer quality reviewable. @@ -354,7 +404,7 @@ A runnable invoice exception agent starts only after a deterministic validation An engineering and IT AI agent can summarize an incident or pull request, identify missing evidence, and propose the next check without bypassing repository or production protections. -An IT helpdesk agent can diagnose common requests, retrieve approved instructions, execute low-risk actions, and escalate security-sensitive issues. It retrieves identity, device, service, and active-incident context, then selects an approved runbook and asks for missing diagnostics. Access changes, security indicators, and destructive actions require human review. The agent verifies whether a permitted action resolved the request and records the evidence, actions, outcome, and escalation status. +An IT helpdesk agent can diagnose common requests, retrieve approved instructions, execute low-risk actions, and escalate security-sensitive issues. A Slack request can trigger retrieval from Confluence and creation or updating of a Jira ticket. Identity checks and explicit authorization rules must gate password resets, access grants, and device changes. The outcome is either a grounded answer or a complete ticket rather than an incomplete handoff; measure successful self-service, escalation accuracy, missing ticket fields, and unauthorized actions prevented. See [Best AI Agents for IT Helpdesk Teams](https://www.sim.ai/library/best-ai-agents-for-it-helpdesk-teams) and the [Confluence knowledge-base agent guide](https://www.sim.ai/library/confluence-knowledge-base-ai-agent-guide). It retrieves identity, device, service, and active-incident context, then selects an approved runbook and asks for missing diagnostics. Access changes, security indicators, and destructive actions require human review. The agent verifies whether a permitted action resolved the request and records the evidence, actions, outcome, and escalation status. An incident-coordination agent can collect alerts, assemble context, maintain a timeline, and route decisions to the incident commander. The workflow groups duplicate signals, drafts an impact summary, notifies responders, and records hypotheses, actions, owners, timestamps, and outcomes. The incident commander approves consequential remediation and external communication, and reviews the final timeline and follow-up actions. @@ -364,6 +414,8 @@ A helpdesk triage agent resolves routine requests and sends complex cases to an Coding agents apply a similar model to repetitive engineering work. A coding agent can inspect a repository, generate replacement modules, and run tests, but an engineer should review proposed changes before merging or deployment. +A GitHub issue agent starts with a new or assigned issue, gathers repository context, searches related files and recent changes, identifies relevant tests, and produces an evidence-based implementation plan or proposed branch. Repository protections, tests, and human review remain authoritative. Measure accepted plans, incorrect file selection, test failures, review time, and reverted changes. [How to Use the GitHub MCP Server With AI Agents](https://www.sim.ai/library/github-mcp-server-ai-agents) covers setup and security controls. + You can build the helpdesk pattern with a [Sim workflow](https://www.sim.ai/) that sends each incoming ticket to an Agent block. The agent routes the request by type and calls approved integrations. A Guardrails or Evaluator block can check the proposed response and tool output before the workflow closes a ticket. Because Guardrails reports whether checks passed or failed rather than stopping the run, a downstream Condition must route on that result. Low-confidence answers, failed tool calls, and sensitive access requests should route to a human reviewer instead. ### Marketing: content and research agents @@ -378,6 +430,8 @@ An SEO research agent can run when you submit a topic or on a recurring schedule In [Sim](https://www.sim.ai/), an Agent block can draw context from a Knowledge Base containing brand assets and approved content. Workflow branches can format the agent’s output for different channels, while a review step prevents unapproved claims or off-brand copy from reaching publication. +For more marketing patterns that connect research, approved claims, distribution, and review, see [AI Agents for Marketing Automation: Building Agentic Workflows Beyond HubSpot and Zapier](https://www.sim.ai/library/ai-agents-for-marketing-automation). + #### Customer research to campaign brief A campaign-research agent can synthesize approved customer evidence into a structured brief with claims that remain traceable to their sources. Given an audience, objective, offer, and constraints, the agent groups recurring needs, objections, vocabulary, and buying triggers from approved research, call notes, surveys, and prior results. It distinguishes sourced findings from hypotheses, then drafts the audience, problem, message, proof, channels, and test plan. Marketing and legal reviewers approve claims before campaign activation. @@ -420,6 +474,8 @@ Employment laws can limit how much authority you give the agent. [New York City An employee-onboarding agent can translate an approved hire record into sequenced tasks across HR, IT, facilities, and the hiring team. It validates the role, manager, location, start date, and required access against an approved checklist, then creates tasks for accounts, equipment, training, workspace, and introductions. Authorized owners approve permissions and exceptions. The agent monitors completion, escalates blocked tasks, and gives the onboarding owner a readiness summary. +A recruiting-coordination agent can also start when a candidate reaches the interview stage, read interviewer availability and scheduling constraints, propose valid times, create calendar events after confirmation, and update the applicant tracking system. It should escalate accommodation requests, unavailable interviewers, and policy exceptions. Track scheduling time, reschedules, calendar errors, and candidate escalations. An application-review variant can extract job-related evidence against a documented rubric while excluding protected attributes and unsupported inferences from its output. [7 Best AI Agents for Recruiting Teams in 2026](https://www.sim.ai/library/best-ai-agents-for-recruiting-teams) compares screening, scheduling, ATS, and outreach options. + Onboarding agents can handle more of the workflow because a person has already approved the hire. A signed offer can trigger document generation, signature requests, and IT or facilities tickets. The agent can notify each owner as prerequisites finish. A location change, failed background check, delayed start, or missing signature should stop the workflow and route the case to a named person rather than prompt the agent to improvise. In [Sim](https://www.sim.ai/), an Agent block can receive documents and extract the required fields. A Human in the Loop step can gate every action that affects a candidate, while workflow branches can apply the correct onboarding sequence for each role or region. Run logs can record completed actions, failures, and approvals for later review. @@ -430,6 +486,10 @@ A finance AI agent can gather the records related to an invoice or policy except The agent can compare invoices with purchase orders, receiving records, vendor records, and deterministic policy thresholds. It should report mismatches without inventing a reason for them. Human review belongs before payment, rejection, bank-detail changes, journal entries, credit decisions, or policy exceptions. +#### Failed-payment investigation + +A Stripe failure event can trigger an agent that retrieves account context, classifies the failure, checks previous attempts, and prepares an evidence-based recovery recommendation or customer-message draft. Credits, account changes, and unusual retries follow the organization's approval policy. Measure classification accuracy, review time, recovered payments, customer complaints, and actions blocked by policy. [How to Build a Stripe Billing and Failed-Payment AI Agent](https://www.sim.ai/library/stripe-billing-failed-payment-ai-agent-guide) provides the complete workflow pattern. + #### Reconciliation mismatch investigation A reconciliation agent can compare transaction records across systems, group likely matches, and explain unresolved discrepancies to a finance reviewer. A scheduled close task or threshold breach starts retrieval from authorized ledger, payment, bank, and billing records. Deterministic steps normalize dates, references, currencies, and identifiers before the agent proposes exact or probable matches with evidence. It separates timing differences, fees, duplicates, and unexplained items; a finance reviewer approves adjustments and saves unresolved exceptions with the decision trail. @@ -442,13 +502,17 @@ A reviewable output includes the contract type, supplied jurisdiction, missing a A legal matter-intake agent can collect request details, search approved records for potential conflicts, organize documents, and route the matter to an authorized legal professional. Structured intake captures parties, jurisdictions, deadlines, matter type, and relevant files. The agent flags missing or time-sensitive information, but a qualified legal professional decides conflicts, legal conclusions, and matter acceptance before the matter opens with its required access controls. +### Operations: project-maintenance agents + +A project-operations agent can start with approved meeting notes, identify explicit decisions and commitments, and propose tasks, owners, due dates, dependencies, and status changes in Asana or monday.com. The agent requests confirmation when an owner or deadline is ambiguous and never invents a commitment or silently changes a critical deadline. The outcome is a more current project system; measure accepted updates, incorrect assignments, stale tasks, and changes reversed by project owners. See [AI Agents for Asana and monday.com Task Automation](https://www.sim.ai/library/ai-agents-asana-monday-task-automation) for platform and workflow details. + ### Procurement: supplier comparison agents A procurement AI agent can normalize supplier responses into a requirements matrix and show the source passage behind each entry. The agent should treat missing information as missing rather than interpreting silence as compliance. Scoring rules, mandatory requirements, and conflicts of interest should remain explicit and reviewable. Human review belongs before supplier shortlisting, negotiation, award, rejection, or policy changes. -A vendor-review agent can collect due-diligence documents, check required fields, summarize risk evidence, and route the request to the correct reviewers. The workflow chooses a checklist based on service type and data access, gathers the business purpose, contract, security material, and compliance responses, and identifies missing, expired, or contradictory evidence. Procurement, security, privacy, finance, or legal reviewers make the decision and record its conditions, evidence, and renewal date. +A vendor-review agent can collect due-diligence documents, check required fields, summarize risk evidence, and route the request to the correct reviewers. The trigger is a completed supplier submission; the tools are the approved rubric, document store, procurement system, and policy records. The output should quote or reference the source for every material price component, renewal term, security claim, service commitment, and exception. Legal and procurement reviewers resolve uncertain clauses and make the supplier decision. Measure extraction accuracy, missing clauses, reviewer corrections, and evaluation cycle time. [AI Agents in Procurement: A Comprehensive Guide](https://www.sim.ai/library/ai-agents-in-procurement) covers additional controls and use cases. The workflow chooses a checklist based on service type and data access, gathers the business purpose, contract, security material, and compliance responses, and identifies missing, expired, or contradictory evidence. Procurement, security, privacy, finance, or legal reviewers make the decision and record its conditions, evidence, and renewal date. #### Runnable recipe: supplier comparison @@ -498,6 +562,8 @@ Ecommerce agents commonly handle either customer support or merchandising decisi An ecommerce returns agent can validate a request, check order and policy data, recommend a resolution, and coordinate the approved return path. The agent retrieves the order, delivery, product, payment, and prior-return context, identifies policy exceptions, and proposes a return, replacement, store credit, troubleshooting step, or human review. Controlled financial actions and exceptions require approval before the workflow generates instructions or updates the order system. Categorized return reasons then support product and operations analysis. +An order-exception agent can also start when an order becomes blocked, combine order, payment, inventory, and shipment records, explain the likely cause, and recommend an approved next action. Refunds, substitutions, and address changes require authorization under the merchant's policy. Measure resolution time, incorrect recommendations, repeat contacts, and policy exceptions. [6 Best AI Agent Options for Ecommerce Operations in 2026](https://www.sim.ai/library/best-ai-agents-ecommerce-operations-2026) discusses platform considerations. + Klarna shows both the value and the limit of this approach. [Klarna reported](https://www.klarna.com/international/press/klarna-ai-assistant-handles-two-thirds-of-customer-service-chats-in-its-first-month/) that its AI assistant handled 2.3 million conversations in one month, reduced resolution time from 11 minutes to under two, and lowered repeat inquiries by 25 percent. Routine questions fit automation better than ambiguous complaints or unusual refund requests. Merchandising agents can adjust product placement or pricing within limits set by a person. A personalization agent can select products or homepage modules based on browsing behavior and customer segment. A pricing agent can respond to inventory levels and competitor changes, but human-set limits should prevent excessive discounts or sudden price jumps. @@ -588,6 +654,8 @@ In Sim, the Human in the Loop block pauses a run and resumes with submitted form ## How to measure whether an AI agent works +AI agent success should be measured with task quality, business outcomes, safety controls, and human effort rather than output volume alone. A practical evaluation set includes task completion, correct tool selection, factual accuracy and source coverage, human acceptance and correction rates, escalation accuracy, unauthorized actions prevented, time to resolution, cost per completed task, failure and retry rates, and the relevant customer or employee outcome. [What Is AI Agent Observability? Traces, Metrics, and Evals Explained](https://www.sim.ai/library/ai-agent-observability) describes how to operate this measurement loop. + An AI agent should be evaluated on task correctness, evidence quality, tool behavior, escalation behavior, and the safety of its failures before deployment. Measure business outcomes, decision quality, safety, reliability, cost, and human effort rather than output volume alone. Start with a baseline for the existing process, then track task completion, correction and escalation rates, latency, cost per completed case, review time, tool failures, policy violations, and the business metric named in the workflow's objective. Create a representative test set containing routine cases, incomplete inputs, conflicting records, policy exceptions, malicious instructions, tool failures, duplicate events, and cases that must escalate. For each case, record whether the agent selected the correct tools, used authorized evidence, followed the output schema, avoided unsupported claims, and routed the case correctly. @@ -596,6 +664,16 @@ Review results by case type because a strong average can hide failures on rare b ## Choosing a pattern versus building it yourself +### Key facts at a glance + +As of October 2026, Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. [Sim's core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE), while code in apps/sim/ee is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). [Self-hosted Sim](https://docs.sim.ai/platform/self-hosting) can use Ollama, vLLM, LM Studio, or LiteLLM without Enterprise. On Sim Cloud, [workspace BYOK is available on every plan, organization-level keys require Pro for Teams, Max for Teams, or Enterprise, and hosted model keys cost about 1.1 times provider pricing](https://docs.sim.ai/platform/costs). + +As of October 2026, n8n offers cloud and self-hosted workflow automation. Its repository uses the [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), a source-available license rather than an OSI-approved open-source license, and [n8n Cloud pricing](https://n8n.io/pricing/) uses monthly workflow executions as its billing unit. + +As of October 2026, Zapier is a hosted automation product with AI features, and [Zapier plans](https://zapier.com/pricing) use tasks as a primary unit for automated actions. + +Sim is a practical choice when a team wants model calls, tool use, branching, human review, and deployment in one visual workflow builder. n8n is a strong incumbent for teams centered on broad workflow automation and execution-based billing. Zapier is a strong incumbent for teams that prioritize a large hosted app-automation ecosystem and familiar trigger-and-action patterns. For a direct comparison, see [Sim vs n8n vs OpenAI AgentKit: AI Agent Builder Comparison (2026)](https://www.sim.ai/library/openai-vs-n8n-vs-sim). + Sim, n8n, Zapier, and Make are implementation options rather than a ranking for any single use case, and n8n may be the incumbent where a team already has its credentials, integrations, and operational experience. For broad platform selection, see [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026). Sim and n8n are two tools teams may evaluate for implementing multistep business workflows, but the choice should follow the workflow's model, integration, deployment, governance, and observability requirements. Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Sim's visual builder can connect model reasoning, tools, conditions, and human review in one workflow. In Sim, Human in the Loop pauses a run and resumes it with form responses; a downstream Condition must evaluate an approval or rejection field before the workflow takes the next action. From 75e7a8a4b61c6120bad0ae08e882dbadb5d9f35a Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 11:44:57 -0700 Subject: [PATCH 07/30] chore(deps): upgrade Bun to 1.4.4 (#9338) Bun 1.4.4 ships the updated root certificate store (oven-sh/bun#46159), so Bun's native fetch stops trusting the distrusted emSign ECC Root CA - G3. Moves the packageManager pin, engines floors, CI setup-bun versions, the oven/bun base images, and the Pi sandbox Bun pin together. --- .github/actions/setup/action.yml | 2 +- .github/workflows/checks.yml | 2 +- .github/workflows/desktop-e2e.yml | 6 +++--- .github/workflows/desktop-release.yml | 2 +- .github/workflows/helm.yml | 6 +++--- apps/desktop/package.json | 2 +- apps/realtime/package.json | 2 +- apps/sim/package.json | 2 +- apps/sim/scripts/pi-sandbox-packages.ts | 2 +- docker/app.Dockerfile | 2 +- docker/db.Dockerfile | 2 +- docker/realtime.Dockerfile | 2 +- package.json | 2 +- packages/audit/package.json | 2 +- packages/auth/package.json | 2 +- packages/browser-protocol/package.json | 2 +- packages/db/package.json | 2 +- packages/desktop-bridge/package.json | 2 +- packages/emcn/package.json | 2 +- packages/logger/package.json | 2 +- packages/platform-authz/package.json | 2 +- packages/realtime-protocol/package.json | 2 +- packages/runtime-secrets/package.json | 2 +- packages/security/package.json | 2 +- packages/terminal-protocol/package.json | 2 +- packages/testing/package.json | 2 +- packages/workflow-persistence/package.json | 2 +- packages/workflow-renderer/package.json | 2 +- packages/workflow-types/package.json | 2 +- 29 files changed, 33 insertions(+), 33 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index bb19cfda9bd..8ae0c89dae4 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -35,7 +35,7 @@ runs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Setup Node if: inputs.node-version != '' diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ef97d255b12..7abb0de6951 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Install only the pinned task planner continue-on-error: true run: bun install --global "turbo@$(jq -r .devDependencies.turbo package.json)" diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index a91e7010363..ab14614fc5b 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -79,7 +79,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 # Electron, electron-builder and Playwright download their binaries on every cold install. All # three caches are keyed by version inside, so an older entry restored by prefix is harmless. @@ -151,7 +151,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 # Electron, electron-builder and Playwright download their binaries on every cold install. All # three caches are keyed by version inside, so an older entry restored by prefix is harmless. @@ -213,7 +213,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Setup Node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 225cb703074..f8d7da153c0 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -143,7 +143,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 - name: Setup Node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index 15823a571bc..7bfddf84b66 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -62,7 +62,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 # Docker Compose and Kubernetes must run the same background jobs on the # same schedules; this fails the build if the two drift apart. The script @@ -286,7 +286,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 # Derives appVersion rather than checking it, so the published chart cannot # be pinned to an older Sim than the release it ships with. The committed @@ -481,7 +481,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: 1.4.2 + bun-version: 1.4.4 # Derives appVersion rather than checking it, so the published chart cannot # be pinned to an older Sim than the release it ships with. The committed diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 87b3699ca7a..303f47a78f4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,7 @@ "type": "module", "main": "dist/main.cjs", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "scripts": { diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 4f3914782d2..e1ab2cd0645 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "type": "module", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "scripts": { diff --git a/apps/sim/package.json b/apps/sim/package.json index dcee870e274..80bc634d208 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -4,7 +4,7 @@ "private": true, "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=24.0.0 <25.0.0" }, "scripts": { diff --git a/apps/sim/scripts/pi-sandbox-packages.ts b/apps/sim/scripts/pi-sandbox-packages.ts index 1a99a4920c7..2de8beee8fb 100644 --- a/apps/sim/scripts/pi-sandbox-packages.ts +++ b/apps/sim/scripts/pi-sandbox-packages.ts @@ -14,7 +14,7 @@ */ /** Bun version mirrored from the root packageManager field. */ -export const PI_BUN_VERSION = '1.4.2' +export const PI_BUN_VERSION = '1.4.4' /** Exact Pi version mirrored from the app dependencies and lockfile. */ export const PI_PACKAGE_VERSION = '1.0.0' diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 82fea8a46f8..8eb5a79d29d 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: runtime-only dependencies (inherited by the final image) # ======================================== -FROM oven/bun:1.4.2-slim AS base +FROM oven/bun:1.4.4-slim AS base # Install Node.js 24 (Active LTS) and the runtime dependencies once in base. # Node runs the isolated-vm sandbox worker and the Oracle Database worker (the diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile index b4c5c5aeacb..30ae055ae0c 100644 --- a/docker/db.Dockerfile +++ b/docker/db.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Alpine Linux with Bun # ======================================== -FROM oven/bun:1.4.2-alpine AS base +FROM oven/bun:1.4.4-alpine AS base # ======================================== # Dependencies Stage: Install Dependencies diff --git a/docker/realtime.Dockerfile b/docker/realtime.Dockerfile index cdbbca56ce5..02c1b23efd6 100644 --- a/docker/realtime.Dockerfile +++ b/docker/realtime.Dockerfile @@ -1,7 +1,7 @@ # ======================================== # Base Stage: Alpine Linux with Bun # ======================================== -FROM oven/bun:1.4.2-alpine AS base +FROM oven/bun:1.4.4-alpine AS base RUN apk add --no-cache libc6-compat curl diff --git a/package.json b/package.json index 86adc006d15..a840e359384 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simstudio", - "packageManager": "bun@1.4.2", + "packageManager": "bun@1.4.4", "version": "0.0.0", "private": true, "license": "Apache-2.0", diff --git a/packages/audit/package.json b/packages/audit/package.json index c29b4100af0..387df2a29e0 100644 --- a/packages/audit/package.json +++ b/packages/audit/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/auth/package.json b/packages/auth/package.json index 1726147cdbe..fbbc8bfca3c 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/browser-protocol/package.json b/packages/browser-protocol/package.json index a3bf4cb6dfc..91f7e7b0c2c 100644 --- a/packages/browser-protocol/package.json +++ b/packages/browser-protocol/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 34c3198fb88..7157481f85b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/desktop-bridge/package.json b/packages/desktop-bridge/package.json index bc5199d5163..29faed82fbe 100644 --- a/packages/desktop-bridge/package.json +++ b/packages/desktop-bridge/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/emcn/package.json b/packages/emcn/package.json index 14187524c5e..734ca605ac8 100644 --- a/packages/emcn/package.json +++ b/packages/emcn/package.json @@ -9,7 +9,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "imports": { diff --git a/packages/logger/package.json b/packages/logger/package.json index 8ab3743a27e..11d2f50135f 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/platform-authz/package.json b/packages/platform-authz/package.json index 8ffdad06755..11070b8d9bd 100644 --- a/packages/platform-authz/package.json +++ b/packages/platform-authz/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/realtime-protocol/package.json b/packages/realtime-protocol/package.json index 978f256f277..8db1ec9ec38 100644 --- a/packages/realtime-protocol/package.json +++ b/packages/realtime-protocol/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/runtime-secrets/package.json b/packages/runtime-secrets/package.json index dd5518143a0..57385c7e921 100644 --- a/packages/runtime-secrets/package.json +++ b/packages/runtime-secrets/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/security/package.json b/packages/security/package.json index d701f57b78f..12b409c28b9 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/terminal-protocol/package.json b/packages/terminal-protocol/package.json index 882228e920b..286bfd71569 100644 --- a/packages/terminal-protocol/package.json +++ b/packages/terminal-protocol/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/testing/package.json b/packages/testing/package.json index 477ce35fe94..1861cfe5aad 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -8,7 +8,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/workflow-persistence/package.json b/packages/workflow-persistence/package.json index 241f15049f3..1d1b4413a09 100644 --- a/packages/workflow-persistence/package.json +++ b/packages/workflow-persistence/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/workflow-renderer/package.json b/packages/workflow-renderer/package.json index d6ff7cad371..ec001f19113 100644 --- a/packages/workflow-renderer/package.json +++ b/packages/workflow-renderer/package.json @@ -9,7 +9,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { diff --git a/packages/workflow-types/package.json b/packages/workflow-types/package.json index 3e351e7fe59..91101fcbfeb 100644 --- a/packages/workflow-types/package.json +++ b/packages/workflow-types/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "Apache-2.0", "engines": { - "bun": ">=1.4.2", + "bun": ">=1.4.4", "node": ">=20.0.0" }, "exports": { From 0f755feb74e2f176a9e42b54d18f6d7afde6e517 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 12:40:51 -0700 Subject: [PATCH 08/30] fix(models): mark OpenAI gpt-4.1-nano deprecated (#9340) OpenAI retired gpt-4.1-nano and the API now returns 404, so move it from legacy to deprecated: hidden from model pickers, still a known id so pinned workflows validate. --- apps/sim/providers/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 38298c3b43f..b5c15d14a1e 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -705,7 +705,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1047576, releaseDate: '2025-04-14', - sunset: { status: 'legacy' }, + sunset: { status: 'deprecated' }, }, // GPT-5.6 family { From 4d269874d11c18ca6fbc19c94c7cd81a46c2541b Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 13:14:27 -0700 Subject: [PATCH 09/30] fix(mcp): classify unresolvable and egress-refused server URLs in discovery (#9341) An MCP server whose hostname no longer resolves made tool discovery answer 500 and log at error. McpDnsResolutionError is now a server-unavailable condition (discover 422, v2 503), and categorizeError maps it to 502 and egress/domain refusals to 403, matching test-connection and server lifecycle. --- .../app/api/mcp/tools/discover/route.test.ts | 17 +++++++++++++ .../[mcpServerId]/tools/route.test.ts | 14 +++++++++++ apps/sim/lib/mcp/utils.ts | 25 ++++++++++++++----- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/mcp/tools/discover/route.test.ts b/apps/sim/app/api/mcp/tools/discover/route.test.ts index 7d0eed851e0..eede45ab3f4 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.test.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.test.ts @@ -26,6 +26,11 @@ vi.mock('@/lib/mcp/service', () => mcpServiceMock) import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' +import { + McpDnsResolutionError, + McpDomainNotAllowedError, + McpSsrfError, +} from '@/lib/mcp/domain-check' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { McpConnectionError, McpServerCooldownError } from '@/lib/mcp/types' import { GET } from '@/app/api/mcp/tools/discover/route' @@ -56,6 +61,7 @@ describe('GET /api/mcp/tools/discover', () => { ) it.each([ + ['hostname no longer resolves', () => new McpDnsResolutionError('expired.example.com')], ['cannot be reached', () => new McpConnectionError('connect ECONNREFUSED', 'Docs')], ['is cooling down after a recent failure', () => new McpServerCooldownError('mcp-1')], ['times out on tools/list', () => new McpError(ErrorCode.RequestTimeout, 'Request timed out')], @@ -72,6 +78,17 @@ describe('GET /api/mcp/tools/discover', () => { expect((await response.json()).error).toBe('Failed to discover MCP tools') }) + it.each([ + ['resolves to a blocked address', () => new McpSsrfError('blocked address')], + ['is outside ALLOWED_MCP_DOMAINS', () => new McpDomainNotAllowedError('internal.example.com')], + ])('answers 403 when the egress policy refuses a server that %s', async (_name, makeError) => { + mcpServiceMockFns.mockDiscoverServerTools.mockRejectedValueOnce(makeError()) + + const response = await discover() + + expect(response.status).toBe(403) + }) + it('keeps a 500 for a failure that is not the registered server', async () => { mcpServiceMockFns.mockDiscoverServerTools.mockRejectedValueOnce(new Error('pool exhausted')) diff --git a/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts index e07091edd07..6e691a2cb23 100644 --- a/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts +++ b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/tools/route.test.ts @@ -19,6 +19,7 @@ vi.mock('@/lib/mcp/application/use-cases', () => mcpUseCasesMock) import { NoWorkspaceAccessError, WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { McpDnsResolutionError } from '@/lib/mcp/domain-check' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { McpConnectionError, @@ -149,6 +150,19 @@ describe('/api/v2/mcp-servers/[mcpServerId]/tools', () => { expect(JSON.stringify(body)).not.toContain('ECONNREFUSED') }) + it('reports a server whose hostname no longer resolves as a retryable 503', async () => { + mockDiscoverMcpServerToolsUseCase.mockRejectedValueOnce( + new McpDnsResolutionError('expired.example.com') + ) + + const response = await GET(request(`workspaceId=${WORKSPACE_ID}`), { ...context }) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.error.code).toBe('SERVICE_UNAVAILABLE') + expect(JSON.stringify(body)).not.toContain('expired.example.com') + }) + it('reports a stale OAuth grant as a 409 a client can branch on, never as a Sim credential failure', async () => { mockDiscoverMcpServerToolsUseCase.mockRejectedValueOnce( new McpOauthAuthorizationRequiredError(SERVER_ID, 'Docs server') diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index 72a9ba7c5e7..b8ab227c82b 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -4,6 +4,11 @@ import { McpError as McpSdkError } from '@modelcontextprotocol/sdk/types.js' import { generateShortId } from '@sim/utils/id' import { NextResponse } from 'next/server' import { DEFAULT_EXECUTION_TIMEOUT_MS, isTimeoutError } from '@/lib/core/execution-limits' +import { + McpDnsResolutionError, + McpDomainNotAllowedError, + McpSsrfError, +} from '@/lib/mcp/domain-check' import { type McpApiResponse, McpConnectionError, @@ -139,16 +144,18 @@ export function mcpOrchestrationStatus(errorCode: string | undefined): number { } /** - * Reports whether a registered MCP server failed to answer usefully: it could not be - * reached, timed out, is cooling down after a recent failure, or answered `tools/list` - * with a protocol or HTTP error. Typed only, never matched on message text, because - * `McpConnectionError` interpolates the server's display name into its message. Check - * authorization errors first: `UnauthorizedError` is not one of these, but a caller that - * wants to answer reauthorization separately must do so before asking. + * Reports whether a registered MCP server failed to answer usefully: its hostname no + * longer resolves, it could not be reached, timed out, is cooling down after a recent + * failure, or answered `tools/list` with a protocol or HTTP error. Typed only, never + * matched on message text, because `McpConnectionError` interpolates the server's display + * name into its message. Check authorization errors first: `UnauthorizedError` is not one + * of these, but a caller that wants to answer reauthorization separately must do so + * before asking. */ export function isMcpServerUnavailableError(error: unknown): boolean { return ( isTimeoutError(error) || + error instanceof McpDnsResolutionError || error instanceof McpConnectionError || error instanceof McpSdkError || error instanceof StreamableHTTPError @@ -174,6 +181,12 @@ export function categorizeError(error: unknown): { message: string; status: numb if (error instanceof McpConnectionError) { return { message: 'Connection failed', status: 502 } } + if (error instanceof McpDnsResolutionError) { + return { message: 'Server hostname could not be resolved', status: 502 } + } + if (error instanceof McpSsrfError || error instanceof McpDomainNotAllowedError) { + return { message: 'Server URL is not allowed by egress policy', status: 403 } + } // Fall back to substring matching for SDK / third-party errors we don't // own a typed class for. From 63ca24f2ebeef20d843adbc75752a4a5b8eb2ee2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 13:24:21 -0700 Subject: [PATCH 10/30] fix(gmail): drop an expired history cursor instead of re-saving it every poll (#9343) Gmail answers history.list with 404 once the stored cursor ages out of its retained history and the client must full-sync. The fallback searched the inbox, then took the mailbox profile's historyId as the new cursor, but a quiet mailbox reports the expired id itself, so it was persisted again and every poll repeated the 404, search and profile calls with an ERROR log. The resync now never adopts the rejected cursor and drops it when nothing newer exists, so the webhook polls through search until matching mail supplies one. The expected 404 logs at info and other history errors at warn. --- apps/sim/lib/webhooks/polling/gmail.test.ts | 183 ++++++++++++++++++++ apps/sim/lib/webhooks/polling/gmail.ts | 54 ++++-- 2 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 apps/sim/lib/webhooks/polling/gmail.test.ts diff --git a/apps/sim/lib/webhooks/polling/gmail.test.ts b/apps/sim/lib/webhooks/polling/gmail.test.ts new file mode 100644 index 00000000000..36de4423dc9 --- /dev/null +++ b/apps/sim/lib/webhooks/polling/gmail.test.ts @@ -0,0 +1,183 @@ +import { createLogger } from '@sim/logger' +import { jsonResponse } from '@sim/testing/helpers/http' +import { idempotencyServiceMock } from '@sim/testing/mocks/idempotency-service.mock' +import { + webhooksPollingUtilsMock, + webhooksPollingUtilsMockFns, +} from '@sim/testing/mocks/webhooks-polling-utils.mock' +import { + webhooksProcessorMock, + webhooksProcessorMockFns, +} from '@sim/testing/mocks/webhooks-processor.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/idempotency/service', () => idempotencyServiceMock) + +vi.mock('@/lib/webhooks/processor', () => webhooksProcessorMock) + +vi.mock('@/lib/webhooks/polling/utils', () => webhooksPollingUtilsMock) + +import { gmailPollingHandler } from '@/lib/webhooks/polling/gmail' +import type { + PollWebhookContext, + WebhookRecord, + WorkflowRecord, +} from '@/lib/webhooks/polling/types' + +const { mockUpdateWebhookProviderConfig: mockUpdateConfig, mockResolveOAuthCredential } = + webhooksPollingUtilsMockFns +const mockProcessEvent = webhooksProcessorMockFns.mockProcessPolledWebhookEvent + +const EXPIRED_HISTORY_ID = '30000' + +const WORKFLOW: WorkflowRecord = { + id: 'gmail-listener', + userId: 'gmail-owner', + workspaceId: 'gmail-workspace', + folderId: null, + sortOrder: 0, + name: 'Gmail listener', + description: null, + lastSynced: new Date('2026-10-01T00:00:00.000Z'), + createdAt: new Date('2026-10-01T00:00:00.000Z'), + updatedAt: new Date('2026-10-01T00:00:00.000Z'), + isDeployed: true, + deployedAt: new Date('2026-10-01T00:00:00.000Z'), + isPublicApi: false, + locked: false, + forkSyncExcluded: false, + runCount: 0, + lastRunAt: null, + variables: {}, + archivedAt: null, +} + +interface MailboxState { + profileHistoryId: string + inboxMessages: { id: string; historyId: string }[] +} + +function context(providerConfig: Record): PollWebhookContext { + const webhookData: WebhookRecord = { + id: 'gmail-webhook', + workflowId: 'gmail-listener', + deploymentVersionId: null, + registrationStatus: null, + registrationGeneration: null, + configFingerprint: null, + preparedAt: null, + blockId: null, + path: 'gmail-listener', + routingKey: null, + provider: 'gmail', + providerConfig: { + labelIds: ['INBOX'], + labelFilterBehavior: 'INCLUDE', + markAsRead: false, + lastCheckedTimestamp: new Date(Date.now() - 60_000).toISOString(), + ...providerConfig, + }, + isActive: true, + failedCount: 0, + lastFailedAt: null, + archivedAt: null, + createdAt: new Date('2026-10-01T00:00:00.000Z'), + updatedAt: new Date('2026-10-01T00:00:00.000Z'), + } + return { + webhookData, + workflowData: WORKFLOW, + requestId: 'gmail-request', + logger: createLogger('GmailTest'), + } +} + +/** + * A Gmail mailbox whose history records older than the stored cursor have aged out: + * `history.list` answers 404 for that cursor, as the Gmail sync guide documents. + */ +function mailboxWithExpiredHistory(state: MailboxState) { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = new URL(typeof input === 'string' ? input : input.toString()) + if (url.pathname.endsWith('/users/me/history')) { + return jsonResponse( + { error: { code: 404, message: 'Requested entity was not found.', status: 'NOT_FOUND' } }, + 404 + ) + } + if (url.pathname.endsWith('/users/me/profile')) { + return jsonResponse({ historyId: state.profileHistoryId }) + } + if (url.pathname.endsWith('/users/me/messages')) { + return jsonResponse( + state.inboxMessages.length + ? { messages: state.inboxMessages.map(({ id }) => ({ id, threadId: id })) } + : { resultSizeEstimate: 0 } + ) + } + const message = state.inboxMessages.find((m) => url.pathname.endsWith(`/messages/${m.id}`)) + if (message) { + return jsonResponse({ + id: message.id, + threadId: message.id, + historyId: message.historyId, + labelIds: ['INBOX'], + payload: { headers: [{ name: 'Subject', value: 'hello' }] }, + }) + } + return new Response('unexpected request', { status: 500 }) + }) + vi.stubGlobal('fetch', fetchMock) +} + +function persistedHistoryIds() { + return mockUpdateConfig.mock.calls.map( + ([, updates]) => (updates as { historyId?: string }).historyId + ) +} + +describe('gmailPollingHandler expired history cursor', () => { + beforeEach(() => { + mockResolveOAuthCredential.mockResolvedValue('access-token') + mockProcessEvent.mockResolvedValue({ success: true }) + }) + + it('drops a cursor the quiet mailbox cannot replace instead of re-saving the expired one', async () => { + mailboxWithExpiredHistory({ + profileHistoryId: EXPIRED_HISTORY_ID, + inboxMessages: [], + }) + + await expect( + gmailPollingHandler.pollWebhook(context({ historyId: EXPIRED_HISTORY_ID })) + ).resolves.toBe('success') + + expect(mockUpdateConfig).toHaveBeenCalledTimes(1) + const [, updates] = mockUpdateConfig.mock.calls[0] + expect(updates).toHaveProperty('historyId', undefined) + }) + + it('resumes from the newest searched message when the full sync finds mail', async () => { + mailboxWithExpiredHistory({ + profileHistoryId: '40100', + inboxMessages: [{ id: 'm-new', historyId: '40050' }], + }) + + await expect( + gmailPollingHandler.pollWebhook(context({ historyId: EXPIRED_HISTORY_ID })) + ).resolves.toBe('success') + + expect(mockProcessEvent).toHaveBeenCalledTimes(1) + expect(persistedHistoryIds()).toEqual(['40050']) + }) + + it('resumes from the mailbox cursor when activity moved it past the expired one', async () => { + mailboxWithExpiredHistory({ profileHistoryId: '40100', inboxMessages: [] }) + + await expect( + gmailPollingHandler.pollWebhook(context({ historyId: EXPIRED_HISTORY_ID })) + ).resolves.toBe('success') + + expect(persistedHistoryIds()).toEqual(['40100']) + }) +}) diff --git a/apps/sim/lib/webhooks/polling/gmail.ts b/apps/sim/lib/webhooks/polling/gmail.ts index 9469fd60a52..780b911938e 100644 --- a/apps/sim/lib/webhooks/polling/gmail.ts +++ b/apps/sim/lib/webhooks/polling/gmail.ts @@ -89,9 +89,7 @@ export const gmailPollingHandler: PollingProviderHandler = { webhookId, { lastCheckedTimestamp: now.toISOString(), - ...(latestHistoryId || config.historyId - ? { historyId: latestHistoryId || config.historyId } - : {}), + historyId: latestHistoryId, }, logger ) @@ -116,9 +114,7 @@ export const gmailPollingHandler: PollingProviderHandler = { webhookId, { lastCheckedTimestamp: now.toISOString(), - ...(latestHistoryId || config.historyId - ? { historyId: latestHistoryId || config.historyId } - : {}), + historyId: latestHistoryId, }, logger ) @@ -174,8 +170,17 @@ async function fetchNewEmails( if (!historyResponse.ok) { const status = historyResponse.status + + if (status === 404 || status === 400) { + await historyResponse.body?.cancel().catch(() => {}) + logger.info( + `[${requestId}] Gmail rejected history cursor ${config.historyId} (${status}); resyncing through search` + ) + return resyncAfterExpiredHistory(accessToken, config, requestId, logger) + } + const errorData = await historyResponse.json().catch(() => ({})) - logger.error(`[${requestId}] Gmail history API error:`, { + logger.warn(`[${requestId}] Gmail history API error:`, { status, statusText: historyResponse.statusText, error: errorData, @@ -188,17 +193,7 @@ async function fetchNewEmails( } logger.info(`[${requestId}] Falling back to search API after history API error ${status}`) - const searchResult = await searchEmails(accessToken, config, requestId, logger) - if (searchResult.emails.length === 0) { - const freshHistoryId = await getGmailProfileHistoryId(accessToken, requestId, logger) - if (freshHistoryId) { - logger.info( - `[${requestId}] Fetched fresh historyId ${freshHistoryId} after invalid historyId (was: ${config.historyId})` - ) - return { emails: [], latestHistoryId: freshHistoryId } - } - } - return searchResult + return searchEmails(accessToken, config, requestId, logger) } const historyData = await historyResponse.json() @@ -384,6 +379,29 @@ async function searchEmails( } } +/** + * Full-syncs through search after Gmail rejects the history cursor (404 once it ages out of + * retained history). Adopts a new cursor but never the rejected one, which a quiet mailbox's + * profile still reports, so the webhook polls through search until matching mail supplies one. + */ +async function resyncAfterExpiredHistory( + accessToken: string, + config: GmailWebhookConfig, + requestId: string, + logger: Logger +) { + const searchResult = await searchEmails(accessToken, config, requestId, logger) + const candidate = + searchResult.latestHistoryId !== config.historyId + ? searchResult.latestHistoryId + : await getGmailProfileHistoryId(accessToken, requestId, logger) + const latestHistoryId = candidate && candidate !== config.historyId ? candidate : undefined + if (!latestHistoryId) { + logger.info(`[${requestId}] No replacement Gmail history cursor yet; polling through search`) + } + return { emails: searchResult.emails, latestHistoryId } +} + async function getGmailProfileHistoryId( accessToken: string, requestId: string, From e5ad3ae1838c963f87730183caf96d40fd828824 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 13:26:25 -0700 Subject: [PATCH 11/30] fix(logs): keep provenance sidecars intact through log PII redaction (#9344) * fix(logs): keep provenance sidecars intact through log PII redaction Log-stage PII masking walks every string leaf of the execution state, so a match inside an encrypted provenance entry rewrote the ciphertext and the display read failed closed with the final output withheld. Restore the finalOutput/workflowInput sidecars, loop/parallel scope provenance, and sourceExecutionId from the pre-redaction state alongside the fields already restored. An undecryptable stored envelope now logs one attributed error per read instead of three. * test(provenance): expect provenance imports to decrypt without the helper's own failure log --- .../search/route.application.test.ts | 4 +- .../utils/resolved-secret-trace-registry.ts | 43 +++++++--- apps/sim/lib/logs/execution/logger.test.ts | 81 +++++++++++++++++-- apps/sim/lib/logs/execution/logger.ts | 69 +++++++++++----- apps/sim/lib/logs/execution/trace-store.ts | 57 +++++++++---- .../agent-cli/file-provenance.test.ts | 2 +- .../mothership/request/tools/client.test.ts | 2 +- 7 files changed, 202 insertions(+), 56 deletions(-) diff --git a/apps/sim/app/api/v2/knowledge/search/route.application.test.ts b/apps/sim/app/api/v2/knowledge/search/route.application.test.ts index e65fe3637a9..414110c301b 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.application.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.application.test.ts @@ -276,7 +276,9 @@ describe('Knowledge search provenance through the V2 route and reranker HTTP bou expect(body.data.rerankerStatus).toBe('applied') expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')]) expect(body.data.results[0].content).toBe(CONTENT) - expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledWith('synthetic-encrypted-token') + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledWith('synthetic-encrypted-token', { + logFailure: false, + }) expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith( requestInput.query, expect.anything(), diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 04f804a32ad..56a475e816d 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -417,6 +417,18 @@ export interface ImportResolvedSecretTraceProvenanceOptions { * `source-provenance-incomplete` from a symptom into an address. */ origin?: string + /** + * Takes the import's entry decrypt-failure summary in place of the registry's own error line, + * for a caller that reports the failure itself with the context the registry lacks. + */ + onDecryptFailure?: (failure: ProvenanceDecryptFailure) => void +} + +/** Entry decrypt failures of one provenance import, summarised. */ +export interface ProvenanceDecryptFailure { + error: string + failedEntryCount: number + totalEntryCount: number } export interface ExportResolvedSecretTraceProvenanceForValueOptions { @@ -966,7 +978,7 @@ export class ResolvedSecretTraceRegistry { * A staged registry filters values for one operation and is then discarded. Its caller owns the * reporting and says it with strictly more context — the real input path for a value filter, the * execution for a display read — so the registry's own summary lines would only restate it. - * Entry-level detail still logs — the caller cannot reconstruct it. + * Entry decrypt failures still log unless the caller takes them through `onDecryptFailure`. */ private readonly staged: boolean /** @@ -1694,11 +1706,10 @@ export class ResolvedSecretTraceRegistry { const sameScope = scopesMatch(provenance.scope, this.scope) let importedAll = true - let decryptFailures = 0 - let firstDecryptError: string | undefined + let decryptFailure: ProvenanceDecryptFailure | undefined for (const entry of provenance.entries) { try { - const { decrypted } = await decryptSecret(entry.encryptedValue) + const { decrypted } = await decryptSecret(entry.encryptedValue, { logFailure: false }) this.addActiveEntry( { name: entry.name ?? '', @@ -1710,8 +1721,12 @@ export class ResolvedSecretTraceRegistry { ) } catch (error) { importedAll = false - decryptFailures += 1 - firstDecryptError ??= getErrorMessage(error, 'Unknown error') + decryptFailure ??= { + error: getErrorMessage(error, 'Unknown error'), + failedEntryCount: 0, + totalEntryCount: provenance.entries.length, + } + decryptFailure.failedEntryCount += 1 this.markIncomplete('entry-decrypt-failed', { origin: options.origin }) } } @@ -1720,13 +1735,15 @@ export class ResolvedSecretTraceRegistry { * Summarised rather than logged per entry: one rotated or corrupt key fails every entry in the * bundle, and a bundle may carry up to MAX_PROVENANCE_ENTRIES of them. */ - if (decryptFailures > 0) { - logger.error('Provenance entries could not be decrypted', { - error: firstDecryptError, - failedEntryCount: decryptFailures, - totalEntryCount: provenance.entries.length, - scopeWorkspaceId: this.scope?.workspaceId, - }) + if (decryptFailure) { + if (options.onDecryptFailure) { + options.onDecryptFailure(decryptFailure) + } else { + logger.error('Provenance entries could not be decrypted', { + ...decryptFailure, + scopeWorkspaceId: this.scope?.workspaceId, + }) + } } return importedAll diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index d09ac80ebfc..b0ab40c2350 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -23,6 +23,7 @@ import { billingUsageMonitorMock, billingUsageMonitorMockFns, } from '@sim/testing/mocks/billing-usage-monitor.mock' +import { maskClientMock, maskClientMockFns } from '@sim/testing/mocks/mask-client.mock' import { isPlainRecord } from '@sim/utils/object' import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest' import { recordUsage } from '@/lib/billing/core/usage-log' @@ -95,6 +96,8 @@ vi.mock('@/lib/billing/core/usage', () => billingUsageMock) vi.mock('@/lib/billing/core/usage-log', () => billingUsageLogMock) +vi.mock('@/lib/guardrails/mask-client', () => maskClientMock) + vi.mock('@/lib/billing/threshold-billing', () => ({ checkAndBillOverageThreshold: vi.fn(() => Promise.resolve()), checkAndBillPayerOverageThreshold: vi.fn(() => Promise.resolve()), @@ -270,6 +273,9 @@ describe('ExecutionLogger', () => { async function completeAndReadWrite(params: { executionState?: SerializableExecutionState redactedState?: SerializableExecutionState + /** Runs the real log-stage PII pass instead of standing in for it. */ + realRedaction?: boolean + finalOutput?: Record }) { const startedAt = new Date('2026-08-11T00:00:00.000Z') queueTableRows(workflowExecutionLogs, [ @@ -299,12 +305,14 @@ describe('ExecutionLogger', () => { costTotalRefined: boolean }> } - vi.spyOn(internals, 'applyPiiRedaction').mockImplementation( - async (_workspaceId: string, payload: Record) => - Object.hasOwn(params, 'redactedState') - ? { ...payload, executionState: params.redactedState } - : payload - ) + if (!params.realRedaction) { + vi.spyOn(internals, 'applyPiiRedaction').mockImplementation( + async (_workspaceId: string, payload: Record) => + Object.hasOwn(params, 'redactedState') + ? { ...payload, executionState: params.redactedState } + : payload + ) + } vi.spyOn(internals, 'recordExecutionUsage').mockResolvedValue({ recordedIncrement: 0, costTotalRefined: false, @@ -324,7 +332,7 @@ describe('ExecutionLogger', () => { baseExecutionCharge: 0, models: {}, }, - finalOutput: { completed: true }, + finalOutput: params.finalOutput ?? { completed: true }, traceSpans: [], ...(params.executionState ? { executionState: params.executionState } : {}), }) @@ -433,6 +441,65 @@ describe('ExecutionLogger', () => { expect(preserved?.blockStates).toEqual(redactedState.blockStates) }) + test('keeps encrypted provenance sidecars intact through log PII redaction', async () => { + queueTableRows(workspace, [ + { + orgSettings: { + piiRedaction: { rules: [{ workspaceId: null, entityTypes: ['PHONE_NUMBER'] }] }, + }, + }, + ]) + maskClientMockFns.mockMaskPIIBatchViaHttp.mockImplementation(async (texts: string[]) => ({ + masked: texts.map((text) => text.replace(/\d{7,}/g, '')), + scrubbedCount: 0, + })) + const sidecar = (encryptedValue: string) => ({ + version: 1 as const, + complete: true, + entries: [{ name: 'API_SECRET', encryptedValue }], + }) + const finalOutputProvenance = sidecar('9f3c2a5551234567e0:4b18093327716650aa:0c1d') + const workflowInputProvenance = sidecar('1e2f5550001111e0:77a0384460007799bb:9d8e') + const loopInputProvenance = sidecar('3a4b5552223333c0:66b0129930004455cc:5f6a') + const parallelOutputProvenance = sidecar('7c8d5554445555a0:11b0567780009911dd:2e3f') + + const written = await completeAndReadWrite({ + realRedaction: true, + finalOutput: { reply: 'Call me at 5551234567' }, + executionState: { + ...EMPTY_STATE, + sourceExecutionId: 'execution-20260811123456789', + resolvedSecretTraceProvenance: RUN_PROVENANCE, + resolvedSecretTraceCheckpointVersion: 1, + finalOutputResolvedSecretTraceProvenance: finalOutputProvenance, + workflowInputResolvedSecretTraceProvenance: workflowInputProvenance, + loopExecutions: { + loop1: { + iteration: 0, + items: ['Call 5559876543'], + inputResolvedSecretTraceProvenance: loopInputProvenance, + }, + }, + parallelExecutions: { + parallel1: { resolvedSecretTraceProvenance: parallelOutputProvenance }, + }, + } as SerializableExecutionState, + }) + + const state = written?.executionState as SerializableExecutionState | undefined + expect(written?.finalOutput).toEqual({ reply: 'Call me at ' }) + expect(state?.loopExecutions?.loop1?.items).toEqual(['Call ']) + expect(state?.finalOutputResolvedSecretTraceProvenance).toEqual(finalOutputProvenance) + expect(state?.workflowInputResolvedSecretTraceProvenance).toEqual(workflowInputProvenance) + expect(state?.loopExecutions?.loop1?.inputResolvedSecretTraceProvenance).toEqual( + loopInputProvenance + ) + expect(state?.parallelExecutions?.parallel1?.resolvedSecretTraceProvenance).toEqual( + parallelOutputProvenance + ) + expect(state?.sourceExecutionId).toBe('execution-20260811123456789') + }) + test('summarizes oversized execution data before storage', () => { const loggerInstance = new ExecutionLogger() as any const largePayload = 'x'.repeat(1_100_000) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 850e817fd48..0c26186d937 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -15,6 +15,7 @@ import { getPostgresErrorCode, } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { filterUndefined, isRecordLike } from '@sim/utils/object' import { and, eq, inArray, sql } from 'drizzle-orm' import { checkUsageStatus as checkResolvedUsageStatus } from '@/lib/billing/calculations/usage-monitor' import { type BillingAttributionSnapshot, toBillingContext } from '@/lib/billing/core/attribution' @@ -259,6 +260,27 @@ function summarizeTraceSpansForExecutionData(traceSpans?: TraceSpan[]): TraceSpa }) } +/** Puts each loop or parallel scope's own provenance back onto its masked copy. */ +function restoreSubflowScopeProvenance( + redactedScopes: Record | undefined, + originalScopes: Record | undefined +): Record | undefined { + if (!redactedScopes) return redactedScopes + const scopes = { ...redactedScopes } + for (const [scopeId, originalScope] of Object.entries(originalScopes ?? {})) { + const redactedScope = scopes[scopeId] + if (!isRecordLike(redactedScope) || !isRecordLike(originalScope)) continue + scopes[scopeId] = { + ...redactedScope, + ...filterUndefined({ + inputResolvedSecretTraceProvenance: originalScope.inputResolvedSecretTraceProvenance, + resolvedSecretTraceProvenance: originalScope.resolvedSecretTraceProvenance, + }), + } + } + return scopes +} + function summarizeExecutionState(executionState?: SerializableExecutionState) { if (!executionState) { return undefined @@ -777,18 +799,19 @@ export class ExecutionLogger { }) } - /** Restores server-only lifecycle metadata after broad execution-state PII masking. */ + /** + * Restores server-only lifecycle metadata after broad execution-state PII masking. The masker + * walks every string leaf, so it can rewrite a substring of an encrypted provenance entry or an + * id and make it unusable; these fields carry no user content, so the originals go back verbatim. + */ private preservePrivateExecutionStateMetadata( redactedState: SerializableExecutionState | undefined, originalState: SerializableExecutionState | undefined ): SerializableExecutionState | undefined { - if (!redactedState) return redactedState + if (!redactedState || !originalState) return redactedState - const provenance = originalState?.resolvedSecretTraceProvenance - const provenanceCheckpointVersion = originalState?.resolvedSecretTraceCheckpointVersion - const trustedLargeValueAccess = originalState?.trustedLargeValueAccess const blockStates = { ...redactedState.blockStates } - for (const [blockId, originalBlockState] of Object.entries(originalState?.blockStates ?? {})) { + for (const [blockId, originalBlockState] of Object.entries(originalState.blockStates)) { const redactedBlockState = blockStates[blockId] if (redactedBlockState && originalBlockState.resolvedSecretTraceProvenance) { blockStates[blockId] = { @@ -798,8 +821,7 @@ export class ExecutionLogger { } } const blockLogs = redactedState.blockLogs.map((log, index) => { - const displayProvenance = - originalState?.blockLogs[index]?.displayResolvedSecretTraceProvenance + const displayProvenance = originalState.blockLogs[index]?.displayResolvedSecretTraceProvenance return displayProvenance ? { ...log, displayResolvedSecretTraceProvenance: displayProvenance } : log @@ -809,17 +831,26 @@ export class ExecutionLogger { ...redactedState, blockStates, blockLogs, - ...(provenance !== undefined ? { resolvedSecretTraceProvenance: provenance } : {}), - ...(originalState?.workflowVariableResolvedSecretTraceProvenance !== undefined - ? { - workflowVariableResolvedSecretTraceProvenance: - originalState.workflowVariableResolvedSecretTraceProvenance, - } - : {}), - ...(provenanceCheckpointVersion !== undefined - ? { resolvedSecretTraceCheckpointVersion: provenanceCheckpointVersion } - : {}), - ...(trustedLargeValueAccess !== undefined ? { trustedLargeValueAccess } : {}), + ...filterUndefined({ + loopExecutions: restoreSubflowScopeProvenance( + redactedState.loopExecutions, + originalState.loopExecutions + ), + parallelExecutions: restoreSubflowScopeProvenance( + redactedState.parallelExecutions, + originalState.parallelExecutions + ), + sourceExecutionId: originalState.sourceExecutionId, + trustedLargeValueAccess: originalState.trustedLargeValueAccess, + resolvedSecretTraceProvenance: originalState.resolvedSecretTraceProvenance, + workflowVariableResolvedSecretTraceProvenance: + originalState.workflowVariableResolvedSecretTraceProvenance, + workflowInputResolvedSecretTraceProvenance: + originalState.workflowInputResolvedSecretTraceProvenance, + finalOutputResolvedSecretTraceProvenance: + originalState.finalOutputResolvedSecretTraceProvenance, + resolvedSecretTraceCheckpointVersion: originalState.resolvedSecretTraceCheckpointVersion, + }), } } diff --git a/apps/sim/lib/logs/execution/trace-store.ts b/apps/sim/lib/logs/execution/trace-store.ts index d2334e83800..48c86eb92a2 100644 --- a/apps/sim/lib/logs/execution/trace-store.ts +++ b/apps/sim/lib/logs/execution/trace-store.ts @@ -14,6 +14,7 @@ import { traceSpansHaveHandledErrors } from '@/lib/logs/execution/trace-spans/ha import type { TraceSpan } from '@/lib/logs/types' import { isResolvedSecretTraceProvenanceV1, + type ProvenanceDecryptFailure, ResolvedSecretTraceRegistry, } from '@/executor/utils/resolved-secret-trace-registry' @@ -446,7 +447,7 @@ export async function materializeExecutionDataForDisplayWithBlockOutputs( executionState?.[RESOLVED_SECRET_PROVENANCE_KEY], 'traceStore.blockOutputRunProvenance' ) - const provenanceFaults = new Map() + const provenanceFaults = new Map() if (runImport.fault) provenanceFaults.set('run', runImport.fault) const blockOutputs = new Map() const projectionStore = createReadOnlyProjectionStore(context) @@ -494,9 +495,15 @@ function readRecord(value: unknown): Record | undefined { type StoredDisplayProvenanceFault = 'incomplete' | 'malformed' | 'undecryptable' +interface StoredDisplayFault { + kind: StoredDisplayProvenanceFault + /** Set for `undecryptable`: the entry detail the registry would otherwise log on its own line. */ + decryptFailure?: ProvenanceDecryptFailure +} + interface StoredDisplayEnvelopeImport { registry: ResolvedSecretTraceRegistry | undefined - fault: StoredDisplayProvenanceFault | undefined + fault: StoredDisplayFault | undefined } /** @@ -517,15 +524,27 @@ async function importStoredDisplayEnvelope( ): Promise { if (provenance === undefined) return { registry: undefined, fault: undefined } if (!isResolvedSecretTraceProvenanceV1(provenance)) { - return { registry: undefined, fault: 'malformed' } + return { registry: undefined, fault: { kind: 'malformed' } } } const registry = new ResolvedSecretTraceRegistry([], provenance.scope, { staged: true }) - await registry.importProvenance(provenance, { trusted: true, origin }) - const fault = !provenance.complete - ? 'incomplete' + let decryptFailure: ProvenanceDecryptFailure | undefined + await registry.importProvenance(provenance, { + trusted: true, + origin, + // Only a complete envelope can classify as `undecryptable`, whose line carries this detail. + ...(provenance.complete + ? { + onDecryptFailure: (failure: ProvenanceDecryptFailure) => { + decryptFailure = failure + }, + } + : {}), + }) + const fault: StoredDisplayFault | undefined = !provenance.complete + ? { kind: 'incomplete' } : registry.isPermanentlyIncomplete() - ? 'undecryptable' + ? { kind: 'undecryptable', decryptFailure } : undefined return { registry, fault } } @@ -538,7 +557,7 @@ const STORED_PROVENANCE_FAULT_REPORTS = { message: 'Stored execution provenance cannot vouch for display content', }, malformed: { level: 'error', message: 'Stored execution provenance is malformed' }, - /** The entry-level decrypt error already logs its counts; this adds the execution it hit. */ + /** Carries the entry decrypt detail too, which the registry hands over instead of logging. */ undecryptable: { level: 'error', message: 'Stored execution provenance could not be decrypted' }, } as const satisfies Record< StoredDisplayProvenanceFault, @@ -563,7 +582,7 @@ const STORED_PROVENANCE_FAULT_REPORTS = { function reportStoredDisplayProvenanceFaults( site: string, context: TraceStoreReadContext, - faults: ReadonlyMap + faults: ReadonlyMap ): void { if (faults.size === 0) return const details = { @@ -576,13 +595,23 @@ function reportStoredDisplayProvenanceFaults( StoredDisplayProvenanceFault, (typeof STORED_PROVENANCE_FAULT_REPORTS)[StoredDisplayProvenanceFault], ][]) { - const parts = [...faults].filter(([, fault]) => fault === kind).map(([part]) => part) - if (parts.length === 0) continue + const matching = [...faults].filter(([, fault]) => fault.kind === kind) + if (matching.length === 0) continue + const decryptFailures = matching.flatMap(([, fault]) => + fault.decryptFailure ? [fault.decryptFailure] : [] + ) logger[report.level](report.message, { ...details, fault: kind, - parts: parts.slice(0, MAX_REPORTED_PROVENANCE_FAULT_PARTS), - partCount: parts.length, + parts: matching.slice(0, MAX_REPORTED_PROVENANCE_FAULT_PARTS).map(([part]) => part), + partCount: matching.length, + ...(decryptFailures.length > 0 + ? { + error: decryptFailures[0].error, + failedEntryCount: decryptFailures.reduce((sum, f) => sum + f.failedEntryCount, 0), + totalEntryCount: decryptFailures.reduce((sum, f) => sum + f.totalEntryCount, 0), + } + : {}), }) } } @@ -625,7 +654,7 @@ export async function projectExecutionDataForDisplay( return projectLegacyExecutionDataForDisplay(executionData) } - const provenanceFaults = new Map() + const provenanceFaults = new Map() const runImport = await importStoredDisplayEnvelope(provenance, 'traceStore.spanProvenance') const registry = runImport.registry if (runImport.fault) provenanceFaults.set('traceSpans', runImport.fault) diff --git a/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts b/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts index 96a707dbcfa..a7fad652c5f 100644 --- a/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts +++ b/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts @@ -458,7 +458,7 @@ describe('file provenance at the actual CLI and model-result boundary', () => { const output = await response.text() expect(output).toContain(content) expect(trace.isPermanentlyIncomplete()).toBe(false) - expect(mocks.decrypt).toHaveBeenCalledWith('fixture-ciphertext') + expect(mocks.decrypt).toHaveBeenCalledWith('fixture-ciphertext', { logFailure: false }) expect( JSON.stringify( inspectToolResultForCopilot({ success: true, output }, trace, 'sim_cli').result diff --git a/apps/sim/lib/mothership/request/tools/client.test.ts b/apps/sim/lib/mothership/request/tools/client.test.ts index 80d5fe38d69..7ba230faee4 100644 --- a/apps/sim/lib/mothership/request/tools/client.test.ts +++ b/apps/sim/lib/mothership/request/tools/client.test.ts @@ -607,7 +607,7 @@ describe('workflow client tool completion', () => { registry, }) - expect(decryptSecret).toHaveBeenCalledWith('encrypted-child-secret') + expect(decryptSecret).toHaveBeenCalledWith('encrypted-child-secret', { logFailure: false }) expect(completion?.data).toEqual({ success: true, workflowId: 'workflow-1', From 7d1b0473164dfa6bf544c39d2c32b25839884ffc Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 13:31:19 -0700 Subject: [PATCH 12/30] fix(workflows): accept json input type and return 400 for unsupported input types (#9342) * fix(workflows): accept json input type and return 400 for unsupported input types A Start/API trigger input field typed "json" (the name blocks use for objects) made manual and copilot runs fail with a 500, and broke MCP schema sync on deploy, because the shared run schema threw a plain Error on any type outside its list. Treat json as object and files as file[] everywhere the type is read, raise OrchestrationError('validation') for reserved names and unsupported types so callers answer 400 naming the field and allowed types, and canonicalize or reject input-format row types at write time. * fix(workflows): sample aliased input types as their canonical type in mock payloads * fix(mcp): answer an unpublishable workflow input as a validation failure --- apps/sim/executor/utils/start-block.test.ts | 3 ++ apps/sim/executor/utils/start-block.ts | 3 +- .../workflow-mcp-lifecycle.test.ts | 32 ++++++++++++++ .../orchestration/workflow-mcp-lifecycle.ts | 10 ++++- .../lib/workflows/editing/validation.test.ts | 31 +++++++++++++ apps/sim/lib/workflows/editing/validation.ts | 43 ++++++++++++++++++- apps/sim/lib/workflows/input-format-schema.ts | 36 +++++++++++++++- apps/sim/lib/workflows/input-format.ts | 7 ++- apps/sim/lib/workflows/input-schema.test.ts | 22 ++++++++-- apps/sim/lib/workflows/input-schema.ts | 31 +++++++++---- .../workflows/triggers/run-options.test.ts | 14 ++++++ .../sim/lib/workflows/triggers/run-options.ts | 3 +- 12 files changed, 217 insertions(+), 18 deletions(-) diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 9f5887ecf7b..758cbe2116c 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -430,6 +430,7 @@ describe('start-block utilities', () => { value: [ { name: 'count', type: 'number' }, { name: 'payload', type: 'object' }, + { name: 'meta', type: 'json' }, ], }, }, @@ -446,12 +447,14 @@ describe('start-block utilities', () => { workflowInput: { count: '5', payload: '{"event":"push"}', + meta: '{"source":"api"}', extra: 'untouched', }, }) expect(output.count).toBe(5) expect(output.payload).toEqual({ event: 'push' }) + expect(output.meta).toEqual({ source: 'api' }) expect(output.extra).toBe('untouched') }) }) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index e2efde6d84a..c3dea3bd3ab 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -7,6 +7,7 @@ import { isInternalFileUrl, parseInternalFileUrl, } from '@/lib/uploads/utils/file' +import { canonicalInputFormatFieldType } from '@/lib/workflows/input-format-schema' import { classifyStartBlockType, resolveStartCandidates, @@ -245,7 +246,7 @@ export function coerceValue(type: string | null | undefined, value: unknown): un return value } - switch (type) { + switch (type ? (canonicalInputFormatFieldType(type) ?? type) : type) { case 'string': return typeof value === 'string' ? value : String(value) case 'number': { diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts index f16629523de..8acdb15775d 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts @@ -11,11 +11,13 @@ vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ generateParameterSchemaForWorkflow: vi.fn().mockResolvedValue({ type: 'object', properties: {} }), })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_MCP_PARAMETER_SCHEMA_BYTES, MAX_MCP_TOOLS_PER_SERVER } from '@/lib/mcp/constants' import { performCreateWorkflowMcpServer, performUpdateWorkflowMcpTool, } from '@/lib/mcp/orchestration/workflow-mcp-lifecycle' +import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { hasValidStartBlock } from '@/lib/workflows/triggers/utils.server' describe('workflow MCP lifecycle orchestration', () => { @@ -97,6 +99,36 @@ describe('workflow MCP lifecycle orchestration', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) + it('answers an unpublishable workflow input as a validation failure naming the field', async () => { + const message = + 'Workflow input "payload" has unsupported type "integer". Use one of: string, number.' + vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true) + vi.mocked(generateParameterSchemaForWorkflow).mockRejectedValueOnce( + new OrchestrationError('validation', message) + ) + dbChainMockFns.where.mockResolvedValueOnce([ + { + id: 'wf-1', + name: 'Workflow', + description: null, + isDeployed: true, + workspaceId: 'workspace-1', + deployedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + + const result = await performCreateWorkflowMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Server', + workflowIds: ['wf-1'], + }) + + expect(result).toEqual({ success: false, error: message, errorCode: 'validation' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('rejects workflow MCP server fan-out above the per-workflow limit', async () => { vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true) dbChainMockFns.where.mockResolvedValueOnce([ diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts index bc6f114da3c..5615e4942b3 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts @@ -3,6 +3,7 @@ import { db, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, ne, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES, @@ -232,7 +233,14 @@ async function prepareWorkflowMcpTool(params: { const toolDescription = params.toolDescription !== undefined ? params.toolDescription?.trim() || null : null - const baseSchema = await generateParameterSchemaForWorkflow(workflowRecord.id) + const baseSchema = await generateParameterSchemaForWorkflow(workflowRecord.id).catch( + (error: unknown) => { + if (error instanceof OrchestrationError && error.code === 'validation') { + throw new WorkflowMcpExpectedError(error.message, 'validation') + } + throw error + } + ) const requestedOverrides = params.parameterDescriptionOverrides ?? (params.legacyParameterSchema diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index 085cae64679..88e2e942077 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -345,6 +345,37 @@ describe('validateInputsForBlock', () => { ).toBe(false) }) + describe('input-format', () => { + const config = { id: 'inputFormat', type: 'input-format' as const } + const validate = (value: unknown) => + validateValueForSubBlockType(config, value, 'inputFormat', 'start_trigger', 'start-1') + + it('stores type aliases as their canonical type and keeps untyped rows', () => { + expect( + validate([ + { name: 'payload', type: 'json' }, + { name: 'documents', type: 'files' }, + { name: 'message' }, + ]) + ).toEqual({ + valid: true, + value: [ + { name: 'payload', type: 'object' }, + { name: 'documents', type: 'file[]' }, + { name: 'message' }, + ], + }) + }) + + it('rejects a type no workflow input can declare, listing the allowed types', () => { + const result = validate([{ name: 'count', type: 'integer' }]) + expect(result.valid).toBe(false) + expect(result.error?.error).toBe( + 'Invalid type "integer" for inputFormat[0] - use one of: string, number, boolean, object, array, file[]' + ) + }) + }) + describe('model-fallback-list', () => { const config = { id: 'fallbackModels', type: 'model-fallback-list' as const } const validate = (value: unknown) => diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index a816ec9f8d0..5fcab91ce65 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -24,6 +24,10 @@ import { type SelectorReference, validateSelectorIds, } from '@/lib/workflows/editing/selector-validator' +import { + canonicalInputFormatFieldType, + INPUT_FORMAT_FIELD_TYPES, +} from '@/lib/workflows/input-format-schema' import { containsReference } from '@/lib/workflows/sanitization/references' import { getSkillById } from '@/lib/workflows/skills/operations' import { @@ -639,7 +643,44 @@ export function validateValueForSubBlockType( return { valid: true, value } } - case 'input-format': + case 'input-format': { + if (!Array.isArray(value)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid ${type} value for field "${fieldName}" - expected an array`, + }, + } + } + const rows: unknown[] = [] + for (const [index, row] of value.entries()) { + if (!isPlainRecord(row) || row.type === undefined) { + rows.push(row) + continue + } + const fieldType = + typeof row.type === 'string' ? canonicalInputFormatFieldType(row.type) : null + if (!fieldType) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid type ${JSON.stringify(row.type)} for ${fieldName}[${index}] - use one of: ${INPUT_FORMAT_FIELD_TYPES.join(', ')}`, + }, + } + } + rows.push(fieldType === row.type ? row : { ...row, type: fieldType }) + } + return { valid: true, value: rows } + } + case 'table': { // Should be an array if (!Array.isArray(value)) { diff --git a/apps/sim/lib/workflows/input-format-schema.ts b/apps/sim/lib/workflows/input-format-schema.ts index 1347ee0a32d..1f22315e482 100644 --- a/apps/sim/lib/workflows/input-format-schema.ts +++ b/apps/sim/lib/workflows/input-format-schema.ts @@ -1,10 +1,44 @@ import { z } from 'zod' +/** The input field types the editor offers and workflow runs accept. */ +export const INPUT_FORMAT_FIELD_TYPES = [ + 'string', + 'number', + 'boolean', + 'object', + 'array', + 'file[]', +] as const + +export type InputFormatFieldType = (typeof INPUT_FORMAT_FIELD_TYPES)[number] + +/** + * Every spelling a stored field's `type` may use, mapped to its canonical type: + * `json` is the name blocks use for object params and outputs, `files` the + * legacy MCP alias. + */ +const INPUT_FORMAT_FIELD_TYPES_BY_NAME = new Map([ + ...INPUT_FORMAT_FIELD_TYPES.map((type) => [type, type] as const), + ['json', 'object'], + ['files', 'file[]'], +]) + +/** + * Resolves a stored field's `type` to its canonical type, or `null` when it + * names none. A field without a type is a string field. + */ +export function canonicalInputFormatFieldType( + type: string | undefined +): InputFormatFieldType | null { + if (type === undefined) return 'string' + return INPUT_FORMAT_FIELD_TYPES_BY_NAME.get(type) ?? null +} + /** Stored fields shared by the input editor and catalog authoring contract. */ export const inputFormatFieldStateSchema = z.object({ id: z.string(), name: z.string(), - type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'file[]']), + type: z.enum(INPUT_FORMAT_FIELD_TYPES), value: z.string(), description: z.string().optional(), collapsed: z.boolean(), diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index dd0682ab302..5d25b48c18f 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -1,6 +1,9 @@ import { generateId } from '@sim/utils/id' import { isInternalFileUrl, parseInternalFileUrl } from '@/lib/uploads/utils/file' -import type { InputFormatFieldState } from '@/lib/workflows/input-format-schema' +import { + canonicalInputFormatFieldType, + type InputFormatFieldState, +} from '@/lib/workflows/input-format-schema' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' import type { UserFile } from '@/executor/types' @@ -48,7 +51,7 @@ export function createDefaultInputFormatField(): InputFormatFieldState { /** Whether a field is a canonical file array or its legacy MCP alias. */ export function isFileFieldType(type: string | null | undefined): boolean { - return type === 'file[]' || type === 'files' + return typeof type === 'string' && canonicalInputFormatFieldType(type) === 'file[]' } /** diff --git a/apps/sim/lib/workflows/input-schema.test.ts b/apps/sim/lib/workflows/input-schema.test.ts index f48566cad7a..77a408be3b7 100644 --- a/apps/sim/lib/workflows/input-schema.test.ts +++ b/apps/sim/lib/workflows/input-schema.test.ts @@ -108,9 +108,25 @@ describe('workflow input schemas', () => { expect(z.object(generateWorkflowInputShape(fields)).parse(input)).toEqual(input) }) - it('rejects unsupported type names instead of advertising strings', () => { - expect(() => generateToolInputSchema([{ name: 'files', type: 'unsupported' }])).toThrow( - 'Unsupported workflow input type "unsupported"' + it('accepts the json and files aliases as their canonical types', () => { + const shape = z.object( + generateWorkflowInputShape([ + { name: 'payload', type: 'json' }, + { name: 'documents', type: 'files' }, + ]) + ) + expect(shape.safeParse({ payload: { a: 1 }, documents: [file] }).success).toBe(true) + expect(shape.safeParse({ payload: 'text', documents: [file] }).success).toBe(false) + }) + + it('rejects unsupported type names as a validation failure naming the field', () => { + expect(() => generateToolInputSchema([{ name: 'count', type: 'unsupported' }])).toThrow( + expect.objectContaining({ + name: 'OrchestrationError', + code: 'validation', + message: + 'Workflow input "count" has unsupported type "unsupported". Use one of: string, number, boolean, object, array, file[].', + }) ) }) }) diff --git a/apps/sim/lib/workflows/input-schema.ts b/apps/sim/lib/workflows/input-schema.ts index a824e013604..f4fed3364c0 100644 --- a/apps/sim/lib/workflows/input-schema.ts +++ b/apps/sim/lib/workflows/input-schema.ts @@ -1,11 +1,16 @@ import { z } from 'zod' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { workflowFileInputSchema } from '@/lib/workflows/input-file-schema' +import { + canonicalInputFormatFieldType, + INPUT_FORMAT_FIELD_TYPES, + type InputFormatFieldType, +} from '@/lib/workflows/input-format-schema' import type { InputFormatField } from '@/lib/workflows/types' import { isSafeKey } from '@/tools/safe-assign' -function fieldTypeToSchema(type: string | undefined): z.ZodType { +function fieldTypeToSchema(type: InputFormatFieldType): z.ZodType { switch (type) { - case undefined: case 'string': return z.string() case 'number': @@ -16,17 +21,17 @@ function fieldTypeToSchema(type: string | undefined): z.ZodType { return z.record(z.string(), z.unknown()) case 'array': return z.array(z.unknown()) - case 'files': case 'file[]': return z.array(workflowFileInputSchema) - default: - throw new Error(`Unsupported workflow input type "${type}"`) } } /** * Shared by workflow run validation and MCP discovery so the advertised input * types match execution. The executor supplies configured defaults at run time. + * + * @throws OrchestrationError `validation` when a field name is reserved or its + * type is not one a workflow input can declare. */ export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.ZodRawShape { const shape: Record = {} @@ -35,12 +40,22 @@ export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.Z const name = field.name?.trim() if (!name) continue if (!isSafeKey(name)) { - throw new Error(`Workflow input name "${name}" is reserved. Rename this input field.`) + throw new OrchestrationError( + 'validation', + `Workflow input name "${name}" is reserved. Rename this input field.` + ) + } + const type = canonicalInputFormatFieldType(field.type) + if (!type) { + throw new OrchestrationError( + 'validation', + `Workflow input "${name}" has unsupported type "${field.type}". Use one of: ${INPUT_FORMAT_FIELD_TYPES.join(', ')}.` + ) } - const schema = fieldTypeToSchema(field.type).describe( + const schema = fieldTypeToSchema(type).describe( field.description?.trim() || - (field.type === 'file[]' || field.type === 'files' + (type === 'file[]' ? 'Files: use an existing workspace file ID/key, a file upload {type:"file",data:"data:;base64,...",name}, or URL {type:"url",data:"https://...",name}. Stored metadata and access are rechecked when the workflow runs.' : name) ) diff --git a/apps/sim/lib/workflows/triggers/run-options.test.ts b/apps/sim/lib/workflows/triggers/run-options.test.ts index 458c9ab28f0..327a9d49552 100644 --- a/apps/sim/lib/workflows/triggers/run-options.test.ts +++ b/apps/sim/lib/workflows/triggers/run-options.test.ts @@ -115,6 +115,20 @@ describe('file inputs through workflow run options', () => { }) }) +describe('mock payloads for aliased field types', () => { + it('samples a json field as an object the run validation accepts', () => { + const [option] = resolveTriggerRunOptions({ + start: { + type: 'start_trigger', + name: 'Start', + subBlocks: { inputFormat: { value: [{ name: 'payload', type: 'json' }] } }, + }, + }) + expect(option.mockPayload).toEqual({ payload: {} }) + expect(validateTriggerInput(option, option.mockPayload)).toEqual({ ok: true }) + }) +}) + describe('workflow input discovery schemas', () => { it('matches optional defaults and describes stored-file and upload alternatives', () => { const [option] = resolveTriggerRunOptions({ diff --git a/apps/sim/lib/workflows/triggers/run-options.ts b/apps/sim/lib/workflows/triggers/run-options.ts index 39d3b418408..bb02b73f856 100644 --- a/apps/sim/lib/workflows/triggers/run-options.ts +++ b/apps/sim/lib/workflows/triggers/run-options.ts @@ -7,6 +7,7 @@ import { normalizeInputFormatValue, parseInputFormatFiles, } from '@/lib/workflows/input-format' +import { canonicalInputFormatFieldType } from '@/lib/workflows/input-format-schema' import { generateWorkflowInputShape } from '@/lib/workflows/input-schema' import { getLegacyStarterMode, @@ -128,7 +129,7 @@ function triggerOutputsToJsonSchema(outputs: Record): Record Date: Fri, 23 Oct 2026 13:48:22 -0700 Subject: [PATCH 13/30] fix(mothership): close the scheduled-job review gaps (#9345) * fix(mothership): close the scheduled-job review gaps - dispatch one attempt at a time per occurrence, so a skip or block never closes a live run's record - brief each occurrence with the run number fixed at its claim, not the job's moving count - report an automatic turn-off only when this failure turned the job off, not one a person already turned off - mark a deadline stop after it is delivered, so a crash before then delivers it again - state the run's spend cap only where hosted billing enforces it - recover an invalid index left by an interrupted build in the job migration, as 0435 does - give PlanUsageLimitDisplay a props interface * fix(mothership): record why a run stops before delivering the stop, and redeliver one an earlier pass did not finish --- .../components/special-tags/special-tags.tsx | 6 +- apps/sim/lib/mothership/jobs/brief.ts | 10 +- apps/sim/lib/mothership/jobs/claim.ts | 2 + apps/sim/lib/mothership/jobs/dispatch.ts | 31 +++++- apps/sim/lib/mothership/jobs/finalize.ts | 12 ++- .../lib/mothership/jobs/lane.integration.ts | 99 ++++++++++++++++++- apps/sim/lib/mothership/jobs/reconcile.ts | 55 +++++++---- apps/sim/lib/mothership/jobs/runs.ts | 3 +- .../0437_scheduled_job_occurrences.sql | 28 ++++++ 9 files changed, 215 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 8c361df0f07..366d0c2c192 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -3478,8 +3478,12 @@ function JobRunBudgetDisplay({ message }: JobRunBudgetDisplayProps) { ) } +interface PlanUsageLimitDisplayProps { + data: UsageUpgradeTagData +} + /** The payer's plan limit: the card links to where the plan or the limit can change. */ -function PlanUsageLimitDisplay({ data }: { data: UsageUpgradeTagData }) { +function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) { const { data: session } = useSession() const hostContext = useOptionalWorkspaceHostContext() const organizationContext = useOptionalOrganizationContext() diff --git a/apps/sim/lib/mothership/jobs/brief.ts b/apps/sim/lib/mothership/jobs/brief.ts index 90abda091bf..bad6861c24e 100644 --- a/apps/sim/lib/mothership/jobs/brief.ts +++ b/apps/sim/lib/mothership/jobs/brief.ts @@ -9,7 +9,7 @@ const BRIEF_SUMMARY_CHARS = 400 type JobBriefSchedule = Pick< JobRun, - 'scheduleId' | 'jobTitle' | 'prompt' | 'successCondition' | 'lifecycle' | 'maxRuns' | 'runCount' + 'scheduleId' | 'jobTitle' | 'prompt' | 'successCondition' | 'lifecycle' | 'maxRuns' | 'runNumber' > /** @@ -20,7 +20,7 @@ type JobBriefSchedule = Pick< export async function buildJobBrief( schedule: JobBriefSchedule, occurrenceAt: Date, - runBudgetUsd: number + runBudgetUsd: number | null ): Promise { const recent = await db .select({ @@ -40,8 +40,8 @@ export async function buildJobBrief( .limit(BRIEF_RECENT_RUNS) const run = schedule.maxRuns - ? `Run ${schedule.runCount} of ${schedule.maxRuns}` - : `Run ${schedule.runCount}` + ? `Run ${schedule.runNumber} of ${schedule.maxRuns}` + : `Run ${schedule.runNumber}` return [ `Job: ${schedule.jobTitle ?? 'Untitled job'} (${run}, scheduled for ${occurrenceAt.toISOString()})`, `Task:\n${schedule.prompt ?? ''}`, @@ -59,7 +59,7 @@ export async function buildJobBrief( ) .join('\n')}` : 'This is the first run.', - `This run may spend up to $${runBudgetUsd.toFixed(2)}.`, + runBudgetUsd === null ? '' : `This run may spend up to $${runBudgetUsd.toFixed(2)}.`, ] .filter((part) => part.length > 0) .join('\n\n') diff --git a/apps/sim/lib/mothership/jobs/claim.ts b/apps/sim/lib/mothership/jobs/claim.ts index 39e8011b48a..e183b1a1108 100644 --- a/apps/sim/lib/mothership/jobs/claim.ts +++ b/apps/sim/lib/mothership/jobs/claim.ts @@ -84,6 +84,8 @@ export async function claimDueJobOccurrences( status: 'queued', trigger: JOB_RUN_TRIGGER, startedAt: now, + /** Fixed at claim: the schedule's count moves on as later occurrences are claimed. */ + executionData: { runNumber: runCount }, }) .onConflictDoNothing() await tx diff --git a/apps/sim/lib/mothership/jobs/dispatch.ts b/apps/sim/lib/mothership/jobs/dispatch.ts index cb4683ad478..bbbc344697f 100644 --- a/apps/sim/lib/mothership/jobs/dispatch.ts +++ b/apps/sim/lib/mothership/jobs/dispatch.ts @@ -1,10 +1,12 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' import { checkAttributedUsageLimits, resolveBillingAttribution, } from '@/lib/billing/core/attribution' import { isHosted } from '@/lib/core/config/env-flags' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { buildJobBrief } from '@/lib/mothership/jobs/brief' import { jobRunBudgetUsd } from '@/lib/mothership/jobs/budget' @@ -26,6 +28,8 @@ const logger = createLogger('MothershipJobDispatch') /** A queued occurrence whose start failed this many times ends as failed instead of retrying. */ const MAX_JOB_DISPATCH_ATTEMPTS = 5 +/** Outlasts one dispatch (its reads and the turn's admission), so an attempt never overlaps another. */ +const JOB_DISPATCH_LOCK_SECONDS = 120 type JobDispatchOutcome = | 'started' @@ -67,6 +71,27 @@ async function block(run: JobRun, summary: string): Promise * skipped rather than queued behind it. */ export async function dispatchJobOccurrence(executionId: string): Promise { + /** + * One attempt at a time per occurrence (a tick's and the reconciler's can meet): a skip or + * block decided while another attempt admits the turn would close the record of a live run. + */ + const lock = `mothership:job-dispatch:${executionId}` + const holder = generateShortId() + if (!(await acquireLock(lock, holder, JOB_DISPATCH_LOCK_SECONDS, { reclaimOnFailure: true }))) + return 'already_started' + try { + return await dispatchQueuedOccurrence(executionId) + } finally { + await releaseLock(lock, holder).catch((error) => + logger.warn('Job dispatch lock release failed; it expires', { + executionId, + error: toError(error).message, + }) + ) + } +} + +async function dispatchQueuedOccurrence(executionId: string): Promise { const run = await loadJobRun(executionId) if (!run || run.status !== 'queued' || !run.occurrenceAt) return 'not_queued' const { userId, workspaceId, chatId } = run @@ -85,7 +110,9 @@ export async function dispatchJobOccurrence(executionId: string): Promise= ${MAX_CONSECUTIVE_JOB_FAILURES} AND ${workflowSchedule.status} = 'active' THEN 'disabled' ELSE ${workflowSchedule.status} END`, updatedAt: new Date(), }) .where(eq(workflowSchedule.id, scheduleId)) - .returning({ status: workflowSchedule.status, failedCount: workflowSchedule.failedCount }) - return schedule?.status === 'disabled' && schedule.failedCount === MAX_CONSECUTIVE_JOB_FAILURES + .returning({ failedCount: workflowSchedule.failedCount }) + if (!schedule || schedule.failedCount < MAX_CONSECUTIVE_JOB_FAILURES) return false + /** Only a job still on is turned off here; one a person already turned off is not reported. */ + const disabled = await tx + .update(workflowSchedule) + .set({ status: 'disabled', updatedAt: new Date() }) + .where(and(eq(workflowSchedule.id, scheduleId), eq(workflowSchedule.status, 'active'))) + .returning({ id: workflowSchedule.id }) + return disabled.length > 0 } /** The turn's last assistant text, as the summary of a run that reported no outcome. */ diff --git a/apps/sim/lib/mothership/jobs/lane.integration.ts b/apps/sim/lib/mothership/jobs/lane.integration.ts index 2811722f461..5428c4b5b72 100644 --- a/apps/sim/lib/mothership/jobs/lane.integration.ts +++ b/apps/sim/lib/mothership/jobs/lane.integration.ts @@ -108,6 +108,10 @@ vi.mock('@/lib/mothership/jobs/reconcile', async (importOriginal) => { return { ...actual, reconcileJobRuns: vi.fn(actual.reconcileJobRuns) } }) vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +/** Spied so a test can tell an automatic turn-off, which emails the job's people, from none. */ +vi.mock('@/lib/workflows/schedules/disable-notifications', () => ({ + notifyScheduleAutoDisabled: vi.fn(async () => {}), +})) const { controllers } = vi.hoisted(() => ({ controllers: new Map() })) vi.mock('@/lib/mothership/request/session/abort', async (importOriginal) => { @@ -136,7 +140,12 @@ import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { eq, inArray, sql } from 'drizzle-orm' import { resolveBillingAttribution } from '@/lib/billing/core/attribution' -import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis' +import { + acquireLock, + closeRedisConnection, + getRedisClient, + releaseLock, +} from '@/lib/core/config/redis' import { ABANDONED_RUN_CURSOR_KEY } from '@/lib/mothership/async-runs/orphaned-runs' import { chatPubSub } from '@/lib/mothership/chat-status' import { recordJobRunSpend } from '@/lib/mothership/jobs/budget' @@ -152,6 +161,7 @@ import { chatStreamLockKey, StreamControllerSupersededError, } from '@/lib/mothership/request/session/controller-lease' +import { notifyScheduleAutoDisabled } from '@/lib/workflows/schedules/disable-notifications' interface QueryPlan { 'Node Type': string @@ -542,6 +552,88 @@ describe.runIf(Boolean(redisUrl))('the scheduled job lane', () => { worker.open.delete(occurrenceId) }, 60_000) + it('delivers a time-limit Stop an earlier pass recorded but died delivering, before finalizing', async () => { + const { scheduleId, occurrenceId } = await seedJob() + worker.legs.set(occurrenceId, [{ text: 'Still triaging. ', mode: 'hang' }]) + await runJobLane(new Date()) + await waitFor(async () => worker.open.has(occurrenceId), 'the first leg') + /** What a pass leaves when it dies after recording why the run stops, before delivering it. */ + await db + .update(jobExecutionLogs) + .set({ executionData: sql`jsonb_build_object('deadlineStopRequestedAt', now())` }) + .where(eq(jobExecutionLogs.executionId, occurrenceId)) + + await reconcileUntilSettled(occurrenceId) + + expect( + worker.requests.filter( + (request) => + request.path === '/api/streams/explicit-abort' && request.body.messageId === occurrenceId + ) + ).toHaveLength(1) + expect(await runsOf(scheduleId)).toEqual([ + expect.objectContaining({ + outcome: 'interrupted', + summary: 'Stopped at the run time limit.', + }), + ]) + worker.open.get(occurrenceId)?.response.end() + worker.open.delete(occurrenceId) + }, 60_000) + + it('does not report a job a person turned off as turned off by its failures', async () => { + vi.mocked(notifyScheduleAutoDisabled).mockClear() + const { scheduleId, occurrenceId } = await seedJob() + worker.legs.set(occurrenceId, [{ text: 'Still triaging. ', mode: 'hang' }]) + await runJobLane(new Date()) + await waitFor(async () => worker.open.has(occurrenceId), 'the first leg') + /** Off by hand with two failures behind it, while this run is still live. */ + await db + .update(workflowSchedule) + .set({ status: 'disabled', failedCount: 2 }) + .where(eq(workflowSchedule.id, scheduleId)) + + await reconcileJobRuns(new Date(Date.now() + 6 * 60 * 60_000 + 60_000)) + await reconcileUntilSettled(occurrenceId) + + expect(await schedule(scheduleId)).toMatchObject({ status: 'disabled', failedCount: 3 }) + expect(notifyScheduleAutoDisabled).not.toHaveBeenCalled() + worker.open.get(occurrenceId)?.response.end() + worker.open.delete(occurrenceId) + }, 60_000) + + it('briefs a retried occurrence with its own run number, not the job count a later claim moved', async () => { + const { scheduleId, occurrenceId } = await seedJob() + await claimDueJobOccurrences(new Date(), 10) + const later = await dueAgain(scheduleId) + await claimDueJobOccurrences(new Date(), 10) + expect(await schedule(scheduleId)).toMatchObject({ runCount: 2 }) + + for (const id of [occurrenceId, later]) { + await dispatchJobOccurrence(id) + await reconcileUntilSettled(id) + } + + expect(legsFor(occurrenceId)[0]?.body.message).toContain('(Run 1, scheduled for') + expect(legsFor(later)[0]?.body.message).toContain('(Run 2, scheduled for') + }) + + it('leaves an occurrence another attempt is dispatching to that attempt', async () => { + const { scheduleId, occurrenceId } = await seedJob() + await claimDueJobOccurrences(new Date(), 10) + const lock = `mothership:job-dispatch:${occurrenceId}` + expect(await acquireLock(lock, 'another-attempt', 60)).toBe(true) + try { + expect(await dispatchJobOccurrence(occurrenceId)).toBe('already_started') + expect(legsFor(occurrenceId)).toHaveLength(0) + expect(await runsOf(scheduleId)).toEqual([expect.objectContaining({ status: 'queued' })]) + } finally { + await releaseLock(lock, 'another-attempt') + } + expect(await dispatchJobOccurrence(occurrenceId)).toBe('started') + await reconcileUntilSettled(occurrenceId) + }) + it('runs a one-shot job once and completes it', async () => { const { scheduleId, occurrenceId } = await seedJob({ cronExpression: null }) await runJobLane(new Date()) @@ -682,6 +774,7 @@ describe.runIf(Boolean(redisUrl))('the scheduled job lane', () => { }) it('turns a job off after three failed runs in a row, and a verdict clears the streak', async () => { + vi.mocked(notifyScheduleAutoDisabled).mockClear() const { scheduleId, occurrenceId } = await seedJob() const fail = (id: string) => worker.legs.set( @@ -710,6 +803,10 @@ describe.runIf(Boolean(redisUrl))('the scheduled job lane', () => { } const outcomes = (await runsOf(scheduleId)).map((run) => run.outcome).sort() expect(outcomes).toEqual(['failed', 'failed', 'failed', 'failed', 'not_met']) + expect(notifyScheduleAutoDisabled).toHaveBeenCalledExactlyOnceWith({ + scheduleId, + reason: 'consecutive_failures', + }) await dueAgain(scheduleId) await runJobLane(new Date()) diff --git a/apps/sim/lib/mothership/jobs/reconcile.ts b/apps/sim/lib/mothership/jobs/reconcile.ts index 8274fbc2e47..41b86c5530a 100644 --- a/apps/sim/lib/mothership/jobs/reconcile.ts +++ b/apps/sim/lib/mothership/jobs/reconcile.ts @@ -46,8 +46,8 @@ const admittedBillingRequestId = sql< /** * Open records that need this pass, oldest first. Every queued record does; a running one only - * once its turn ended or its run row is gone, its time limit passed without a Stop, or its - * spend-cap link is missing, so a backlog of healthy long runs never hides the ones that finished. + * once its turn ended or its run row is gone, its time limit passed without a Stop, its Stop's + * delivery never finished, or its spend-cap link is missing, so a backlog of healthy long runs never hides the ones that finished. */ function openJobRuns(status: OpenJobRunStatus, now: Date) { const needsAction = @@ -60,6 +60,7 @@ function openJobRuns(status: OpenJobRunStatus, now: Date) { lte(jobExecutionLogs.startedAt, new Date(now.getTime() - JOB_RUN_TIME_LIMIT_MS)), sql`${jobExecutionLogs.executionData}->>'deadlineStopRequestedAt' IS NULL` ), + sql`${jobExecutionLogs.executionData}->>'deadlineStopRequestedAt' IS NOT NULL AND ${jobExecutionLogs.executionData}->>'deadlineStopDeliveredAt' IS NULL`, and(isNull(jobExecutionLogs.billingRequestId), isNotNull(admittedBillingRequestId)) ) return db @@ -70,6 +71,9 @@ function openJobRuns(status: OpenJobRunStatus, now: Date) { deadlineStopRequestedAt: sql< string | null >`${jobExecutionLogs.executionData}->>'deadlineStopRequestedAt'`, + deadlineStopDeliveredAt: sql< + string | null + >`${jobExecutionLogs.executionData}->>'deadlineStopDeliveredAt'`, billingRequestId: jobExecutionLogs.billingRequestId, admittedBillingRequestId, scheduleId: jobExecutionLogs.scheduleId, @@ -89,8 +93,9 @@ function openJobRuns(status: OpenJobRunStatus, now: Date) { * Drives every open occurrence to an end, so no crash leaves one queued or running forever: * a queued record whose dispatch was lost is dispatched again under the same id (the turn is * admitted at most once, so a retry of one that did start only finds it); a record whose turn - * ended is finalized; one past the time limit is stopped and finalized once it settles; one - * whose job was deleted before it started is ended. A run whose spend-cap link was lost (its + * ended is finalized; one past the time limit is stopped and finalized once it settles, its + * Stop delivered again first if an earlier pass died delivering it; one whose job was deleted + * before it started is ended. A run whose spend-cap link was lost (its * process died between recording the billing admission and the link) gets it back. */ export async function reconcileJobRuns(now: Date): Promise { @@ -121,15 +126,15 @@ export async function reconcileJobRuns(now: Date): Promise { if (!row.billingRequestId && row.admittedBillingRequestId) { await recordJobRunBillingRequest(row.executionId, row.admittedBillingRequestId) } - if (TERMINAL_RUN_STATUSES.includes(row.runStatus)) { - await finalizeJobRun(row.executionId) - } else if ( + const ended = TERMINAL_RUN_STATUSES.includes(row.runStatus) + /** A Stop whose delivery never finished is delivered before the run is finalized. */ + const undelivered = + row.deadlineStopRequestedAt !== null && row.deadlineStopDeliveredAt === null + const overdue = + !ended && !row.deadlineStopRequestedAt && - row.userId && - row.workspaceId && - row.chatId && now.getTime() - row.startedAt.getTime() >= JOB_RUN_TIME_LIMIT_MS - ) { + if ((undelivered || overdue) && row.userId && row.workspaceId && row.chatId) { await stopAtTimeLimit({ executionId: row.executionId, userId: row.userId, @@ -137,6 +142,7 @@ export async function reconcileJobRuns(now: Date): Promise { chatId: row.chatId, }) } + if (ended) await finalizeJobRun(row.executionId) } catch (error) { logger.warn('Job run could not be reconciled; the next pass retries', { executionId: row.executionId, @@ -161,13 +167,12 @@ async function stopAtTimeLimit(run: { }) /** A run outside the job's scope records no Stop, so nothing is delivered to it. */ if (!stopped || stopped.workspaceId !== run.workspaceId || stopped.chatId !== run.chatId) return - /** Marked once the Stop is durable, so a pass that fails before it stops the run again. */ - await db - .update(jobExecutionLogs) - .set({ - executionData: sql`jsonb_set(${jobExecutionLogs.executionData}, '{deadlineStopRequestedAt}', to_jsonb(now()))`, - }) - .where(eq(jobExecutionLogs.executionId, run.executionId)) + /** + * Why the run stopped is recorded before delivery, so its summary survives a crash; delivery + * is marked separately once done, and a pass that finds it unmarked delivers again (requesting + * and delivering a Stop are idempotent), so the run's client workflows and sandbox work end. + */ + await markDeadlineStop(run.executionId, 'deadlineStopRequestedAt') await deliverRunStop( { id: stopped.id, @@ -178,5 +183,19 @@ async function stopAtTimeLimit(run: { }, readStoppedRunWork(run.executionId, run.userId) ) + await markDeadlineStop(run.executionId, 'deadlineStopDeliveredAt') logger.info('Stopped a job run at its time limit', { executionId: run.executionId }) } + +/** Records a step of the time-limit Stop once; a repeated pass keeps the first time. */ +async function markDeadlineStop( + executionId: string, + step: 'deadlineStopRequestedAt' | 'deadlineStopDeliveredAt' +): Promise { + await db + .update(jobExecutionLogs) + .set({ + executionData: sql`jsonb_set(${jobExecutionLogs.executionData}, ${`{${step}}`}::text[], COALESCE(${jobExecutionLogs.executionData}->${step}, to_jsonb(now())))`, + }) + .where(eq(jobExecutionLogs.executionId, executionId)) +} diff --git a/apps/sim/lib/mothership/jobs/runs.ts b/apps/sim/lib/mothership/jobs/runs.ts index 53f4917ac72..93359b848b5 100644 --- a/apps/sim/lib/mothership/jobs/runs.ts +++ b/apps/sim/lib/mothership/jobs/runs.ts @@ -40,7 +40,8 @@ export async function loadJobRun(executionId: string) { successCondition: workflowSchedule.successCondition, lifecycle: workflowSchedule.lifecycle, maxRuns: workflowSchedule.maxRuns, - runCount: workflowSchedule.runCount, + /** Records claimed before the run number was stored fall back to the schedule's count. */ + runNumber: sql`COALESCE((${jobExecutionLogs.executionData}->>'runNumber')::int, ${workflowSchedule.runCount})`, runBudgetUsd: workflowSchedule.runBudgetUsd, userId: workflowSchedule.sourceUserId, workspaceId: workflowSchedule.sourceWorkspaceId, diff --git a/packages/db/migrations/0437_scheduled_job_occurrences.sql b/packages/db/migrations/0437_scheduled_job_occurrences.sql index cc047db1815..0c480351727 100644 --- a/packages/db/migrations/0437_scheduled_job_occurrences.sql +++ b/packages/db/migrations/0437_scheduled_job_occurrences.sql @@ -5,9 +5,37 @@ ALTER TABLE "job_execution_logs" ADD COLUMN IF NOT EXISTS "outcome" text;--> sta ALTER TABLE "job_execution_logs" ADD COLUMN IF NOT EXISTS "summary" text;--> statement-breakpoint ALTER TABLE "job_execution_logs" ADD COLUMN IF NOT EXISTS "billing_request_id" text;--> statement-breakpoint ALTER TABLE "workflow_schedule" ADD COLUMN IF NOT EXISTS "run_budget_usd" numeric(12, 4);--> statement-breakpoint +-- A failed concurrent build leaves an invalid index that IF NOT EXISTS would keep: each replay +-- moves one aside to be dropped, never a valid index or one on another table. +DO $$ +DECLARE + index_name text; +BEGIN + FOREACH index_name IN ARRAY ARRAY[ + 'job_execution_logs_schedule_occurrence_unique', + 'job_execution_logs_billing_request_unique', + 'job_execution_logs_open_idx' + ] LOOP + IF EXISTS ( + SELECT 1 FROM pg_index WHERE indexrelid = to_regclass(index_name || '_failed') + AND (indisvalid OR indrelid <> to_regclass('job_execution_logs')) + ) THEN + RAISE EXCEPTION 'Refusing to drop unexpected recovery index %_failed', index_name; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_index WHERE indexrelid = to_regclass(index_name) + AND indrelid = to_regclass('job_execution_logs') AND NOT indisvalid + ) THEN + EXECUTE format('ALTER INDEX %I RENAME TO %I', index_name, index_name || '_failed'); + END IF; + END LOOP; +END $$;--> statement-breakpoint COMMIT;--> statement-breakpoint SET lock_timeout = 0;--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "job_execution_logs_schedule_occurrence_unique_failed";--> statement-breakpoint CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "job_execution_logs_schedule_occurrence_unique" ON "job_execution_logs" USING btree ("schedule_id","occurrence_at");--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "job_execution_logs_billing_request_unique_failed";--> statement-breakpoint CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "job_execution_logs_billing_request_unique" ON "job_execution_logs" USING btree ("billing_request_id") WHERE "job_execution_logs"."billing_request_id" IS NOT NULL;--> statement-breakpoint +DROP INDEX CONCURRENTLY IF EXISTS "job_execution_logs_open_idx_failed";--> statement-breakpoint CREATE INDEX CONCURRENTLY IF NOT EXISTS "job_execution_logs_open_idx" ON "job_execution_logs" USING btree ("started_at") WHERE "job_execution_logs"."status" IN ('queued', 'running');--> statement-breakpoint SET lock_timeout = '5s'; From 42f340992077d251b9f11514249641c65c130d96 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 14:17:18 -0700 Subject: [PATCH 14/30] fix(db): isolate billing and auth connection pools, release the redeploy-check transaction early, cache integration tool schemas (#9347) * fix(db): give billing and auth their own connection pools postgres.js pipelines new queries onto busy connections when none is idle, so slow reads saturating the process pool made short usage-ledger transactions wait seconds for BEGIN until Postgres killed them. - Add `billing` (4) and `auth` (3) dbFor roles, with DATABASE_URL_BILLING and DATABASE_URL_AUTH overrides falling back to the process URL. - Billing pool: recordCumulativeUsage, recordUsage without a caller tx, the execution logger's usage reconciliation, both threshold settlement transactions, and recordJobRunSpend. Each moved transaction only queries through its tx handle; readers stay on the process pool. - Auth pool: getAuthPool() backs the Better Auth adapter, getAuthDatabase() outside a transaction, the OAuth adapter guard defaults, and the API-key hash lookup. - isPrimaryPoolClient() in @sim/db replaces the `executor === db` checks in the security, session, and SSO policy caches, so reads through the auth pool keep hitting them instead of costing two queries per cached session. - Integration test saturates the process pool and requires the billing write, an adapter session read, and an API-key lookup to finish anyway. * fix(workflows): compare deployment states after the snapshot transaction commits checkNeedsRedeployment ran the full workflow diff inside its REPEATABLE READ transaction, holding a pooled connection idle in transaction during CPU work. Reads (active version, normalized draft, variables, credential migrations) stay in the snapshot; the pure diff now runs after commit. * fix(mothership): build integration tool schemas once per process instead of per user every 5s The integration catalog cached each viewer's full schema set for 5s, so nearly every call rebuilt a user-tool schema for all ~5.3k exposed tools, sized the ~7.7MB result with JSON.stringify, structuredClone'd it, and re-tokenized every tool for BM25 - all synchronously. The schemas depend only on the registry and deployment config, so they are now built once per (surface, personal mode), frozen, and shared; the build yields between tools. Each request still applies the viewer's own block visibility and permission policy, and the plan-dependent email tagline is read per user with the same 5s TTL. Results are byte-identical. BM25 term counts are memoized per shared schema object. * refactor(db): tighten pool-isolation follow-ups Assert each frozen-schema mutation throws, rank BM25 by query terms instead of copying each tool's vocabulary, gate the reporting usage cache on isPrimaryPoolClient like the auth caches, and trim comments. * fix(api-key): read workspace key settings on the auth pool Give the memory harness integration mock a dbFor so the billing pool resolves. * test(db): close every pool the isolation suite opens --- .../billing/update-cost/replay.live.test.ts | 2 +- .../billing/update-cost/worker.live.test.ts | 2 +- .../agent/memory-harness.integration.ts | 9 +- apps/sim/lib/api-key/service.ts | 5 +- apps/sim/lib/auth/database-context.ts | 11 +- .../lib/auth/oauth-provider-adapter-guard.ts | 7 +- apps/sim/lib/auth/security-policy.test.ts | 38 ++- apps/sim/lib/auth/security-policy.ts | 10 +- apps/sim/lib/auth/session-policy.ts | 6 +- apps/sim/lib/auth/sim-auth-adapter.ts | 5 +- apps/sim/lib/auth/sso-policy.ts | 6 +- .../core/reporting-usage-cache.integration.ts | 10 +- .../lib/billing/core/reporting-usage-cache.ts | 4 +- .../lib/billing/core/usage-log.integration.ts | 2 +- apps/sim/lib/billing/core/usage-log.ts | 6 +- apps/sim/lib/billing/threshold-billing.ts | 6 +- apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/db/pool-isolation.integration.ts | 145 ++++++++++++ apps/sim/lib/integrations/tool-projection.ts | 16 -- apps/sim/lib/logs/execution/logger.ts | 2 +- apps/sim/lib/mothership/block-visibility.ts | 12 - apps/sim/lib/mothership/chat/payload.test.ts | 100 +++++--- apps/sim/lib/mothership/chat/payload.ts | 223 ++++++++++-------- .../integrations/application/catalog.ts | 32 ++- apps/sim/lib/mothership/jobs/budget.ts | 4 +- .../lib/workflows/deployment-status.test.ts | 77 ++++++ apps/sim/lib/workflows/deployment-status.ts | 14 +- apps/sim/lib/workspaces/utils.ts | 7 +- packages/db/db.test.ts | 36 +++ packages/db/db.ts | 14 ++ packages/db/pool-profiles.ts | 8 + packages/testing/src/mocks/database.mock.ts | 7 +- .../src/mocks/schema-tables.generated.ts | 10 + 33 files changed, 622 insertions(+), 216 deletions(-) create mode 100644 apps/sim/lib/db/pool-isolation.integration.ts create mode 100644 apps/sim/lib/workflows/deployment-status.test.ts create mode 100644 packages/db/db.test.ts diff --git a/apps/sim/app/api/billing/update-cost/replay.live.test.ts b/apps/sim/app/api/billing/update-cost/replay.live.test.ts index 44d50a6e642..2d7be627d8f 100644 --- a/apps/sim/app/api/billing/update-cost/replay.live.test.ts +++ b/apps/sim/app/api/billing/update-cost/replay.live.test.ts @@ -47,7 +47,7 @@ vi.mock('@sim/db', async () => { }) state.client = client const db = drizzle(client) - return { db, dbReplica: db } + return { db, dbReplica: db, dbFor: () => db } }) /** Subscription and payment-provider fixtures; callback, ledger and replay code are real. */ diff --git a/apps/sim/app/api/billing/update-cost/worker.live.test.ts b/apps/sim/app/api/billing/update-cost/worker.live.test.ts index 9729f86ffa1..d62914d9b86 100644 --- a/apps/sim/app/api/billing/update-cost/worker.live.test.ts +++ b/apps/sim/app/api/billing/update-cost/worker.live.test.ts @@ -47,7 +47,7 @@ vi.mock('@sim/db', async () => { }) state.client = client const db = drizzle(client) - return { db, dbReplica: db } + return { db, dbReplica: db, dbFor: () => db } }) /** Subscription and payment-provider fixtures; callback, ledger and replay code are real. */ diff --git a/apps/sim/executor/handlers/agent/memory-harness.integration.ts b/apps/sim/executor/handlers/agent/memory-harness.integration.ts index af2b350d8a7..c6fd1683db0 100644 --- a/apps/sim/executor/handlers/agent/memory-harness.integration.ts +++ b/apps/sim/executor/handlers/agent/memory-harness.integration.ts @@ -30,8 +30,8 @@ const fixture = vi.hoisted(() => ({ uploads: '', })) -vi.mock('@sim/db', () => ({ - db: new Proxy( +vi.mock('@sim/db', () => { + const db = new Proxy( {}, { get(_target, property) { @@ -40,8 +40,9 @@ vi.mock('@sim/db', () => ({ return typeof value === 'function' ? value.bind(fixture.database) : value }, } - ), -})) + ) + return { db, dbFor: () => db } +}) vi.mock('@/lib/uploads/core/setup.server', () => ({ get UPLOAD_DIR_SERVER() { return fixture.uploads diff --git a/apps/sim/lib/api-key/service.ts b/apps/sim/lib/api-key/service.ts index d8edaa27d00..8dbe94cf520 100644 --- a/apps/sim/lib/api-key/service.ts +++ b/apps/sim/lib/api-key/service.ts @@ -3,6 +3,7 @@ import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema' import { createLogger, setRequestAuth } from '@sim/logger' import { and, eq, isNull, lt, or } from 'drizzle-orm' import { hashApiKey } from '@/lib/api-key/crypto' +import { getAuthPool } from '@/lib/auth/database-context' import { createDetachedTouch } from '@/lib/core/utils/background' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils' @@ -55,14 +56,14 @@ export async function authenticateApiKeyFromHeader( let workspaceSettings: WorkspaceBillingSettings | null = null if (options.workspaceId) { - workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId) + workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId, getAuthPool()) if (!workspaceSettings) { return { success: false, error: 'Workspace not found' } } } const keyHash = hashApiKey(apiKeyHeader) - const rows: HashCandidate[] = await db + const rows: HashCandidate[] = await getAuthPool() .select({ id: apiKeyTable.id, userId: apiKeyTable.userId, diff --git a/apps/sim/lib/auth/database-context.ts b/apps/sim/lib/auth/database-context.ts index 5c1326dd004..bb55187da3d 100644 --- a/apps/sim/lib/auth/database-context.ts +++ b/apps/sim/lib/auth/database-context.ts @@ -1,12 +1,17 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import type { DbOrTx } from '@/lib/db/types' const authDatabase = new AsyncLocalStorage() -/** The Drizzle executor backing the current Better Auth transaction, or the root database. */ +/** The auth pool's root client (see `DB_POOL_PROFILES.auth`). */ +export function getAuthPool() { + return dbFor('auth') +} + +/** The Drizzle executor backing the current Better Auth transaction, or the auth pool. */ export function getAuthDatabase(): DbOrTx { - return authDatabase.getStore() ?? db + return authDatabase.getStore() ?? getAuthPool() } /** Keeps database hooks on the adapter's connection so they can see uncommitted auth records. */ diff --git a/apps/sim/lib/auth/oauth-provider-adapter-guard.ts b/apps/sim/lib/auth/oauth-provider-adapter-guard.ts index 72bae75102c..2ca707e0e34 100644 --- a/apps/sim/lib/auth/oauth-provider-adapter-guard.ts +++ b/apps/sim/lib/auth/oauth-provider-adapter-guard.ts @@ -1,10 +1,11 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import { db } from '@sim/db' +import type { db } from '@sim/db' import { oauthConsent, oauthTokenFamily } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import type { drizzleAdapter } from 'better-auth/adapters/drizzle' import { APIError } from 'better-auth/api' import { inArray } from 'drizzle-orm' +import { getAuthPool } from '@/lib/auth/database-context' import { getOAuthIssuedResource } from '@/lib/auth/oauth-resource' import { capabilityRefusal } from '@/lib/permission-groups/capabilities' import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' @@ -37,7 +38,7 @@ async function deleteTrackedFamilies( */ export async function withOAuthProviderIssuanceCompensation( work: () => Promise, - database: AuthDatabase = db + database: AuthDatabase = getAuthPool() ): Promise { const familyIds = new Set() try { @@ -87,7 +88,7 @@ function requireConsentInsert(data: Record): OAuthConsentInsert */ export function guardOAuthProviderWrites( adapter: BetterAuthAdapter, - database: AuthDatabase = db + database: AuthDatabase = getAuthPool() ): BetterAuthAdapter { return { ...adapter, diff --git a/apps/sim/lib/auth/security-policy.test.ts b/apps/sim/lib/auth/security-policy.test.ts index be96063c022..d4b97c6ec55 100644 --- a/apps/sim/lib/auth/security-policy.test.ts +++ b/apps/sim/lib/auth/security-policy.test.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getMemberOrganizationId, getSecurityPolicyVersion, @@ -146,3 +146,37 @@ describe('getSessionCookieCacheVersion', () => { expect(limit).toHaveBeenCalledTimes(2) }) }) + +describe('shared caches behind a sub-pool client', () => { + beforeEach(() => { + resetDbChainMock() + invalidateMembershipCache('pool-user') + invalidateSecurityPolicyVersionCache('pool-org') + }) + + afterEach(() => { + databaseMockFns.mockIsPrimaryPoolClient.mockReset() + }) + + /** + * Better Auth reads the cookie-cache version on every cached-session request through the auth + * pool, not `db`; caching only reads made through `db` would cost two queries per request. + */ + it('serves a repeat cookie-cache version from cache when read through the auth pool', async () => { + const authPool: typeof db = Object.create(db) + databaseMockFns.mockIsPrimaryPoolClient.mockImplementation( + (executor) => executor === db || executor === authPool + ) + dbChainMockFns.limit + .mockResolvedValueOnce([{ organizationId: 'pool-org' }]) + .mockResolvedValueOnce([{ version: 3 }]) + + await expect(getSessionCookieCacheVersion({ userId: 'pool-user' }, authPool)).resolves.toBe( + 'pool-org:3' + ) + await expect(getSessionCookieCacheVersion({ userId: 'pool-user' }, authPool)).resolves.toBe( + 'pool-org:3' + ) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/auth/security-policy.ts b/apps/sim/lib/auth/security-policy.ts index 354bd531901..8b8e455e289 100644 --- a/apps/sim/lib/auth/security-policy.ts +++ b/apps/sim/lib/auth/security-policy.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, isPrimaryPoolClient } from '@sim/db' import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' @@ -50,7 +50,7 @@ export async function getSecurityPolicyVersion( ): Promise { if (!organizationId) return DEFAULT_VERSION - const cached = executor === db ? versionCache.get(organizationId) : undefined + const cached = isPrimaryPoolClient(executor) ? versionCache.get(organizationId) : undefined if (cached !== undefined) return cached try { @@ -61,7 +61,7 @@ export async function getSecurityPolicyVersion( .limit(1) const version = row?.version ?? DEFAULT_VERSION - if (executor === db) versionCache.set(organizationId, version) + if (isPrimaryPoolClient(executor)) versionCache.set(organizationId, version) return version } catch (error) { logger.error('Failed to resolve security policy version; using default', { @@ -133,7 +133,7 @@ export async function getMemberOrganizationId( if (!userId) return null /** A transaction may have just created or moved the membership; shared cache entries cannot see it. */ - const cached = executor === db ? membershipCache.get(userId) : undefined + const cached = isPrimaryPoolClient(executor) ? membershipCache.get(userId) : undefined if (cached) return cached.organizationId try { @@ -144,7 +144,7 @@ export async function getMemberOrganizationId( .limit(1) const organizationId = row?.organizationId ?? null - if (executor === db) { + if (isPrimaryPoolClient(executor)) { membershipCache.set(userId, { organizationId }, { ttl: membershipCacheTtlMs(organizationId) }) } return organizationId diff --git a/apps/sim/lib/auth/session-policy.ts b/apps/sim/lib/auth/session-policy.ts index 8508d9caa6f..0f28b9e53e5 100644 --- a/apps/sim/lib/auth/session-policy.ts +++ b/apps/sim/lib/auth/session-policy.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, isPrimaryPoolClient } from '@sim/db' import type { SessionPolicySettings } from '@sim/db/schema' import { organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -57,7 +57,7 @@ export async function getSessionPolicy( if (!organizationId) return NO_POLICY /** Uncommitted policy reads must neither consume nor populate the shared cache. */ - const cached = executor === db ? policyCache.get(organizationId) : undefined + const cached = isPrimaryPoolClient(executor) ? policyCache.get(organizationId) : undefined if (cached) return cached try { @@ -78,7 +78,7 @@ export async function getSessionPolicy( idleTimeoutHours: settings.idleTimeoutHours ?? null, } : NO_POLICY - if (executor === db) policyCache.set(organizationId, policy) + if (isPrimaryPoolClient(executor)) policyCache.set(organizationId, policy) return policy } catch (error) { logger.error('Failed to resolve session policy; applying no policy', { diff --git a/apps/sim/lib/auth/sim-auth-adapter.ts b/apps/sim/lib/auth/sim-auth-adapter.ts index b2bebd08c7c..741a211debd 100644 --- a/apps/sim/lib/auth/sim-auth-adapter.ts +++ b/apps/sim/lib/auth/sim-auth-adapter.ts @@ -1,10 +1,9 @@ -import { db } from '@sim/db' import * as schema from '@sim/db/schema' import type { BetterAuthOptions } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { APIError } from 'better-auth/api' import { and, eq } from 'drizzle-orm' -import { runWithAuthDatabase } from '@/lib/auth/database-context' +import { getAuthPool, runWithAuthDatabase } from '@/lib/auth/database-context' import { type AuthDatabase, guardOAuthProviderWrites, @@ -66,7 +65,7 @@ function createTransactionAdapter( */ export function createSimAuthAdapter( options: BetterAuthOptions, - database: AuthDatabase = db + database: AuthDatabase = getAuthPool() ): BetterAuthAdapter { const guarded = createGuardedAdapter(options, database) const transaction: BetterAuthAdapter['transaction'] = (callback) => diff --git a/apps/sim/lib/auth/sso-policy.ts b/apps/sim/lib/auth/sso-policy.ts index d2db3ace8d8..0fa8c32d267 100644 --- a/apps/sim/lib/auth/sso-policy.ts +++ b/apps/sim/lib/auth/sso-policy.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, isPrimaryPoolClient } from '@sim/db' import { organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { APIError } from 'better-auth/api' @@ -43,7 +43,7 @@ export async function isSsoRequiredForOrganization( if (!organizationId) return false /** Uncommitted reads must neither consume nor populate the shared cache. */ - const cached = executor === db ? requirementCache.get(organizationId) : undefined + const cached = isPrimaryPoolClient(executor) ? requirementCache.get(organizationId) : undefined if (cached !== undefined) return cached const [row] = await executor @@ -64,7 +64,7 @@ export async function isSsoRequiredForOrganization( onError: 'throw', })) && (await hasSignInCapableSsoProvider(organizationId, executor)) - if (executor === db) requirementCache.set(organizationId, required) + if (isPrimaryPoolClient(executor)) requirementCache.set(organizationId, required) return required } diff --git a/apps/sim/lib/billing/core/reporting-usage-cache.integration.ts b/apps/sim/lib/billing/core/reporting-usage-cache.integration.ts index 062c9de0f98..c34b1f761a9 100644 --- a/apps/sim/lib/billing/core/reporting-usage-cache.integration.ts +++ b/apps/sim/lib/billing/core/reporting-usage-cache.integration.ts @@ -23,7 +23,15 @@ const { transaction, summedPeriods } = vi.hoisted(() => ({ const databaseUrl = readTestDatabaseUrl() const redisUrl = readTestRedisUrl() -vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} })) +vi.mock('@sim/db', () => { + const db = { transaction } + return { + db, + dbFor: () => ({ transaction }), + dbReplica: {}, + isPrimaryPoolClient: (executor: object) => executor === db, + } +}) vi.mock('@/lib/core/config/redis', () => redisConfigMock) vi.mock('@/lib/billing/core/usage-log', async (importOriginal) => { const actual = await importOriginal() diff --git a/apps/sim/lib/billing/core/reporting-usage-cache.ts b/apps/sim/lib/billing/core/reporting-usage-cache.ts index 60dc97d23fe..507cb78066f 100644 --- a/apps/sim/lib/billing/core/reporting-usage-cache.ts +++ b/apps/sim/lib/billing/core/reporting-usage-cache.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, isPrimaryPoolClient } from '@sim/db' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -417,7 +417,7 @@ export function readSoftGateUsageCost( period: UsageQueryPeriod, executor: DbClient = db ): Promise { - if (isReportingPeriod(period) && executor === db) { + if (isReportingPeriod(period) && isPrimaryPoolClient(executor)) { return readCachedReportingUsageCost(entity, period) } return getBillingPeriodUsageCost(entity, period, undefined, executor) diff --git a/apps/sim/lib/billing/core/usage-log.integration.ts b/apps/sim/lib/billing/core/usage-log.integration.ts index b6866e49665..4f91dad0d5b 100644 --- a/apps/sim/lib/billing/core/usage-log.integration.ts +++ b/apps/sim/lib/billing/core/usage-log.integration.ts @@ -19,7 +19,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites const { transaction } = vi.hoisted(() => ({ transaction: vi.fn() })) const databaseUrl = readTestDatabaseUrl() -vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} })) +vi.mock('@sim/db', () => ({ db: { transaction }, dbFor: () => ({ transaction }), dbReplica: {} })) vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: vi.fn() })) vi.mock('@/lib/billing/subscriptions/utils', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 2c29a25e4f1..837ab470c00 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { db, dbReplica } from '@sim/db' +import { db, dbFor, dbReplica } from '@sim/db' import { subscription as subscriptionTable, usageLog, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toNumberOrNull } from '@sim/utils/coerce' @@ -475,7 +475,7 @@ export async function recordUsage(params: RecordUsageParams): Promise { const context = await resolveBillingContext(userId, billingEntity, billingPeriod) - const insertedRows = await (tx ?? db) + const insertedRows = await (tx ?? dbFor('billing')) .insert(usageLog) .values( validEntries.map((entry, index) => { @@ -842,7 +842,7 @@ export async function recordCumulativeUsage( } try { - const result = await db.transaction(async (tx) => { + const result = await dbFor('billing').transaction(async (tx) => { enterStage('configure') await tx.execute(sql` select diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 1f50d5b2050..afb72fb6a36 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' +import { db, dbFor } from '@sim/db' import { member, organization, subscription, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' @@ -343,7 +343,7 @@ export async function checkAndBillOverageThreshold( const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) const totalOverageCents = Math.round(currentOverage * 100) - const billedResult = await db.transaction( + const billedResult = await dbFor('billing').transaction( async (tx): Promise => { await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`)) @@ -678,7 +678,7 @@ async function checkAndBillOrganizationOverageThreshold( const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) const totalOverageCents = Math.round(currentOverage * 100) - const orgBilledResult = await db.transaction( + const orgBilledResult = await dbFor('billing').transaction( async ( tx ): Promise< diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 423c949174b..0157cf43222 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -121,6 +121,8 @@ export const env = createEnv({ DATABASE_URL_REALTIME: z.string().url().optional(), // Per-role primary URL override (realtime) DATABASE_URL_CLEANUP: z.string().url().optional(), // Sub-process pool URL override (cleanup jobs, via dbFor) DATABASE_URL_EXEC: z.string().url().optional(), // Sub-process pool URL override (inline execution writes, via dbFor) + DATABASE_URL_BILLING: z.string().url().optional(), // Sub-process pool URL override (usage-ledger writes, via dbFor) + DATABASE_URL_AUTH: z.string().url().optional(), // Sub-process pool URL override (session and API-key lookups, via dbFor) DATABASE_REPLICA_URL_WEB: z.string().url().optional(), // Per-role replica URL override; falls back to DATABASE_REPLICA_URL DATABASE_REPLICA_URL_TRIGGER: z.string().url().optional(), // Per-role replica URL override (trigger) DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), // Per-role replica URL override (realtime) diff --git a/apps/sim/lib/db/pool-isolation.integration.ts b/apps/sim/lib/db/pool-isolation.integration.ts new file mode 100644 index 00000000000..d21c48fc952 --- /dev/null +++ b/apps/sim/lib/db/pool-isolation.integration.ts @@ -0,0 +1,145 @@ +/** + * Real `@sim/db` pools against the integration database: with every connection of the process + * pool stuck behind a held advisory lock, usage-ledger writes and auth lookups must still finish + * on their own pools. postgres.js pipelines new work onto busy connections when none is idle, so + * anything still on the process pool waits for the lock instead. + */ +import { db, dbFor } from '@sim/db' +import { session, user } from '@sim/db/schema' +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { randomInt } from '@sim/utils/random' +import { eq, sql } from 'drizzle-orm' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { authenticateApiKeyFromHeader } from '@/lib/api-key/service' +import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter' +import { recordCumulativeUsage } from '@/lib/billing/core/usage-log' + +/** Covers a new pool's first connect; a stalled operation waits for the lock, which never comes. */ +const ISOLATED_BUDGET_MS = 1_500 +const SATURATION_PROBE_MS = 300 + +const control = postgres(readTestDatabaseUrl(), { max: 1, onnotice: () => {} }) +const lockKey = randomInt(1, 2 ** 31) +const userId = `pool-isolation-${generateId()}` +const sessionToken = `pool-isolation-${generateId()}` +const now = new Date() + +let saturation: Promise | undefined + +async function waitForSaturatedProcessPool(connections: number): Promise { + for (let attempt = 0; attempt < 400; attempt++) { + const [{ waiting }] = await control<{ waiting: number }[]>` + SELECT count(*)::int AS waiting FROM pg_stat_activity + WHERE datname = current_database() AND wait_event = 'advisory' + AND query LIKE '%pg_advisory_xact_lock_shared%'` + if (waiting >= connections) return + await sleep(5) + } + throw new Error('The process pool never filled with lock waiters') +} + +/** Resolves `'done'` when `work` settles within the budget, `'stalled'` otherwise. */ +function settlesWithin(work: Promise, budgetMs: number): Promise<'done' | 'stalled'> { + return Promise.race([ + work.then(() => 'done' as const), + sleep(budgetMs).then(() => 'stalled' as const), + ]) +} + +beforeAll(async () => { + await db.insert(user).values({ + id: userId, + name: 'Pool isolation', + email: `${userId}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(session).values({ + id: generateId(), + token: sessionToken, + userId, + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), + createdAt: now, + updatedAt: now, + }) + + await control`SELECT pg_advisory_lock(${lockKey})` + const connections = db.$client.options.max + saturation = Promise.all( + Array.from({ length: connections }, () => + db.execute(sql`SELECT pg_advisory_xact_lock_shared(${lockKey})`) + ) + ) + await waitForSaturatedProcessPool(connections) +}) + +afterAll(async () => { + try { + await control`SELECT pg_advisory_unlock(${lockKey})` + await saturation + await db.delete(user).where(eq(user.id, userId)) + } finally { + await Promise.all([ + db.$client.end(), + dbFor('billing').$client.end(), + dbFor('auth').$client.end(), + control.end(), + ]) + } +}) + +describe('dedicated pools under a saturated process pool', () => { + it('leaves the process pool unable to serve a new query', async () => { + await expect(settlesWithin(db.execute(sql`SELECT 1`), SATURATION_PROBE_MS)).resolves.toBe( + 'stalled' + ) + }) + + it('records cumulative usage on the billing pool', async () => { + const write = recordCumulativeUsage({ + userId, + billingEntity: { type: 'user', id: userId }, + billingPeriod: { + start: new Date('2026-10-01T00:00:00Z'), + end: new Date('2026-11-01T00:00:00Z'), + }, + source: 'workflow', + model: 'pool-isolation-model', + cost: 0.25, + eventKey: `pool-isolation:${generateId()}`, + }) + + await expect(settlesWithin(write, ISOLATED_BUDGET_MS)).resolves.toBe('done') + await expect(write).resolves.toMatchObject({ billed: true, total: 0.25 }) + }) + + it('reads a session through the auth adapter on the auth pool', async () => { + const lookup = createSimAuthAdapter({}).findOne<{ userId: string }>({ + model: 'session', + where: [{ field: 'token', value: sessionToken }], + }) + + await expect(settlesWithin(lookup, ISOLATED_BUDGET_MS)).resolves.toBe('done') + await expect(lookup).resolves.toMatchObject({ userId }) + }) + + it('looks up an API key on the auth pool', async () => { + const lookup = authenticateApiKeyFromHeader(`sk-sim-${generateId()}`) + + await expect(settlesWithin(lookup, ISOLATED_BUDGET_MS)).resolves.toBe('done') + await expect(lookup).resolves.toEqual({ success: false, error: 'Invalid API key' }) + }) + + it('reads workspace key settings on the auth pool', async () => { + const lookup = authenticateApiKeyFromHeader(`sk-sim-${generateId()}`, { + workspaceId: generateId(), + }) + + await expect(settlesWithin(lookup, ISOLATED_BUDGET_MS)).resolves.toBe('done') + await expect(lookup).resolves.toEqual({ success: false, error: 'Workspace not found' }) + }) +}) diff --git a/apps/sim/lib/integrations/tool-projection.ts b/apps/sim/lib/integrations/tool-projection.ts index 085d4ce0608..a7fa47c02da 100644 --- a/apps/sim/lib/integrations/tool-projection.ts +++ b/apps/sim/lib/integrations/tool-projection.ts @@ -76,22 +76,6 @@ export function projectIntegrationToolsForViewer( return { tools, allowedBlockTypes, isToolAllowed } } -/** - * Stable signature of the policy {@link projectIntegrationToolsForViewer} reads, - * for keying caches whose contents depend on the projection. - * - * The projection is only as fresh as what keys it: an entry cached under a - * viewer's identity alone outlives the policy that produced it, so an admin's - * change would not take effect until the entry expired. Mirrors - * `visibilitySignature`, which does the same job for block visibility. - */ -export function integrationGateSignature(config: IntegrationGateConfig | null | undefined): string { - return JSON.stringify([ - config?.allowedIntegrations ? [...config.allowedIntegrations].sort() : null, - config?.deniedTools?.length ? [...config.deniedTools].sort() : null, - ]) -} - /** What a viewer's `deniedTools` denylist costs the block schemas they are shown. */ export interface DeniedBlockOperations { /** diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 0c26186d937..e1bdc8e0e10 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -1773,7 +1773,7 @@ export class ExecutionLogger { // flow and only matters under a cross-process double-completion of the // same execution, where it stops a stale already-billed read from // dropping the larger delta. - await db.transaction(async (tx) => { + await dbFor('billing').transaction(async (tx) => { await tx.execute( sql`select set_config('lock_timeout', ${`${USAGE_RECONCILE_LOCK_TIMEOUT_MS}ms`}, true)` ) diff --git a/apps/sim/lib/mothership/block-visibility.ts b/apps/sim/lib/mothership/block-visibility.ts index f35e787de45..430fe099fe9 100644 --- a/apps/sim/lib/mothership/block-visibility.ts +++ b/apps/sim/lib/mothership/block-visibility.ts @@ -46,15 +46,3 @@ export function getBlockVisibilityForCopilot( } return promise } - -/** - * Stable signature of a visibility state, for keying caches whose contents - * depend on the gated projection (e.g. the integration tool-schema LRU). - */ -export function visibilitySignature(vis: BlockVisibilityState): string { - return JSON.stringify([ - [...vis.revealed].sort(), - [...vis.disabled].sort(), - [...vis.previewTagged].sort(), - ]) -} diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts index 61fef4c6fcc..d4c5107a772 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -22,7 +22,7 @@ import { workspaceFileManagerMock, workspaceFileManagerMockFns, } from '@sim/testing/mocks/workspace-file-manager.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getExposedIntegrationTools } from '@/lib/integrations/tool-catalog' import { ChatPayloadSchema, TOOL_DEADLINE_CAPABILITY } from '@/lib/mothership/generated/protocol' import { searchUsersV2Tool } from '@/tools/github/search_users' @@ -128,7 +128,6 @@ vi.mock('@/lib/mothership/block-visibility', () => ({ disabled: new Set(), previewTagged: new Set(), })), - visibilitySignature: vi.fn(() => 'vis:none'), })) vi.mock('@/lib/integrations/tool-catalog', () => ({ @@ -273,38 +272,85 @@ describe('buildIntegrationToolSchemas', () => { expect(mockCreateUserToolSchema).not.toHaveBeenCalled() }) - it('briefly reuses built schemas for the same user and surface', async () => { + it('builds each tool schema once per process, not once per viewer', async () => { mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) + mockGetUserPermissionConfig.mockResolvedValueOnce({ allowedIntegrations: ['gmail'] }) - const first = await buildIntegrationToolSchemas('user-cache') - first[0].input_schema.mutated = true - if (first[0].outputs) first[0].outputs.mutated = true - const second = await buildIntegrationToolSchemas('user-cache') + await buildIntegrationToolSchemas('user-a', { schemaSurface: 'copilot' }, 'workspace-a') + await buildIntegrationToolSchemas('user-b', { schemaSurface: 'copilot' }, 'workspace-b') + await buildIntegrationToolSchemas('user-b', { schemaSurface: 'copilot' }, 'workspace-b') - expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(1) expect(mockCreateUserToolSchema).toHaveBeenCalledTimes(3) - expect(second[0].input_schema).not.toHaveProperty('mutated') - expect(second[0].outputs).not.toHaveProperty('mutated') }) - it('isolates nested schemas and required fields between requests', async () => { - mockCreateUserToolSchema.mockReturnValueOnce({ + it('keeps one caller from changing what any other viewer receives', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) + mockCreateUserToolSchema.mockReturnValue({ type: 'object', properties: { recipients: { type: 'array', items: { type: 'string' } } }, required: ['recipients'], }) - const first = await buildIntegrationToolSchemas('user-nested-schema') - const properties = first[0].input_schema.properties as Record - properties.recipients = { type: 'number' } - ;(first[0].input_schema.required as string[]).push('forged') - - const second = await buildIntegrationToolSchemas('user-nested-schema') + const first = await buildIntegrationToolSchemas('user-mutating') + first.push({ name: 'mcp-private-tool', description: '', input_schema: {} }) + expect(() => { + ;(first[0].input_schema.properties as Record).recipients = { type: 'number' } + }).toThrow(TypeError) + expect(() => (first[0].input_schema.required as string[]).push('forged')).toThrow(TypeError) + expect(() => { + first[0].description = 'forged' + }).toThrow(TypeError) + + const second = await buildIntegrationToolSchemas('user-reading') + expect(second.map((tool) => tool.name)).toEqual([ + 'gmail_send', + 'brandfetch_search', + 'run_workflow', + ]) + expect(second[0].description).not.toBe('forged') expect(second[0].input_schema.properties).toEqual({ recipients: { type: 'array', items: { type: 'string' } }, }) expect(second[0].input_schema.required).toEqual(['recipients']) }) + it('appends the free-plan email tagline only for the viewer whose plan earns it', async () => { + mockGetHighestPrioritySubscription + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ plan: 'pro', status: 'active' }) + const tagline = 'sent with sim ai' + const describeGmail = (tools: Array<{ name: string; description: string }>) => + tools.find((tool) => tool.name === 'gmail_send')?.description ?? '' + + const free = await buildIntegrationToolSchemas('free-user') + const paid = await buildIntegrationToolSchemas('paid-user') + const freeAgain = await buildIntegrationToolSchemas('free-user') + + expect(describeGmail(free)).toContain(tagline) + expect(describeGmail(paid)).not.toContain(tagline) + expect(describeGmail(freeAgain)).toContain(tagline) + }) + + it("gates the shared schemas by each viewer's own permission policy", async () => { + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) + mockGetUserPermissionConfig + .mockResolvedValueOnce({ allowedIntegrations: null, deniedTools: ['gmail_send'] }) + .mockResolvedValueOnce(null) + + const restricted = await buildIntegrationToolSchemas( + 'restricted-user', + { schemaSurface: 'copilot' }, + 'workspace-shared' + ) + const unrestricted = await buildIntegrationToolSchemas( + 'open-user', + { schemaSurface: 'copilot' }, + 'workspace-shared' + ) + + expect(restricted.map((tool) => tool.name)).not.toContain('gmail_send') + expect(unrestricted.map((tool) => tool.name)).toContain('gmail_send') + }) + it('coalesces simultaneous catalog builds', async () => { const catalogs = await Promise.all( Array.from({ length: 20 }, () => buildIntegrationToolSchemas('concurrent-user')) @@ -323,7 +369,6 @@ describe('buildIntegrationToolSchemas', () => { ) const recovered = await buildIntegrationToolSchemas('schema-failure') expect(recovered).toHaveLength(3) - expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(2) }) it('propagates subscription failures without caching a guessed catalog', async () => { @@ -335,18 +380,6 @@ describe('buildIntegrationToolSchemas', () => { expect(await buildIntegrationToolSchemas('subscription-failure')).toHaveLength(3) }) - it('evicts catalogs by their byte size before reaching the entry cap', async () => { - mockCreateUserToolSchema.mockImplementation(() => ({ - type: 'object', - properties: { large: { type: 'string', description: 'x'.repeat(1024 * 1024) } }, - })) - for (let i = 0; i < 12; i++) await buildIntegrationToolSchemas(`sized-user-${i}`) - expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(12) - await buildIntegrationToolSchemas('sized-user-0') - expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(13) - mockCreateUserToolSchema.mockImplementation(() => ({ type: 'object', properties: {} })) - }) - it('rebuilds instead of serving a cache entry from the previous policy', async () => { mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null, deniedTools: [] }) @@ -660,6 +693,7 @@ describe('buildCopilotRequestPayload', () => { }) describe('Assistant payload', () => { + afterEach(() => vi.mocked(getExposedIntegrationTools).mockReset()) beforeEach(() => { resetEnvFlagsMock() mockGetUserPermissionConfig.mockResolvedValue(null) @@ -671,7 +705,7 @@ describe('Assistant payload', () => { it('discovers the existing GitHub user lookup tool with a personal credential in live Search', async () => { clearIntegrationToolSchemaCacheForTests() mockSearchApprovals.mockResolvedValue(new Map([['github', true]])) - vi.mocked(getExposedIntegrationTools).mockReturnValueOnce([ + vi.mocked(getExposedIntegrationTools).mockReturnValue([ { toolId: searchUsersV2Tool.id, config: searchUsersV2Tool, @@ -706,7 +740,7 @@ describe('Assistant payload', () => { }) it('advertises approved personal organization integrations and rechecks revocation', async () => { clearIntegrationToolSchemaCacheForTests() - vi.mocked(getExposedIntegrationTools).mockReturnValueOnce([ + vi.mocked(getExposedIntegrationTools).mockReturnValue([ { toolId: gmailListLabelsV2Tool.id, config: gmailListLabelsV2Tool, diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index df4b74aa1f1..85fdef2fd74 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -7,12 +7,12 @@ import { LRUCache } from 'lru-cache' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPaid } from '@/lib/billing/plan-helpers' import { isComputerUseAvailableFor } from '@/lib/computer-use/application/availability' -import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { isHosted } from '@/lib/core/config/env-flags' +import { yieldWhenSliceExpires } from '@/lib/core/utils/event-loop' import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' +import { getExposedIntegrationTools } from '@/lib/integrations/tool-catalog' import { type IntegrationGateConfig, - integrationGateSignature, projectIntegrationToolsForViewer, } from '@/lib/integrations/tool-projection' import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters' @@ -23,10 +23,7 @@ import { isAssistantIntegrationParameter, isAssistantIntegrationTool, } from '@/lib/mothership/assistant/tool-policy' -import { - getBlockVisibilityForCopilot, - visibilitySignature, -} from '@/lib/mothership/block-visibility' +import { getBlockVisibilityForCopilot } from '@/lib/mothership/block-visibility' import type { AssistantImageContent } from '@/lib/mothership/chat/assistant-images' import { buildUploadedFileContext } from '@/lib/mothership/chat/upload-context' import { buildWorkspaceInventory } from '@/lib/mothership/chat/workspace-inventory' @@ -51,9 +48,10 @@ import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' import { getToolMetadata } from '@/tools/metadata' const logger = createLogger('CopilotChatPayload') -const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000 -const INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES = 500 -const INTEGRATION_TOOL_SCHEMA_CACHE_MAX_BYTES = 32 * 1024 * 1024 +const EMAIL_TAGLINE_CACHE_TTL_MS = 5_000 +const EMAIL_TAGLINE_CACHE_MAX_ENTRIES = 20_000 +/** One per schema surface and personal-accounts mode; the cap only backstops a new surface. */ +const INTEGRATION_TOOL_SCHEMA_VARIANT_MAX_ENTRIES = 8 interface BuildPayloadParams { message: string @@ -133,43 +131,56 @@ interface BuildIntegrationToolSchemasOptions { organizationId?: string } -interface IntegrationToolSchemaBuildContext { - userId: string - options: Required> - vis: BlockVisibilityState | null - permissionConfig: IntegrationGateConfig | null +type IntegrationToolSchemaVariantOptions = Required< + Omit +> + +/** + * Every exposed integration tool's schema for one surface and mode, by tool id. + * + * Depends only on the tool registry and deployment config (`isHosted`, OAuth availability), + * fixed for the process's life, never on the viewer, so it is built once per process and + * shared: the entries are frozen and must not be mutated. + * Viewer gating (block visibility, permission groups, the deployment allowlist) picks + * which entries a request returns. Tools whose description gains the free-plan email + * tagline also get a tagged copy in `withEmailTagline`, sharing the same input schema. + */ +interface IntegrationToolSchemaVariant { + schemas: ReadonlyMap + withEmailTagline: ReadonlyMap } -const integrationToolSchemaCache = new LRUCache< +const integrationToolSchemaVariants = new LRUCache< string, - ToolSchema[], - IntegrationToolSchemaBuildContext + IntegrationToolSchemaVariant, + IntegrationToolSchemaVariantOptions >({ - max: INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES, - maxSize: INTEGRATION_TOOL_SCHEMA_CACHE_MAX_BYTES, - sizeCalculation: (schemas) => Buffer.byteLength(JSON.stringify(schemas)), - ttl: INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS, + max: INTEGRATION_TOOL_SCHEMA_VARIANT_MAX_ENTRIES, ignoreFetchAbort: true, - fetchMethod: async (_key, _staleValue, { context }) => - buildIntegrationToolSchemasUncached(context), + fetchMethod: async (_key, _staleValue, { context }) => buildIntegrationToolSchemaVariant(context), }) -function getIntegrationToolSchemaCacheKey( - userId: string, - workspaceId: string | undefined, - schemaSurface: string, - visSignature: string, - gateSignature: string -): string { - // The visibility signature keys the entry to the viewer's gated projection — - // two users in one workspace with different preview reveals must not share. - // The gate signature does the same for permission-group policy, so an admin's - // change takes effect on the next build rather than when the entry expires. - return JSON.stringify([userId, workspaceId ?? null, schemaSurface, visSignature, gateSignature]) -} +/** Whether the viewer's plan adds the email tagline; as fresh as the subscription read it caches. */ +const emailTaglineCache = new LRUCache({ + max: EMAIL_TAGLINE_CACHE_MAX_ENTRIES, + ttl: EMAIL_TAGLINE_CACHE_TTL_MS, + ignoreFetchAbort: true, + fetchMethod: async (userId) => { + const subscription = await getHighestPrioritySubscription(userId) + return !subscription || !isPaid(subscription.plan) + }, +}) export function clearIntegrationToolSchemaCacheForTests(): void { - integrationToolSchemaCache.clear() + integrationToolSchemaVariants.clear() + emailTaglineCache.clear() +} + +function deepFreeze(value: T): T { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value + for (const child of Object.values(value)) deepFreeze(child) + Object.freeze(value) + return value } /** @@ -180,6 +191,9 @@ export function clearIntegrationToolSchemaCacheForTests(): void { * When `workspaceId` is provided the user's workspace permission config is * loaded once and used to skip any tool whose owning block is not in the * workspace's `allowedIntegrations` allowlist. + * + * The returned array is the caller's own, but its schemas are shared across + * requests and frozen: copy one before changing it. */ export async function buildIntegrationToolSchemas( userId: string, @@ -189,9 +203,6 @@ export async function buildIntegrationToolSchemas( const schemaSurface = options.schemaSurface ?? 'copilot' const personalAccountsOnly = options.personalAccountsOnly ?? false const vis = await getBlockVisibilityForCopilot(userId, workspaceId, options.organizationId) - // Resolved before the key, not inside the cached build, so the entry is keyed - // to the policy it was produced under. The read this adds is cheap next to - // what the entry caches: a user-tool schema per exposed integration tool. let permissionConfig: IntegrationGateConfig | null = null if (workspaceId) { const { resolvePermissionGroupConfig } = await import( @@ -204,17 +215,25 @@ export async function buildIntegrationToolSchemas( return [] permissionConfig = organizationConfig } - const cacheKey = getIntegrationToolSchemaCacheKey( - userId, - workspaceId, - personalAccountsOnly ? `${schemaSurface}:personal` : schemaSurface, - visibilitySignature(vis), - integrationGateSignature(permissionConfig) - ) - const schemas = await integrationToolSchemaCache.fetch(cacheKey, { - context: { userId, options: { schemaSurface, personalAccountsOnly }, vis, permissionConfig }, - }) - if (!schemas) throw new Error('Integration tool catalog is unavailable') + const [variant, appendEmailTagline] = await Promise.all([ + integrationToolSchemaVariants.fetch(JSON.stringify([schemaSurface, personalAccountsOnly]), { + context: { schemaSurface, personalAccountsOnly }, + }), + emailTaglineCache.fetch(userId), + ]) + if (!variant || appendEmailTagline === undefined) + throw new Error('Integration tool catalog is unavailable') + + const schemas: ToolSchema[] = [] + for (const { toolId, service } of projectIntegrationToolsForViewer(vis, permissionConfig).tools) { + const schema = + (appendEmailTagline ? variant.withEmailTagline.get(toolId) : undefined) ?? + variant.schemas.get(toolId) + if (!schema) continue + // The viewer's visible owning block can be a different service than the canonical one. + schemas.push(schema.service === service ? schema : Object.freeze({ ...schema, service })) + } + if (options.organizationId && personalAccountsOnly) { const approvals = await listOrganizationSearchApprovals(options.organizationId) const providers = new Set( @@ -222,36 +241,30 @@ export async function buildIntegrationToolSchemas( (connector) => connector.providerId ) ) - return structuredClone( - schemas.filter((schema) => { - const original = getToolMetadata(schema.name) - const metadata = original ? projectAssistantConnectedAccountTool(original) : undefined - return ( - !metadata?.personalToken && - metadata?.oauth?.required && - providerIdsForService(metadata.oauth.provider).some((providerId) => - providers.has(providerId) - ) + return schemas.filter((schema) => { + const original = getToolMetadata(schema.name) + const metadata = original ? projectAssistantConnectedAccountTool(original) : undefined + return ( + !metadata?.personalToken && + metadata?.oauth?.required && + providerIdsForService(metadata.oauth.provider).some((providerId) => + providers.has(providerId) ) - }) - ) + ) + }) } - return structuredClone(schemas) + return schemas } -async function buildIntegrationToolSchemasUncached({ - userId, - options, - vis, - permissionConfig, -}: IntegrationToolSchemaBuildContext): Promise { - const integrationTools: ToolSchema[] = [] +async function buildIntegrationToolSchemaVariant( + options: IntegrationToolSchemaVariantOptions +): Promise { + const schemas = new Map() + const withEmailTagline = new Map() const { createUserToolSchema } = await import('@/tools/params') - const subscription = await getHighestPrioritySubscription(userId) - const shouldAppendEmailTagline = !subscription || !isPaid(subscription.plan) - const { tools: exposedTools } = projectIntegrationToolsForViewer(vis, permissionConfig) - for (const { toolId, config: toolConfig, service, operation } of exposedTools) { + for (const { toolId, config: toolConfig, service, operation } of getExposedIntegrationTools()) { + await yieldWhenSliceExpires() const metadata = getToolMetadata(toolId) if (options.personalAccountsOnly && !isAssistantIntegrationTool(metadata)) continue const projectedTool = options.personalAccountsOnly @@ -282,37 +295,47 @@ async function buildIntegrationToolSchemasUncached({ } } const catalogEntry = getToolEntry(toolId) - integrationTools.push({ - name: toolId, - service, - operation, - description: getCopilotToolDescription(toolConfig, { + const describe = (appendEmailTagline: boolean) => + getCopilotToolDescription(toolConfig, { isHosted, hostedApiKey: deriveHostedApiKeySupport(toolConfig.hosting), fallbackName: toolId, - appendEmailTagline: shouldAppendEmailTagline, - }), - input_schema: { ...userSchema }, - ...(toolConfig.outputs && { - outputs: Object.fromEntries( - Object.entries(toolConfig.outputs) - .filter(([, output]) => output != null) - .map(([key, output]) => [key, { type: output.type, description: output.description }]) - ), - }), - defer_loading: true, - executeLocally: catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client', - ...(projectedTool.oauth?.required && - isOAuthServiceDeploymentAvailable(projectedTool.oauth.provider) && { - oauth: { - required: true, - provider: projectedTool.oauth.provider, - }, + appendEmailTagline, + }) + const description = describe(false) + // Cloned so freezing the shared schema never freezes registry objects it was built from. + const schema = deepFreeze( + structuredClone({ + name: toolId, + service, + operation, + description, + input_schema: { ...userSchema }, + ...(toolConfig.outputs && { + outputs: Object.fromEntries( + Object.entries(toolConfig.outputs) + .filter(([, output]) => output != null) + .map(([key, output]) => [key, { type: output.type, description: output.description }]) + ), }), - }) + defer_loading: true, + executeLocally: catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client', + ...(projectedTool.oauth?.required && + isOAuthServiceDeploymentAvailable(projectedTool.oauth.provider) && { + oauth: { + required: true, + provider: projectedTool.oauth.provider, + }, + }), + }) + ) + schemas.set(toolId, schema) + const taggedDescription = describe(true) + if (taggedDescription !== description) + withEmailTagline.set(toolId, Object.freeze({ ...schema, description: taggedDescription })) } - return integrationTools + return { schemas, withEmailTagline } } /** diff --git a/apps/sim/lib/mothership/integrations/application/catalog.ts b/apps/sim/lib/mothership/integrations/application/catalog.ts index 8924d8be430..c3b254edba6 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.ts @@ -107,15 +107,39 @@ function operationTerms(tool: ToolSchema): string[] { return tokenize(`${tool.name} ${tool.service ?? ''} ${tool.description} ${parameters}`) } +interface OperationTermStats { + length: number + frequencies: ReadonlyMap +} + +/** + * Integration schemas are shared, frozen objects reused across requests, so their terms + * are counted once per process; per-request MCP schemas fall out with their request. + */ +const operationTermStats = new WeakMap() + +function getOperationTermStats(tool: ToolSchema): OperationTermStats { + const cached = operationTermStats.get(tool) + if (cached) return cached + const terms = operationTerms(tool) + const frequencies = new Map() + for (const term of terms) frequencies.set(term, (frequencies.get(term) ?? 0) + 1) + const stats = { length: terms.length, frequencies } + operationTermStats.set(tool, stats) + return stats +} + /** Okapi BM25 over the candidate operations; zero means no query term occurs. */ function rankByBm25(tools: ToolSchema[], queryTerms: string[]): number[] { const query = new Set(queryTerms) const documents = tools.map((tool) => { - const terms = operationTerms(tool) + const stats = getOperationTermStats(tool) const frequencies = new Map() - for (const term of terms) - if (query.has(term)) frequencies.set(term, (frequencies.get(term) ?? 0) + 1) - return { length: terms.length, frequencies } + for (const term of query) { + const frequency = stats.frequencies.get(term) + if (frequency) frequencies.set(term, frequency) + } + return { length: stats.length, frequencies } }) const averageLength = documents.reduce((total, document) => total + document.length, 0) / (documents.length || 1) diff --git a/apps/sim/lib/mothership/jobs/budget.ts b/apps/sim/lib/mothership/jobs/budget.ts index 9a38f21f0f1..6cc7b7165a2 100644 --- a/apps/sim/lib/mothership/jobs/budget.ts +++ b/apps/sim/lib/mothership/jobs/budget.ts @@ -1,4 +1,4 @@ -import { db, jobExecutionLogs, workflowSchedule } from '@sim/db' +import { dbFor, jobExecutionLogs, workflowSchedule } from '@sim/db' import { and, eq, sql } from 'drizzle-orm' import type { UsageUpgradePayload } from '@/lib/api/contracts/subscription' @@ -22,7 +22,7 @@ export async function recordJobRunSpend( billingRequestId: string, cumulativeCostUsd: number ): Promise { - const [run] = await db + const [run] = await dbFor('billing') .update(jobExecutionLogs) .set({ cost: sql`jsonb_build_object('total', GREATEST(COALESCE((${jobExecutionLogs.cost}->>'total')::numeric, 0), ${cumulativeCostUsd}::numeric))`, diff --git a/apps/sim/lib/workflows/deployment-status.test.ts b/apps/sim/lib/workflows/deployment-status.test.ts new file mode 100644 index 00000000000..a58fa906328 --- /dev/null +++ b/apps/sim/lib/workflows/deployment-status.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { workflowDeploymentVersion } from '@sim/db' +import { createMinimalWorkflowState } from '@sim/testing/factories/workflow.factory' +import { + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing/mocks/database.mock' +import { + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, +} from '@sim/testing/mocks/workflows-persistence-utils.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) + +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' + +const WORKFLOW_ID = 'wf-1' + +describe('checkNeedsRedeployment', () => { + let transactionOpen: boolean + let draftReadWhileTransactionOpen: boolean + + beforeEach(() => { + resetDbChainMock() + transactionOpen = false + draftReadWhileTransactionOpen = false + + dbChainMockFns.transaction.mockImplementation(async (cb: (tx: unknown) => unknown) => { + transactionOpen = true + try { + return await cb(dbChainMock.db) + } finally { + transactionOpen = false + } + }) + + queueTableRows(workflowDeploymentVersion, [ + { id: 'version-1', state: { blocks: {} }, workspaceId: 'ws-1' }, + ]) + + const deployed = createMinimalWorkflowState() + const draft = createMinimalWorkflowState() + draft.blocks.agent1.subBlocks.prompt.value = 'Edited after deploy' + const draftBlocks = draft.blocks + + workflowsPersistenceUtilsMockFns.mockMaterializeDeploymentState.mockResolvedValue({ + ...deployed, + parallels: {}, + variables: {}, + }) + workflowsPersistenceUtilsMockFns.mockLoadWorkflowDeploymentSnapshot.mockResolvedValue({ + edges: draft.edges, + loops: {}, + parallels: {}, + variables: {}, + get blocks() { + if (transactionOpen) draftReadWhileTransactionOpen = true + return draftBlocks + }, + }) + }) + + it('diffs the draft against the deployment only after the snapshot transaction commits', async () => { + await expect(checkNeedsRedeployment(WORKFLOW_ID)).resolves.toBe(true) + expect(draftReadWhileTransactionOpen).toBe(false) + }) + + it('reports no redeploy when the workflow has no active deployment', async () => { + resetDbChainMock() + await expect(checkNeedsRedeployment(WORKFLOW_ID)).resolves.toBe(false) + }) +}) diff --git a/apps/sim/lib/workflows/deployment-status.ts b/apps/sim/lib/workflows/deployment-status.ts index 5931625a7d1..df2b15e8d58 100644 --- a/apps/sim/lib/workflows/deployment-status.ts +++ b/apps/sim/lib/workflows/deployment-status.ts @@ -19,9 +19,12 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' * caller is what let this surface compare a raw jsonb blob against a normalized * draft, so that the server and the client answered the same question * differently for the same workflow. + * + * All reads share one REPEATABLE READ snapshot; the CPU-only diff runs after + * commit so the connection is not held idle in transaction. */ export async function checkNeedsRedeployment(workflowId: string): Promise { - return db.transaction(async (tx) => { + const snapshot = await db.transaction(async (tx) => { await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) /* * `workspaceId` is selected here, in this transaction, rather than left for @@ -53,10 +56,10 @@ export async function checkNeedsRedeployment(workflowId: string): Promise { if (!workspaceId) { return null } - const rows = await db + const rows = await executor .select({ billedAccountUserId: workspaceTable.billedAccountUserId, allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys, diff --git a/packages/db/db.test.ts b/packages/db/db.test.ts new file mode 100644 index 00000000000..851e5baedb5 --- /dev/null +++ b/packages/db/db.test.ts @@ -0,0 +1,36 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) + +type DbModule = typeof import('./db') + +let database: DbModule + +/** postgres.js connects on first query, so unreachable URLs build real clients without a server. */ +beforeAll(async () => { + vi.stubEnv('SIM_DB_ROLE', undefined) + vi.stubEnv('DATABASE_URL_WEB', undefined) + vi.stubEnv('DATABASE_REPLICA_URL_WEB', undefined) + vi.stubEnv('DATABASE_URL', 'postgres://127.0.0.1:1/primary') + vi.stubEnv('DATABASE_REPLICA_URL', 'postgres://127.0.0.1:2/replica') + database = await import('./db') +}) + +describe('isPrimaryPoolClient', () => { + it('recognizes sub-pool clients, so process-wide caches keep serving their reads', () => { + expect(database.isPrimaryPoolClient(database.db)).toBe(true) + expect(database.isPrimaryPoolClient(database.dbFor('auth'))).toBe(true) + expect(database.isPrimaryPoolClient(database.dbFor('billing'))).toBe(true) + }) + + it('rejects a separately configured replica, whose reads can lag the primary', () => { + expect(database.dbReplica).not.toBe(database.db) + expect(database.isPrimaryPoolClient(database.dbReplica)).toBe(false) + }) + + it('rejects an executor that is not one of the root clients', () => { + expect(database.isPrimaryPoolClient({ ...database.db })).toBe(false) + }) +}) diff --git a/packages/db/db.ts b/packages/db/db.ts index 7176f92c6f1..c4a4f09f1ff 100644 --- a/packages/db/db.ts +++ b/packages/db/db.ts @@ -74,6 +74,19 @@ const postgresClient = instrumentPoolClient( export const db = drizzle(postgresClient, { schema }) +/** Root clients connected to the primary: `db` and every `dbFor()` sub-pool. */ +const primaryPoolClients = new WeakSet([db]) + +/** + * True for a root client on the primary — `db` or any `dbFor()` sub-pool — and + * false for a transaction handle or `dbReplica`. A process-wide cache may only + * serve and store reads made through such a client: a transaction can see its + * own uncommitted writes, and the replica can lag. + */ +export function isPrimaryPoolClient(executor: object): boolean { + return primaryPoolClients.has(executor) +} + /** * Opt-in read-replica client for reads that tolerate bounded staleness and have * no read-your-writes dependency (logs, exports, dashboard aggregations). Never @@ -159,5 +172,6 @@ export function dbFor(role: SubProcessDbRole): typeof db { { schema } ) subPoolClients.set(role, client) + primaryPoolClients.add(client) return client } diff --git a/packages/db/pool-profiles.ts b/packages/db/pool-profiles.ts index 7ffb19bd73a..4ce1a66c9ba 100644 --- a/packages/db/pool-profiles.ts +++ b/packages/db/pool-profiles.ts @@ -8,4 +8,12 @@ export const DB_POOL_PROFILES = { cleanup: { primaryMax: 5, replicaMax: 2, appName: 'sim-cleanup' }, exec: { primaryMax: 10, replicaMax: 4, appName: 'sim-exec' }, search: { primaryMax: 5, replicaMax: 0, appName: 'sim-search' }, + /** + * Usage-ledger writes and threshold settlements: short transactions bounded by + * server-side timeouts, so they must never queue behind slow reads on the process + * pool, where postgres.js pipelines new work onto busy connections. + */ + billing: { primaryMax: 4, replicaMax: 0, appName: 'sim-billing' }, + /** Session, adapter, and API-key lookups that gate every authenticated request. */ + auth: { primaryMax: 3, replicaMax: 0, appName: 'sim-auth' }, } as const diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 8198651e206..f6eb2535d83 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -439,6 +439,8 @@ const dbInstance = { transaction, } +const mockIsPrimaryPoolClient = vi.fn((executor: object): boolean => executor === dbInstance) + /** * Static mock module for `@sim/db` backed by `dbChainMockFns`. * @@ -451,8 +453,9 @@ export const dbChainMock = { db: dbInstance, /** Same instance as `db` so per-test chain overrides cover both clients. */ dbReplica: dbInstance, - /** Sub-pool clients (`dbFor('cleanup' | 'exec')`) share the same instance too. */ + /** Sub-pool clients (`dbFor('auth' | 'billing' | ...)`) share the same instance too. */ dbFor: () => dbInstance, + isPrimaryPoolClient: mockIsPrimaryPoolClient, runOutsideTransactionContext: (fn: () => T): T => fn(), instrumentPoolClient: (client: T): T => client, } @@ -474,6 +477,8 @@ function resolveDbUrl( */ export const databaseMockFns = { mockResolveDbUrl: vi.fn(resolveDbUrl), + /** Recognizes only the shared instance unless a test marks its own client. */ + mockIsPrimaryPoolClient, mockEnsureRowCountTriggers: vi.fn(async (): Promise => {}), mockVerifyRowCountTriggers: vi.fn(async () => ({ incrementTrigger: true, diff --git a/packages/testing/src/mocks/schema-tables.generated.ts b/packages/testing/src/mocks/schema-tables.generated.ts index de7a334e001..afa1de7bab1 100644 --- a/packages/testing/src/mocks/schema-tables.generated.ts +++ b/packages/testing/src/mocks/schema-tables.generated.ts @@ -2339,6 +2339,16 @@ export const GENERATED_DB_CONSTANTS = { replicaMax: 0, appName: 'sim-search', }, + billing: { + primaryMax: 4, + replicaMax: 0, + appName: 'sim-billing', + }, + auth: { + primaryMax: 3, + replicaMax: 0, + appName: 'sim-auth', + }, }, INCREMENT_ROW_COUNT_SQL: '\nCREATE OR REPLACE FUNCTION increment_table_row_count()\nRETURNS TRIGGER AS $$\nBEGIN\n UPDATE user_table_definitions\n SET row_count = row_count + 1\n WHERE id = NEW.table_id;\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS trg_increment_row_count ON user_table_rows;\nCREATE TRIGGER trg_increment_row_count\nAFTER INSERT ON user_table_rows\nFOR EACH ROW\nEXECUTE FUNCTION increment_table_row_count();\n', From 60f7996ae4b386c23f2a4eaaec6016a77ea92dea Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 14:39:33 -0700 Subject: [PATCH 15/30] fix(logs): stop loading execution data and snapshots that reads do not use (#9349) * fix(logs): stop loading execution data and snapshots that reads do not use - Status polls select execution_data through a CASE only for the statuses the poll reads it for (failed; completed when output is requested; always for selected outputs), from one shared status rule. - The execution snapshot view no longer loads execution_data while resolving its context; it is read only after authorization, so a refused caller never fetches it. - The paused execution list projects execution_snapshot->'triggerIds' instead of loading whole rows with the serialized executor snapshot. - One shared terminal-status predicate replaces the copies in the HITL manager and run-file reads. * test(logs): give the recording client a connection option so its options type-check * fix(logs): read a snapshot view's execution data with the timing and cost it pairs with --- .../read-execution-snapshot.test.ts | 43 +++- .../application/read-execution-snapshot.ts | 95 ++++--- apps/sim/lib/logs/types.ts | 15 ++ .../execution-read-payloads.integration.ts | 231 ++++++++++++++++++ .../workflows/executor/execution-run-files.ts | 6 +- .../workflows/executor/execution-status.ts | 24 +- .../executor/human-in-the-loop-manager.ts | 57 +++-- 7 files changed, 404 insertions(+), 67 deletions(-) create mode 100644 apps/sim/lib/workflows/executor/execution-read-payloads.integration.ts diff --git a/apps/sim/lib/logs/application/read-execution-snapshot.test.ts b/apps/sim/lib/logs/application/read-execution-snapshot.test.ts index 5c173e4f59d..625896c5a40 100644 --- a/apps/sim/lib/logs/application/read-execution-snapshot.test.ts +++ b/apps/sim/lib/logs/application/read-execution-snapshot.test.ts @@ -66,6 +66,13 @@ const workspaceContext = { billedAccountUserId: 'billing-owner-1', } +const completedRun = { + executionData: null, + endedAt: new Date('2026-08-05T12:00:01.000Z'), + totalDurationMs: 1000, + costTotal: '0.75', +} + const workflowRecord = { id: 'log-1', workflowId: 'workflow-1', @@ -77,17 +84,19 @@ const workflowRecord = { endedAt: new Date('2026-08-05T12:00:01.000Z'), totalDurationMs: 1000, costTotal: '0.75', - executionData: null, } /** * Answers `db.select(...)` calls in order. The snapshot read walks the workflow - * log, then (only when that missed) the job log, then the state snapshot. + * log, then (only when that missed) the job log, then the state snapshot, then + * the run's execution data. */ function queueSelects(...results: unknown[][]): void { for (const rows of results) { mocks.select.mockReturnValueOnce({ - from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }), + from: () => ({ + where: () => Object.assign(Promise.resolve(rows), { limit: () => Promise.resolve(rows) }), + }), }) } } @@ -135,7 +144,11 @@ describe('readExecutionSnapshot spend projection', () => { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCostInfo: true, }) - queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }]) + queueSelects( + [workflowRecord], + [{ id: 'snapshot-1', stateData: { blocks: {} } }], + [completedRun] + ) const result = await read(principal, 'run-1') @@ -153,11 +166,31 @@ describe('readExecutionSnapshot spend projection', () => { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCostInfo: true, }) - queueSelects([workflowRecord], [{ id: 'snapshot-1', stateData: { blocks: {} } }]) + queueSelects( + [workflowRecord], + [{ id: 'snapshot-1', stateData: { blocks: {} } }], + [completedRun] + ) const result = await read(executorPrincipal, 'run-1') expect(result.executionMetadata.cost).toEqual({ total: 0.75 }) expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() }) + + it('reports the timing and cost read with the execution data, not the earlier context', async () => { + queueSelects( + [{ ...workflowRecord, endedAt: null, totalDurationMs: null, costTotal: null }], + [{ id: 'snapshot-1', stateData: { blocks: {} } }], + [completedRun] + ) + + const result = await read(executorPrincipal, 'run-1') + + expect(result.executionMetadata).toMatchObject({ + endedAt: '2026-08-05T12:00:01.000Z', + totalDurationMs: 1000, + cost: { total: 0.75 }, + }) + }) }) diff --git a/apps/sim/lib/logs/application/read-execution-snapshot.ts b/apps/sim/lib/logs/application/read-execution-snapshot.ts index cdeb115139d..e60eca12175 100644 --- a/apps/sim/lib/logs/application/read-execution-snapshot.ts +++ b/apps/sim/lib/logs/application/read-execution-snapshot.ts @@ -23,7 +23,7 @@ import { resolveActiveWorkspaceApplicationContext, } from '@/lib/workspaces/application/context' -const CHILD_SNAPSHOT_QUERY_CHUNK_SIZE = 1_000 +const SNAPSHOT_QUERY_CHUNK_SIZE = 1_000 interface WorkflowExecutionRecord { kind: 'workflow' @@ -37,7 +37,6 @@ interface WorkflowExecutionRecord { endedAt: Date | null totalDurationMs: number | null costTotal: string | null - executionData: unknown } interface JobExecutionRecord { @@ -79,7 +78,6 @@ async function resolveExecutionSnapshotContext( endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, costTotal: workflowExecutionLogs.costTotal, - executionData: workflowExecutionLogs.executionData, }) .from(workflowExecutionLogs) .where(eq(workflowExecutionLogs.executionId, executionId)) @@ -118,6 +116,47 @@ async function resolveExecutionSnapshotContext( return { ...workspace, executionId, record: { kind: 'job', ...jobRecord } } } +async function readSnapshotStates( + ids: string[], + signal: AbortSignal | undefined +): Promise> { + const states = new Map() + for (let index = 0; index < ids.length; index += SNAPSHOT_QUERY_CHUNK_SIZE) { + signal?.throwIfAborted() + const rows = await db + .select({ + id: workflowExecutionSnapshots.id, + stateData: workflowExecutionSnapshots.stateData, + }) + .from(workflowExecutionSnapshots) + .where( + inArray(workflowExecutionSnapshots.id, ids.slice(index, index + SNAPSHOT_QUERY_CHUNK_SIZE)) + ) + for (const row of rows) states.set(row.id, row.stateData) + } + return states +} + +/** + * The run's execution data with the fields a completion write changes alongside it, + * read in one statement after authorization: a caller the use case refuses never + * fetches the payload, and a run finishing mid-request cannot pair new data with + * stale timing or cost. + */ +async function readRunCompletion(executionId: string) { + const [row] = await db + .select({ + executionData: workflowExecutionLogs.executionData, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + return row +} + function collectChildSnapshotIds(traceSpans: TraceSpan[]): string[] { const ids = new Set() const pending = [...traceSpans] @@ -175,17 +214,18 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( } } - const [snapshot] = await db - .select() - .from(workflowExecutionSnapshots) - .where(eq(workflowExecutionSnapshots.id, record.stateSnapshotId)) - .limit(1) - if (!snapshot) { + const workflowState = (await readSnapshotStates([record.stateSnapshotId], input.signal)).get( + record.stateSnapshotId + ) + if (workflowState === undefined) { throw new OrchestrationError('not_found', 'Workflow state snapshot not found') } + input.signal?.throwIfAborted() + const completion = await readRunCompletion(record.executionId) + if (!completion) throw new OrchestrationError('not_found', 'Workflow execution not found') const executionData = (await materializeExecutionData( - record.executionData as Record | null, + completion.executionData as Record | null, { workspaceId: context.workspaceId, workflowId: record.workflowId, @@ -201,40 +241,23 @@ const authorizedReadExecutionSnapshotUseCase = defineAuthorizedWorkspaceUseCase( }) } - const childSnapshotIds = collectChildSnapshotIds(traceSpans) - const childWorkflowSnapshots: Array<{ id: string; stateData: unknown }> = [] - for (let index = 0; index < childSnapshotIds.length; index += CHILD_SNAPSHOT_QUERY_CHUNK_SIZE) { - input.signal?.throwIfAborted() - childWorkflowSnapshots.push( - ...(await db - .select({ - id: workflowExecutionSnapshots.id, - stateData: workflowExecutionSnapshots.stateData, - }) - .from(workflowExecutionSnapshots) - .where( - inArray( - workflowExecutionSnapshots.id, - childSnapshotIds.slice(index, index + CHILD_SNAPSHOT_QUERY_CHUNK_SIZE) - ) - )) - ) - } + const childWorkflowSnapshots = await readSnapshotStates( + collectChildSnapshotIds(traceSpans), + input.signal + ) input.signal?.throwIfAborted() return { executionId: record.executionId, workflowId: record.workflowId, - workflowState: snapshot.stateData as Record, - childWorkflowSnapshots: Object.fromEntries( - childWorkflowSnapshots.map((child) => [child.id, child.stateData]) - ), + workflowState: workflowState as Record, + childWorkflowSnapshots: Object.fromEntries(childWorkflowSnapshots), executionMetadata: { trigger: record.trigger, startedAt: record.startedAt.toISOString(), - endedAt: record.endedAt?.toISOString(), - totalDurationMs: record.totalDurationMs, - cost: projectCostTotal(record.costTotal, projection), + endedAt: completion.endedAt?.toISOString(), + totalDurationMs: completion.totalDurationMs, + cost: projectCostTotal(completion.costTotal, projection), }, } }, diff --git a/apps/sim/lib/logs/types.ts b/apps/sim/lib/logs/types.ts index 6c0bba8bd3c..186a7140ced 100644 --- a/apps/sim/lib/logs/types.ts +++ b/apps/sim/lib/logs/types.ts @@ -225,6 +225,21 @@ export function isPersistedWorkflowExecutionStatus( ): value is PersistedWorkflowExecutionStatus { return (PERSISTED_WORKFLOW_EXECUTION_STATUSES as readonly string[]).includes(value) } + +/** + * Statuses of a finished run's log. A resume can never claim a log in one of them (the + * same set the resume attempt-failure `CASE` preserves). + */ +const TERMINAL_WORKFLOW_EXECUTION_STATUSES = [ + 'cancelled', + 'failed', + 'completed', +] as const satisfies readonly PersistedWorkflowExecutionStatus[] + +export function isTerminalWorkflowExecutionStatus(status: string): boolean { + return (TERMINAL_WORKFLOW_EXECUTION_STATUSES as readonly string[]).includes(status) +} + /** * In-flight statuses a crashed worker can strand, which the stale-execution * cron terminalizes. `pending` and `paused` are excluded: both are written as diff --git a/apps/sim/lib/workflows/executor/execution-read-payloads.integration.ts b/apps/sim/lib/workflows/executor/execution-read-payloads.integration.ts new file mode 100644 index 00000000000..231e7ff245f --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-read-payloads.integration.ts @@ -0,0 +1,231 @@ +/** + * The run status poll, the paused-execution list, and the execution snapshot + * view against real PostgreSQL, recording every value the client receives over + * the wire: a poll fetches a run's execution data only when it reads it, the + * paused list never fetches the serialized execution snapshot it does not + * return, and the snapshot view fetches no execution data for a refused caller. + */ +import { + pausedExecutions, + user, + workflow, + workflowExecutionLogs, + workflowExecutionSnapshots, + workspace, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createExecutorPrincipal } from '@sim/testing/factories/principal.factory' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const harness = vi.hoisted(() => ({ + received: [] as unknown[], + closeClients: [] as Array<() => Promise>, +})) + +vi.mock('@sim/db', async (importOriginal) => { + const actual = await importOriginal() + const { default: postgres } = await import('postgres') + const { drizzle } = await import('drizzle-orm/postgres-js') + const schema = await import('@sim/db/schema') + const { withUtcTimestamps } = await import('@sim/db/timestamps') + const { readTestDatabaseUrl } = await import('@sim/db/testing/test-infrastructure') + const client = postgres( + readTestDatabaseUrl(), + withUtcTimestamps({ + max: 4, + prepare: false, + fetch_types: false, + connection: {}, + onnotice: () => {}, + transform: { + value: { + from: (value: unknown) => { + harness.received.push(value) + return value + }, + }, + }, + }) + ) + harness.closeClients.push(() => client.end()) + const recorded = drizzle(client, { schema }) + return { ...actual, db: recorded, dbReplica: recorded, dbFor: () => recorded } +}) + +import { db } from '@sim/db' +import { LOGS_DELEGATION_AUDIENCE } from '@/lib/logs/application/authorization' +import { readExecutionSnapshotUseCase } from '@/lib/logs/application/read-execution-snapshot' +import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-status' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' + +const ids = { + owner: `execution-read-payloads-${generateId()}`, + workspace: generateId(), + workflow: generateId(), + snapshot: generateId(), +} + +/** Whether `marker` reached the client in any value since the last reset. */ +function fetched(marker: string): boolean { + return JSON.stringify(harness.received).includes(marker) +} + +beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: ids.owner, + name: 'Execution Read Payloads', + email: `${ids.owner}@execution-read-payloads.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: ids.workspace, + name: 'Execution Read Payloads', + ownerId: ids.owner, + billedAccountUserId: ids.owner, + }) + await db.insert(workflow).values({ + id: ids.workflow, + userId: ids.owner, + workspaceId: ids.workspace, + name: 'Execution Read Payloads', + lastSynced: now, + createdAt: now, + updatedAt: now, + }) + await db.insert(workflowExecutionSnapshots).values({ + id: ids.snapshot, + workflowId: ids.workflow, + stateHash: ids.snapshot, + stateData: {}, + }) +}, 30_000) + +afterAll(async () => { + await db.delete(pausedExecutions).where(eq(pausedExecutions.workflowId, ids.workflow)) + await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workflowId, ids.workflow)) + await db.delete(workflowExecutionSnapshots).where(eq(workflowExecutionSnapshots.id, ids.snapshot)) + await db.delete(workflow).where(eq(workflow.id, ids.workflow)) + await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspace)) + await db.delete(user).where(eq(user.id, ids.owner)) + await Promise.all(harness.closeClients.map((close) => close())) +}) + +describe('run status poll', () => { + it.each([ + { status: 'running', includeOutput: true, readsExecutionData: false }, + { status: 'completed', includeOutput: false, readsExecutionData: false }, + { status: 'completed', includeOutput: true, readsExecutionData: true }, + { status: 'failed', includeOutput: false, readsExecutionData: true }, + ])( + 'fetches execution data for a $status run polled with includeOutput=$includeOutput only when it reads it', + async ({ status, includeOutput, readsExecutionData }) => { + const executionId = generateId() + const marker = `execution-data-${executionId}` + await db.insert(workflowExecutionLogs).values({ + id: generateId(), + workflowId: ids.workflow, + workspaceId: ids.workspace, + executionId, + stateSnapshotId: ids.snapshot, + level: status === 'failed' ? 'error' : 'info', + status, + trigger: 'api', + startedAt: new Date(), + executionData: { error: marker, finalOutput: { marker } }, + }) + harness.received.length = 0 + + const result = await getWorkflowExecutionStatus({ + workflowId: ids.workflow, + executionId, + includeOutput, + selectedOutputs: [], + workspaceId: ids.workspace, + viewerUserId: null, + }) + + expect(result?.status).toBe(status) + expect(fetched(marker)).toBe(readsExecutionData) + } + ) +}) + +describe('paused execution list', () => { + it('returns trigger ids without fetching the serialized execution snapshot', async () => { + const executionId = generateId() + const marker = `serialized-snapshot-${executionId}` + await db.insert(pausedExecutions).values({ + id: generateId(), + workflowId: ids.workflow, + executionId, + executionSnapshot: { snapshot: marker, triggerIds: ['trigger-1'] }, + pausePoints: { + approval: { + contextId: 'approval', + pauseKind: 'human', + resumeStatus: 'paused', + response: {}, + registeredAt: new Date().toISOString(), + snapshotReady: true, + }, + }, + totalPauseCount: 1, + }) + harness.received.length = 0 + + const listed = await PauseResumeManager.listPausedExecutions({ workflowId: ids.workflow }) + + expect(listed.map((paused) => [paused.executionId, paused.triggerIds])).toEqual([ + [executionId, ['trigger-1']], + ]) + expect(fetched(marker)).toBe(false) + }) +}) + +describe('execution snapshot view', () => { + it('fetches no execution data for a caller it then refuses', async () => { + const executionId = generateId() + const marker = `execution-data-${executionId}` + await db.insert(workflowExecutionLogs).values({ + id: generateId(), + workflowId: ids.workflow, + workspaceId: ids.workspace, + executionId, + stateSnapshotId: ids.snapshot, + level: 'info', + status: 'completed', + trigger: 'api', + startedAt: new Date(), + executionData: { finalOutput: { marker } }, + }) + harness.received.length = 0 + const otherWorkspaceExecutor = createExecutorPrincipal({ + subjectUserId: undefined, + workspaceId: generateId(), + audience: LOGS_DELEGATION_AUDIENCE, + delegationContext: { + kind: 'workflow_execution', + workflowId: ids.workflow, + currentWorkflow: { + workflowId: ids.workflow, + mode: 'deployment', + deploymentVersionId: 'deployment-1', + }, + }, + }) + + await expect( + readExecutionSnapshotUseCase.execute({ + principal: otherWorkspaceExecutor, + input: { executionId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(fetched(marker)).toBe(false) + }) +}) diff --git a/apps/sim/lib/workflows/executor/execution-run-files.ts b/apps/sim/lib/workflows/executor/execution-run-files.ts index 84f71e6c5b3..3959752ccdb 100644 --- a/apps/sim/lib/workflows/executor/execution-run-files.ts +++ b/apps/sim/lib/workflows/executor/execution-run-files.ts @@ -6,15 +6,13 @@ import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { collectUserFilesById } from '@/lib/core/utils/user-file' import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { isTerminalWorkflowExecutionStatus } from '@/lib/logs/types' import { downloadFile } from '@/lib/uploads/core/storage-service' import { formatFileSize, inferContextFromKey } from '@/lib/uploads/utils/file' import { isRunOutputFileKey } from '@/lib/workflows/executor/run-file-scope' import { classifyRunFileStorageError } from '@/lib/workflows/executor/run-file-storage-error' import type { UserFile } from '@/executor/types' -/** Run states whose recorded output is final and therefore safe to address. */ -const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']) - export interface WorkflowRunFilesInput { workflowId: string runId: string @@ -68,7 +66,7 @@ export async function getWorkflowRunFiles( if (!logRow) return null - const terminal = TERMINAL_RUN_STATUSES.has(logRow.status) + const terminal = isTerminalWorkflowExecutionStatus(logRow.status) if (!terminal) { return { terminal: false, workspaceId: logRow.workspaceId, filesById: new Map() } } diff --git a/apps/sim/lib/workflows/executor/execution-status.ts b/apps/sim/lib/workflows/executor/execution-status.ts index d55ff02742a..8137808fcdf 100644 --- a/apps/sim/lib/workflows/executor/execution-status.ts +++ b/apps/sim/lib/workflows/executor/execution-status.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, inArray, sql } from 'drizzle-orm' import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows' import { getJobQueue } from '@/lib/core/async-jobs' import type { Job } from '@/lib/core/async-jobs/types' @@ -286,6 +286,24 @@ export async function getWorkflowExecutionStatus( return (await getProjectedWorkflowExecutionStatus(input))?.status ?? null } +/** Run statuses whose error or final output a poll returns, so it reads their execution data. */ +function statusesReadingExecutionData(includeOutput: boolean): string[] { + return includeOutput ? ['failed', 'completed'] : ['failed'] +} + +/** + * `execution_data` only for a row whose status the poll will read it for, so a + * poll on an in-flight or output-less run does not pull and parse the payload. + */ +function executionDataReadByPoll( + input: Pick +) { + if (input.selectedOutputs.length > 0) return workflowExecutionLogs.executionData + return sql`CASE WHEN ${inArray(workflowExecutionLogs.status, statusesReadingExecutionData(input.includeOutput))} THEN ${workflowExecutionLogs.executionData} END`.mapWith( + workflowExecutionLogs.executionData + ) +} + async function readWorkflowExecutionStatus( input: GetWorkflowExecutionStatusInput ): Promise { @@ -302,7 +320,7 @@ async function readWorkflowExecutionStatus( startedAt: workflowExecutionLogs.startedAt, endedAt: workflowExecutionLogs.endedAt, totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, + executionData: executionDataReadByPoll(input), costTotal: workflowExecutionLogs.costTotal, }) .from(workflowExecutionLogs) @@ -446,7 +464,7 @@ async function readWorkflowExecutionStatus( // Mirrors the three output conditions below; a poll needing none skips the archive read. const readsExecutionData = - status === 'failed' || (includeOutput && status === 'completed') || selectedOutputs.length > 0 + selectedOutputs.length > 0 || statusesReadingExecutionData(includeOutput).includes(status) if (!readsExecutionData) { return { ...resource, error: null, finalOutput: null, blockOutputs: null } } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index d7b811e187c..7687162d637 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -40,7 +40,7 @@ import { terminalExecutionLogFields, } from '@/lib/logs/execution/cancellation' import { LoggingSession } from '@/lib/logs/execution/logging-session' -import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types' +import { isTerminalWorkflowExecutionStatus } from '@/lib/logs/types' import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { @@ -92,19 +92,6 @@ const execDb = dbFor('exec') const logger = createLogger('HumanInTheLoopManager') const RUN_BUFFER_UNAVAILABLE_ERROR = 'Run buffer temporarily unavailable' const RESUMABLE_PAUSED_STATUSES = ['paused', 'partially_resumed'] as const -/** - * Statuses of a finished execution's log, the same set the attempt-failure - * `CASE` preserves; a resume can never claim a log in one of them. - */ -const TERMINAL_EXECUTION_LOG_STATUSES = [ - 'cancelled', - 'failed', - 'completed', -] as const satisfies readonly PersistedWorkflowExecutionStatus[] - -function isTerminalExecutionLogStatus(status: string): boolean { - return (TERMINAL_EXECUTION_LOG_STATUSES as readonly string[]).includes(status) -} const CANCELLABLE_PAUSED_STATUSES = ['paused', 'partially_resumed'] as const const AUTOMATIC_RESUME_INTERVENTION_PREFIX = 'Automatic resume requires manual intervention: ' const PAUSED_CANCELLATION_QUEUE_FAILURE_REASON = 'Paused execution cancellation requested' @@ -411,6 +398,19 @@ interface PausedExecutionSummary { pausePoints: PausePointWithQueue[] } +/** The `paused_executions` columns a summary is built from, with the snapshot's trigger ids. */ +type PausedExecutionSummaryRow = Pick< + typeof pausedExecutions.$inferSelect, + | 'id' + | 'workflowId' + | 'executionId' + | 'status' + | 'metadata' + | 'pausedAt' + | 'updatedAt' + | 'expiresAt' +> & { triggerIds: string[] | null | undefined } + interface PausedExecutionDetail extends PausedExecutionSummary { executionSnapshot: SerializedSnapshot queue: ResumeQueueEntrySummary[] @@ -2523,7 +2523,7 @@ export class PauseResumeManager { pausedExecution !== undefined && isResumablePausedStatus(pausedExecution.status) && executionLog !== undefined && - !isTerminalExecutionLogStatus(executionLog.status) + !isTerminalWorkflowExecutionStatus(executionLog.status) ) }) } @@ -3488,8 +3488,24 @@ export class PauseResumeManager { } } + /** + * The summary reads only `triggerIds` from the execution snapshot, whose + * serialized `snapshot` string can run to megabytes per row, so that one key + * is projected instead of shipping and parsing the whole document. + */ const rows = await execDb - .select() + .select({ + id: pausedExecutions.id, + workflowId: pausedExecutions.workflowId, + executionId: pausedExecutions.executionId, + status: pausedExecutions.status, + pausePoints: pausedExecutions.pausePoints, + metadata: pausedExecutions.metadata, + pausedAt: pausedExecutions.pausedAt, + updatedAt: pausedExecutions.updatedAt, + expiresAt: pausedExecutions.expiresAt, + triggerIds: sql`${pausedExecutions.executionSnapshot}->'triggerIds'`, + }) .from(pausedExecutions) .where(whereClause) .orderBy(desc(pausedExecutions.pausedAt)) @@ -3558,7 +3574,10 @@ export class PauseResumeManager { return null } - const executionSummary = PauseResumeManager.normalizePausedExecution(row, pausePoints) + const executionSummary = PauseResumeManager.normalizePausedExecution( + { ...row, triggerIds: (row.executionSnapshot as SerializedSnapshot)?.triggerIds }, + pausePoints + ) return { ...executionSummary, @@ -3753,7 +3772,7 @@ export class PauseResumeManager { } private static normalizePausedExecution( - row: typeof pausedExecutions.$inferSelect, + row: PausedExecutionSummaryRow, pausePoints: PausePointWithQueue[] ): PausedExecutionSummary { return { @@ -3767,7 +3786,7 @@ export class PauseResumeManager { updatedAt: row.updatedAt ? row.updatedAt.toISOString() : null, expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null, metadata: row.metadata as Record, - triggerIds: (row.executionSnapshot as SerializedSnapshot)?.triggerIds || [], + triggerIds: row.triggerIds || [], pausePoints, } } From 1a69d2a53f74ef6f0b743cd3df08188959def8e6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 15:16:28 -0700 Subject: [PATCH 16/30] fix(workflows): accept legacy input field types instead of rejecting them (#9350) Stored Start inputs carry a long tail of types outside the editor's list (json, legacy widget names). Until the shared run schema began throwing on them, they ran with the executor passing values through unchanged. Keep that contract end to end: an unknown type accepts any value in run validation and advertises no type over MCP, the save path stores input-format rows unchanged again, and the executor no longer reinterprets json values, so deployed runs behave exactly as they did. files stays an alias of file[], which both sides already apply. --- apps/sim/executor/utils/start-block.test.ts | 2 +- apps/sim/executor/utils/start-block.ts | 3 +- .../lib/workflows/editing/validation.test.ts | 47 ++++++++----------- apps/sim/lib/workflows/editing/validation.ts | 43 +---------------- apps/sim/lib/workflows/input-format-schema.ts | 11 ++--- apps/sim/lib/workflows/input-schema.test.ts | 33 ++++++------- apps/sim/lib/workflows/input-schema.ts | 18 ++++--- .../workflows/triggers/run-options.test.ts | 5 +- 8 files changed, 51 insertions(+), 111 deletions(-) diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 758cbe2116c..32b850e4ee1 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -454,7 +454,7 @@ describe('start-block utilities', () => { expect(output.count).toBe(5) expect(output.payload).toEqual({ event: 'push' }) - expect(output.meta).toEqual({ source: 'api' }) + expect(output.meta).toBe('{"source":"api"}') expect(output.extra).toBe('untouched') }) }) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index c3dea3bd3ab..e2efde6d84a 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -7,7 +7,6 @@ import { isInternalFileUrl, parseInternalFileUrl, } from '@/lib/uploads/utils/file' -import { canonicalInputFormatFieldType } from '@/lib/workflows/input-format-schema' import { classifyStartBlockType, resolveStartCandidates, @@ -246,7 +245,7 @@ export function coerceValue(type: string | null | undefined, value: unknown): un return value } - switch (type ? (canonicalInputFormatFieldType(type) ?? type) : type) { + switch (type) { case 'string': return typeof value === 'string' ? value : String(value) case 'number': { diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index 88e2e942077..916a85f3190 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -345,34 +345,27 @@ describe('validateInputsForBlock', () => { ).toBe(false) }) - describe('input-format', () => { - const config = { id: 'inputFormat', type: 'input-format' as const } - const validate = (value: unknown) => - validateValueForSubBlockType(config, value, 'inputFormat', 'start_trigger', 'start-1') - - it('stores type aliases as their canonical type and keeps untyped rows', () => { - expect( - validate([ - { name: 'payload', type: 'json' }, - { name: 'documents', type: 'files' }, - { name: 'message' }, - ]) - ).toEqual({ - valid: true, - value: [ - { name: 'payload', type: 'object' }, - { name: 'documents', type: 'file[]' }, - { name: 'message' }, - ], - }) - }) - - it('rejects a type no workflow input can declare, listing the allowed types', () => { - const result = validate([{ name: 'count', type: 'integer' }]) - expect(result.valid).toBe(false) - expect(result.error?.error).toBe( - 'Invalid type "integer" for inputFormat[0] - use one of: string, number, boolean, object, array, file[]' + it('stores input-format rows with legacy or aliased types unchanged', () => { + const rows = [ + { name: 'payload', type: 'json' }, + { name: 'title', type: 'short-input' }, + { name: 'message' }, + ] + expect( + validateValueForSubBlockType( + { id: 'inputFormat', type: 'input-format' }, + rows, + 'inputFormat', + 'start_trigger', + 'start-1' ) + ).toEqual({ + valid: true, + value: [ + { name: 'payload', type: 'json' }, + { name: 'title', type: 'short-input' }, + { name: 'message' }, + ], }) }) diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 5fcab91ce65..a816ec9f8d0 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -24,10 +24,6 @@ import { type SelectorReference, validateSelectorIds, } from '@/lib/workflows/editing/selector-validator' -import { - canonicalInputFormatFieldType, - INPUT_FORMAT_FIELD_TYPES, -} from '@/lib/workflows/input-format-schema' import { containsReference } from '@/lib/workflows/sanitization/references' import { getSkillById } from '@/lib/workflows/skills/operations' import { @@ -643,44 +639,7 @@ export function validateValueForSubBlockType( return { valid: true, value } } - case 'input-format': { - if (!Array.isArray(value)) { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid ${type} value for field "${fieldName}" - expected an array`, - }, - } - } - const rows: unknown[] = [] - for (const [index, row] of value.entries()) { - if (!isPlainRecord(row) || row.type === undefined) { - rows.push(row) - continue - } - const fieldType = - typeof row.type === 'string' ? canonicalInputFormatFieldType(row.type) : null - if (!fieldType) { - return { - valid: false, - error: { - blockId, - blockType, - field: fieldName, - value, - error: `Invalid type ${JSON.stringify(row.type)} for ${fieldName}[${index}] - use one of: ${INPUT_FORMAT_FIELD_TYPES.join(', ')}`, - }, - } - } - rows.push(fieldType === row.type ? row : { ...row, type: fieldType }) - } - return { valid: true, value: rows } - } - + case 'input-format': case 'table': { // Should be an array if (!Array.isArray(value)) { diff --git a/apps/sim/lib/workflows/input-format-schema.ts b/apps/sim/lib/workflows/input-format-schema.ts index 1f22315e482..3b69b674112 100644 --- a/apps/sim/lib/workflows/input-format-schema.ts +++ b/apps/sim/lib/workflows/input-format-schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -/** The input field types the editor offers and workflow runs accept. */ -export const INPUT_FORMAT_FIELD_TYPES = [ +/** The input field types the editor offers. */ +const INPUT_FORMAT_FIELD_TYPES = [ 'string', 'number', 'boolean', @@ -12,14 +12,9 @@ export const INPUT_FORMAT_FIELD_TYPES = [ export type InputFormatFieldType = (typeof INPUT_FORMAT_FIELD_TYPES)[number] -/** - * Every spelling a stored field's `type` may use, mapped to its canonical type: - * `json` is the name blocks use for object params and outputs, `files` the - * legacy MCP alias. - */ +/** Every canonical type by name, plus `files`, the legacy MCP alias of `file[]`. */ const INPUT_FORMAT_FIELD_TYPES_BY_NAME = new Map([ ...INPUT_FORMAT_FIELD_TYPES.map((type) => [type, type] as const), - ['json', 'object'], ['files', 'file[]'], ]) diff --git a/apps/sim/lib/workflows/input-schema.test.ts b/apps/sim/lib/workflows/input-schema.test.ts index 77a408be3b7..7e268d6c591 100644 --- a/apps/sim/lib/workflows/input-schema.test.ts +++ b/apps/sim/lib/workflows/input-schema.test.ts @@ -108,25 +108,22 @@ describe('workflow input schemas', () => { expect(z.object(generateWorkflowInputShape(fields)).parse(input)).toEqual(input) }) - it('accepts the json and files aliases as their canonical types', () => { - const shape = z.object( - generateWorkflowInputShape([ - { name: 'payload', type: 'json' }, - { name: 'documents', type: 'files' }, - ]) - ) - expect(shape.safeParse({ payload: { a: 1 }, documents: [file] }).success).toBe(true) - expect(shape.safeParse({ payload: 'text', documents: [file] }).success).toBe(false) + it('accepts the files alias as file[]', () => { + const shape = z.object(generateWorkflowInputShape([{ name: 'documents', type: 'files' }])) + expect(shape.safeParse({ documents: [file] }).success).toBe(true) + expect(shape.safeParse({ documents: 'text' }).success).toBe(false) }) - it('rejects unsupported type names as a validation failure naming the field', () => { - expect(() => generateToolInputSchema([{ name: 'count', type: 'unsupported' }])).toThrow( - expect.objectContaining({ - name: 'OrchestrationError', - code: 'validation', - message: - 'Workflow input "count" has unsupported type "unsupported". Use one of: string, number, boolean, object, array, file[].', + it.each(['short-input', 'json'])( + 'accepts any value for the legacy type %s and advertises no type for it', + (type) => { + const fields = [{ name: 'title', type }] + expect(generateToolInputSchema(fields).properties).toEqual({ + title: { description: 'title' }, }) - ) - }) + const shape = z.object(generateWorkflowInputShape(fields)) + expect(shape.safeParse({ title: 'Quarterly report' }).success).toBe(true) + expect(shape.safeParse({ title: { text: 'Quarterly report' } }).success).toBe(true) + } + ) }) diff --git a/apps/sim/lib/workflows/input-schema.ts b/apps/sim/lib/workflows/input-schema.ts index f4fed3364c0..ac7f7563e7f 100644 --- a/apps/sim/lib/workflows/input-schema.ts +++ b/apps/sim/lib/workflows/input-schema.ts @@ -3,13 +3,16 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { workflowFileInputSchema } from '@/lib/workflows/input-file-schema' import { canonicalInputFormatFieldType, - INPUT_FORMAT_FIELD_TYPES, type InputFormatFieldType, } from '@/lib/workflows/input-format-schema' import type { InputFormatField } from '@/lib/workflows/types' import { isSafeKey } from '@/tools/safe-assign' -function fieldTypeToSchema(type: InputFormatFieldType): z.ZodType { +/** + * A stored type outside the editor's list (legacy widget names such as `short-input`) accepts any + * value, as the executor passes it through unchanged. + */ +function fieldTypeToSchema(type: InputFormatFieldType | null): z.ZodType { switch (type) { case 'string': return z.string() @@ -23,6 +26,8 @@ function fieldTypeToSchema(type: InputFormatFieldType): z.ZodType { return z.array(z.unknown()) case 'file[]': return z.array(workflowFileInputSchema) + case null: + return z.unknown() } } @@ -30,8 +35,7 @@ function fieldTypeToSchema(type: InputFormatFieldType): z.ZodType { * Shared by workflow run validation and MCP discovery so the advertised input * types match execution. The executor supplies configured defaults at run time. * - * @throws OrchestrationError `validation` when a field name is reserved or its - * type is not one a workflow input can declare. + * @throws OrchestrationError `validation` when a field name is reserved. */ export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.ZodRawShape { const shape: Record = {} @@ -46,12 +50,6 @@ export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.Z ) } const type = canonicalInputFormatFieldType(field.type) - if (!type) { - throw new OrchestrationError( - 'validation', - `Workflow input "${name}" has unsupported type "${field.type}". Use one of: ${INPUT_FORMAT_FIELD_TYPES.join(', ')}.` - ) - } const schema = fieldTypeToSchema(type).describe( field.description?.trim() || diff --git a/apps/sim/lib/workflows/triggers/run-options.test.ts b/apps/sim/lib/workflows/triggers/run-options.test.ts index 327a9d49552..3f710c44aa1 100644 --- a/apps/sim/lib/workflows/triggers/run-options.test.ts +++ b/apps/sim/lib/workflows/triggers/run-options.test.ts @@ -115,8 +115,8 @@ describe('file inputs through workflow run options', () => { }) }) -describe('mock payloads for aliased field types', () => { - it('samples a json field as an object the run validation accepts', () => { +describe('mock payloads for legacy field types', () => { + it('samples a json field with a value the run validation accepts', () => { const [option] = resolveTriggerRunOptions({ start: { type: 'start_trigger', @@ -124,7 +124,6 @@ describe('mock payloads for aliased field types', () => { subBlocks: { inputFormat: { value: [{ name: 'payload', type: 'json' }] } }, }, }) - expect(option.mockPayload).toEqual({ payload: {} }) expect(validateTriggerInput(option, option.mockPayload)).toEqual({ ok: true }) }) }) From d76f2cddeef02e6ab36c37c6abbeb67dad43e509 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 15:31:55 -0700 Subject: [PATCH 17/30] improvement(execution): run sandbox workers on a dedicated worker host (#9348) * improvement(execution): run sandbox workers on a dedicated worker host - Isolated-vm pool can open workers as connections to a dedicated worker host (isolated-vm-host.cjs, shipped in the app image) instead of local child processes, behind IVM_REMOTE_WORKER_URL/TOKEN and the isolated-vm-remote-workers flag. Remote mode never falls back to local workers; a target change retires old workers without interrupting runs. - The host relays the unchanged worker protocol; fetch and brokers stay in the app, and the app sends its worker limits so both modes enforce the same values. - ffmpeg/ffprobe run with a minimal environment and a file-only input protocol whitelist. * fix(execution): address review on the dedicated worker host - Host: reject an unparseable upgrade target instead of throwing; bound unauthenticated connections separately so they never hold a worker slot. - Pool: re-enqueue an execution whose selected worker was retired before dispatch; forward TZ/LANG/LC_ALL with the limits so remote and local workers produce the same Date/Intl results. - Keep the child_process import in its original form so the bundler does not trace the worker spawn; give ffmpeg's env NODE_ENV for ProcessEnv. - E2E: stop the suite when a check cannot settle; assert the exact truncation bound; cover the unparseable target. * improvement(execution): replace the remote-workers flag and its env fallback with a local-workers kill switch A configured worker host is now used by default; the AppConfig-only isolated-vm-local-workers kill switch forces local workers during rollout. Drops IVM_REMOTE_WORKERS, and an unreachable AppConfig keeps the host in use. * fix(execution): re-queue work selected onto a retiring worker; stop E2E hosts with their workers - dispatchToWorker re-queues when the selected worker retired during a target switch (not only when it was removed), and cleans it up if idle. - E2E hosts run in their own process group so cleanup also stops their worker children. - Sandbox rule notes the URL needs its token. --- .claude/rules/sim-sandbox.md | 28 +- .cursor/rules/sim-sandbox.mdc | 28 +- apps/sim/lib/audio/extractor.test.ts | 3 + apps/sim/lib/audio/extractor.ts | 23 +- apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/core/config/feature-flags.ts | 8 + apps/sim/lib/execution/isolated-vm-host.cjs | 376 +++++++++++++++ apps/sim/lib/execution/isolated-vm-remote.ts | 139 ++++++ apps/sim/lib/execution/isolated-vm.test.ts | 81 ++++ apps/sim/lib/execution/isolated-vm.ts | 184 ++++++-- apps/sim/lib/media/ffmpeg-process.ts | 52 ++- apps/sim/lib/media/ffmpeg.test.ts | 38 +- apps/sim/lib/media/ffmpeg.ts | 24 +- apps/sim/package.json | 2 + .../scripts/test-isolated-vm-remote-e2e.ts | 438 ++++++++++++++++++ bun.lock | 1 + docker/app.Dockerfile | 6 + knip.jsonc | 2 + 18 files changed, 1362 insertions(+), 73 deletions(-) create mode 100644 apps/sim/lib/execution/isolated-vm-host.cjs create mode 100644 apps/sim/lib/execution/isolated-vm-remote.ts create mode 100644 apps/sim/scripts/test-isolated-vm-remote-e2e.ts diff --git a/.claude/rules/sim-sandbox.md b/.claude/rules/sim-sandbox.md index e696f04f843..ff41c6cec2f 100644 --- a/.claude/rules/sim-sandbox.md +++ b/.claude/rules/sim-sandbox.md @@ -2,6 +2,8 @@ description: Isolated-vm sandbox worker security policy. Hard rules for anything that lives in the worker child process that runs user code. paths: - "apps/sim/lib/execution/isolated-vm-worker.cjs" + - "apps/sim/lib/execution/isolated-vm-host.cjs" + - "apps/sim/lib/execution/isolated-vm-remote.ts" - "apps/sim/lib/execution/isolated-vm.ts" - "apps/sim/lib/execution/sandbox/**" - "apps/sim/sandboxes/**" @@ -75,7 +77,31 @@ payload or `ivm.Reference` wrapper in the worker: - [ ] Does the worker read a new env var? Add it to the `buildWorkerEnv()` allowlist in `isolated-vm.ts` **and** to the allowlist regression test in `isolated-vm.test.ts` — the worker does not inherit the app environment, - so an un-allowlisted var is simply absent in the child. + so an un-allowlisted var is simply absent in the child. If the app owns + its value, put it in `appWorkerEnv()` and add it with its accepted shape + to `CLIENT_WORKER_ENV` in `isolated-vm-host.cjs`, so a remote worker + gets the same value. +- [ ] Does the host send the worker a new message type? Add it to + `ALLOWED_INBOUND_TYPES` in `isolated-vm-host.cjs`, or remote workers + never receive it. + +## Where the worker runs + +The pool in `isolated-vm.ts` opens each worker either as a local child process +or, when `IVM_REMOTE_WORKER_URL` is set (with `IVM_REMOTE_WORKER_TOKEN`; a URL +without a valid token fails executions rather than falling back) and the +`isolated-vm-local-workers` kill switch is off, as a WebSocket connection to +`isolated-vm-host.cjs`, which runs the +same worker in a dedicated service that holds no platform credentials. Every +rule in this file applies in both modes. + +- `fetch()` and brokers always run in the app. The host only relays the + worker's messages, so it needs no network access of its own and never gets + a broker implementation. +- A remote target never falls back to a local child process. An unreachable + host fails executions with a system error; keep it that way. +- Changes to the worker message protocol must stay compatible with the + previous release: the host and its clients roll out independently. ## What the worker *may* hold diff --git a/.cursor/rules/sim-sandbox.mdc b/.cursor/rules/sim-sandbox.mdc index 351c4e74292..f9ce1e078b6 100644 --- a/.cursor/rules/sim-sandbox.mdc +++ b/.cursor/rules/sim-sandbox.mdc @@ -1,6 +1,6 @@ --- description: "Isolated-vm sandbox worker security policy. Hard rules for anything that lives in the worker child process that runs user code." -globs: ["apps/sim/lib/execution/isolated-vm-worker.cjs","apps/sim/lib/execution/isolated-vm.ts","apps/sim/lib/execution/sandbox/**","apps/sim/sandboxes/**"] +globs: ["apps/sim/lib/execution/isolated-vm-worker.cjs","apps/sim/lib/execution/isolated-vm-host.cjs","apps/sim/lib/execution/isolated-vm-remote.ts","apps/sim/lib/execution/isolated-vm.ts","apps/sim/lib/execution/sandbox/**","apps/sim/sandboxes/**"] --- @@ -73,7 +73,31 @@ payload or `ivm.Reference` wrapper in the worker: - [ ] Does the worker read a new env var? Add it to the `buildWorkerEnv()` allowlist in `isolated-vm.ts` **and** to the allowlist regression test in `isolated-vm.test.ts` — the worker does not inherit the app environment, - so an un-allowlisted var is simply absent in the child. + so an un-allowlisted var is simply absent in the child. If the app owns + its value, put it in `appWorkerEnv()` and add it with its accepted shape + to `CLIENT_WORKER_ENV` in `isolated-vm-host.cjs`, so a remote worker + gets the same value. +- [ ] Does the host send the worker a new message type? Add it to + `ALLOWED_INBOUND_TYPES` in `isolated-vm-host.cjs`, or remote workers + never receive it. + +## Where the worker runs + +The pool in `isolated-vm.ts` opens each worker either as a local child process +or, when `IVM_REMOTE_WORKER_URL` is set (with `IVM_REMOTE_WORKER_TOKEN`; a URL +without a valid token fails executions rather than falling back) and the +`isolated-vm-local-workers` kill switch is off, as a WebSocket connection to +`isolated-vm-host.cjs`, which runs the +same worker in a dedicated service that holds no platform credentials. Every +rule in this file applies in both modes. + +- `fetch()` and brokers always run in the app. The host only relays the + worker's messages, so it needs no network access of its own and never gets + a broker implementation. +- A remote target never falls back to a local child process. An unreachable + host fails executions with a system error; keep it that way. +- Changes to the worker message protocol must stay compatible with the + previous release: the host and its clients roll out independently. ## What the worker *may* hold diff --git a/apps/sim/lib/audio/extractor.test.ts b/apps/sim/lib/audio/extractor.test.ts index 58c9d45f1b1..e399794e35a 100644 --- a/apps/sim/lib/audio/extractor.test.ts +++ b/apps/sim/lib/audio/extractor.test.ts @@ -73,6 +73,7 @@ describe('audio FFmpeg execution', () => { it('keeps MIME-derived input filenames inside the temporary directory', async () => { await getAudioMetadata(Buffer.from('audio'), 'audio/../../../../escaped') + expect(calls[0].args.slice(0, 2)).toEqual(['-protocol_whitelist', 'file,pipe']) const inputFile = calls[0].args.at(-1) expect(inputFile).toBeDefined() expect(path.basename(inputFile as string)).toBe('input.dat') @@ -97,6 +98,8 @@ describe('audio FFmpeg execution', () => { expect(conversion?.args).toEqual([ '-y', '-nostdin', + '-protocol_whitelist', + 'file,pipe', '-i', expect.stringMatching(/input\.mp4$/), '-f', diff --git a/apps/sim/lib/audio/extractor.ts b/apps/sim/lib/audio/extractor.ts index cdda8e06368..c540f54b00c 100644 --- a/apps/sim/lib/audio/extractor.ts +++ b/apps/sim/lib/audio/extractor.ts @@ -10,7 +10,12 @@ import type { } from '@/lib/audio/types' import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { MAX_MEDIA_BYTES } from '@/lib/media/falai' -import { FFMPEG_BASE_ARGS, resolveExecutable, runExecutable } from '@/lib/media/ffmpeg-process' +import { + ffmpegArgs, + ffprobeJsonArgs, + resolveExecutable, + runExecutable, +} from '@/lib/media/ffmpeg-process' const logger = createLogger('AudioExtractor') @@ -59,7 +64,7 @@ async function withTempDir(prefix: string, fn: (dir: string) => Promise): async function runFfmpeg(args: string[], signal?: AbortSignal): Promise { try { - await runExecutable(requireFfmpeg(), [...FFMPEG_BASE_ARGS, ...args], { + await runExecutable(requireFfmpeg(), ffmpegArgs(args), { maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES, signal, timeoutMs: CONVERSION_TIMEOUT_MS, @@ -195,15 +200,11 @@ async function getAudioMetadataFromFile( ): Promise { let stdout: string try { - ;({ stdout } = await runExecutable( - requireFfprobe(), - ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', filePath], - { - maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES, - signal, - timeoutMs: PROBE_TIMEOUT_MS, - } - )) + ;({ stdout } = await runExecutable(requireFfprobe(), ffprobeJsonArgs(filePath), { + maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES, + signal, + timeoutMs: PROBE_TIMEOUT_MS, + })) } catch (error) { signal?.throwIfAborted() const failure = error as NodeJS.ErrnoException & { killed?: boolean; stderr?: string } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 0157cf43222..b0a10f0c6ee 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -456,6 +456,8 @@ export const env = createEnv({ IVM_MAX_BROKER_ARGS_JSON_CHARS: z.string().optional().default('262144'), // Max JSON payload size for sandbox task broker args (isolate→host) IVM_MAX_BROKER_RESULT_JSON_CHARS: z.string().optional().default('16777216'),// Max JSON payload size for sandbox task broker results (host→isolate) IVM_MAX_BROKERS_PER_EXECUTION: z.string().optional().default('1000'), // Max broker calls per sandbox task execution + IVM_REMOTE_WORKER_URL: z.string().optional(), // ws(s):///v1/worker of a dedicated worker host; workers run there unless the isolated-vm-local-workers kill switch is on + IVM_REMOTE_WORKER_TOKEN: z.string().optional(), // Token the worker host expects (its IVM_HOST_TOKEN, >= 32 chars); required with IVM_REMOTE_WORKER_URL // Knowledge Base Processing Configuration - Shared across all processing methods KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index cedbf9d8c42..766745905eb 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -121,6 +121,14 @@ const FEATURE_FLAGS = { 'targeting. Off-AppConfig falls back to KNOWLEDGE_MEMBER_ACCESS.', fallback: 'KNOWLEDGE_MEMBER_ACCESS', }, + 'isolated-vm-local-workers': { + description: + 'Kill switch: run JavaScript sandbox workers as local child processes even where ' + + 'IVM_REMOTE_WORKER_URL names a dedicated worker host. Global on/off only; off by default, ' + + 'so a configured host is used unless this is turned on. AppConfig-only: when AppConfig is ' + + 'unreachable the host stays in use.', + fallback: null, + }, 'mothership-abandoned-run-takeover-disabled': { description: 'Kill switch for Sim taking over Chat runs whose controller died (a deploy or a crash) and ' + diff --git a/apps/sim/lib/execution/isolated-vm-host.cjs b/apps/sim/lib/execution/isolated-vm-host.cjs new file mode 100644 index 00000000000..ca194d8ef2d --- /dev/null +++ b/apps/sim/lib/execution/isolated-vm-host.cjs @@ -0,0 +1,376 @@ +/** + * Network host for the isolated-vm worker. + * + * Runs the unchanged `isolated-vm-worker.cjs` in a separate service that holds + * no platform credentials. Each authenticated WebSocket connection owns + * exactly one worker child process: messages are relayed verbatim between the + * socket and the child's IPC channel, closing the socket kills the child, and + * the child exiting closes the socket. The app's pool in `isolated-vm.ts` + * therefore treats a connection exactly like a locally spawned worker. + * + * Wire contract (`/v1/worker`): one JSON object per text frame. The first + * client frame is `{ type: 'auth', token, env }`, where `env` carries the + * worker settings the app owns (limits, timezone, locale); after it, frames + * are the objects the worker + * exchanges over IPC. Host-originated frames are `{ type: 'host:heartbeat' }` + * every 30s and `{ type: 'host:draining' }` on SIGTERM, after which the app + * retires the connection once its executions finish. A breaking change to the + * contract gets a new path served alongside this one until every client has + * rolled. + * + * Environment: + * - `IVM_HOST_TOKEN` (required, >= 32 chars): token clients present. + * - `IVM_HOST_PORT` (default 3100) + * - `IVM_HOST_MAX_WORKERS` (default 16): concurrent worker processes. + * - `IVM_HOST_MAX_MESSAGE_BYTES` (default 64 MiB): largest accepted frame. + * - `IVM_HOST_DRAIN_TIMEOUT_MS` (default 110000): SIGTERM grace before exit. + */ + +const crypto = require('node:crypto') +const fs = require('node:fs') +const http = require('node:http') +const path = require('node:path') +const { spawn } = require('node:child_process') +const { WebSocketServer } = require('ws') + +const WORKER_PATH = '/v1/worker' +const HEARTBEAT_INTERVAL_MS = 30000 +const AUTH_TIMEOUT_MS = 5000 +const WORKER_START_TIMEOUT_MS = 10000 +const MAX_STARTUP_STDERR_CHARS = 64 * 1024 +const MIN_TOKEN_LENGTH = 32 +const UNAUTHORIZED_CLOSE_CODE = 4401 +const ALLOWED_INBOUND_TYPES = new Set(['execute', 'cancel', 'fetchResponse', 'brokerResponse']) +/** Worker settings a client may set, each with the only shape it accepts. */ +const CLIENT_WORKER_ENV = { + IVM_MAX_STDOUT_CHARS: /^\d{1,12}$/, + IVM_MAX_FETCH_OPTIONS_JSON_CHARS: /^\d{1,12}$/, + TZ: /^:?[A-Za-z0-9_+\-/]{1,64}$/, + LANG: /^[A-Za-z0-9_.@-]{1,64}$/, + LC_ALL: /^[A-Za-z0-9_.@-]{1,64}$/, +} + +function readPositiveInt(name, fallback) { + const parsed = Number.parseInt(process.env[name] || '', 10) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function log(level, message, fields) { + const line = JSON.stringify({ level, message, ...fields, timestamp: new Date().toISOString() }) + if (level === 'error' || level === 'warn') process.stderr.write(`${line}\n`) + else process.stdout.write(`${line}\n`) +} + +// The worker is spawned with this binary, and isolated-vm only loads in Node. +if (process.versions.bun) { + log('error', 'The isolated-vm worker host must run under Node.js, not Bun') + process.exit(1) +} + +const PORT = readPositiveInt('IVM_HOST_PORT', 3100) +const MAX_WORKERS = readPositiveInt('IVM_HOST_MAX_WORKERS', 16) +/** Unauthenticated connections are bounded on their own, so they never hold a worker slot. */ +const MAX_PENDING_AUTH = MAX_WORKERS * 4 +const MAX_MESSAGE_BYTES = readPositiveInt('IVM_HOST_MAX_MESSAGE_BYTES', 64 * 1024 * 1024) +const DRAIN_TIMEOUT_MS = readPositiveInt('IVM_HOST_DRAIN_TIMEOUT_MS', 110000) + +const token = process.env.IVM_HOST_TOKEN || '' +if (token.length < MIN_TOKEN_LENGTH) { + log('error', `IVM_HOST_TOKEN must be set to at least ${MIN_TOKEN_LENGTH} characters`) + process.exit(1) +} +const tokenDigest = crypto.createHash('sha256').update(token).digest() + +const workerScript = path.join(__dirname, 'isolated-vm-worker.cjs') +if (!fs.existsSync(workerScript)) { + log('error', 'Worker script not found', { workerScript }) + process.exit(1) +} + +function isAuthorized(presented) { + if (typeof presented !== 'string') return false + const digest = crypto.createHash('sha256').update(presented).digest() + return crypto.timingSafeEqual(digest, tokenDigest) +} + +/** + * Mirrors `buildWorkerEnv()` in `isolated-vm.ts`: the worker runs untrusted + * code and receives an explicit allowlist, never this process's environment. + * Settings the app owns come from the client, so a remote worker behaves like + * a local one. + */ +function buildWorkerEnv(clientEnv) { + const workerEnv = {} + for (const key of ['PATH', 'NODE_ENV']) { + if (process.env[key] !== undefined) workerEnv[key] = process.env[key] + } + if (clientEnv && typeof clientEnv === 'object') { + for (const [key, pattern] of Object.entries(CLIENT_WORKER_ENV)) { + const value = clientEnv[key] + if (typeof value === 'string' && pattern.test(value)) workerEnv[key] = value + } + } + return workerEnv +} + +function rejectUpgrade(socket, status, reason) { + socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, () => + socket.destroy() + ) +} + +const workers = new Set() +let pendingConnections = 0 +let draining = false +let nextConnectionId = 0 + +const server = http.createServer((req, res) => { + // Liveness: the process is up. Stays 200 while draining so an orchestrator + // never kills a task that is finishing in-flight executions. + if (req.method === 'GET' && req.url === '/livez') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"status":"ok"}') + return + } + // Readiness: 503 while draining, so load balancers stop routing new workers here. + if (req.method === 'GET' && req.url === '/health') { + const status = draining ? 503 : 200 + res.writeHead(status, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + status: draining ? 'draining' : 'ok', + workers: workers.size, + maxWorkers: MAX_WORKERS, + }) + ) + return + } + res.writeHead(404) + res.end() +}) +server.headersTimeout = 10000 +server.requestTimeout = 10000 + +const wss = new WebSocketServer({ + noServer: true, + maxPayload: MAX_MESSAGE_BYTES, + perMessageDeflate: false, +}) + +server.on('upgrade', (req, socket, head) => { + socket.on('error', () => socket.destroy()) + let pathname + try { + pathname = new URL(req.url || '/', 'http://host').pathname + } catch { + rejectUpgrade(socket, 400, 'Bad Request') + return + } + if (pathname !== WORKER_PATH) { + rejectUpgrade(socket, 404, 'Not Found') + return + } + if (draining) { + rejectUpgrade(socket, 503, 'Service Unavailable') + return + } + if (workers.size >= MAX_WORKERS || pendingConnections >= MAX_PENDING_AUTH) { + log('warn', 'Rejected worker connection at capacity', { maxWorkers: MAX_WORKERS }) + rejectUpgrade(socket, 503, 'Service Unavailable') + return + } + wss.handleUpgrade(req, socket, head, (ws) => authenticate(ws)) +}) + +/** Admits the connection to a worker slot only once its first frame proves it holds the token. */ +function authenticate(ws) { + pendingConnections++ + let settled = false + const settle = () => { + if (settled) return false + settled = true + pendingConnections-- + clearTimeout(authTimeout) + return true + } + const refuse = (reason) => { + if (!settle()) return + log('warn', 'Rejected worker connection', { reason }) + ws.close(UNAUTHORIZED_CLOSE_CODE, 'unauthorized') + } + const authTimeout = setTimeout(() => refuse('authentication timed out'), AUTH_TIMEOUT_MS) + const onPreAuthError = (error) => { + log('warn', 'Worker connection error before authentication', { error: error.message }) + } + ws.on('error', onPreAuthError) + + ws.once('message', (data, isBinary) => { + let message + try { + message = isBinary ? undefined : JSON.parse(data.toString()) + } catch {} + if (!message || message.type !== 'auth' || !isAuthorized(message.token)) { + refuse('invalid credentials') + return + } + if (!settle()) return + if (draining) { + ws.close(1012, 'draining') + return + } + if (workers.size >= MAX_WORKERS) { + log('warn', 'Rejected worker connection at capacity', { maxWorkers: MAX_WORKERS }) + ws.close(1013, 'at capacity') + return + } + ws.off('error', onPreAuthError) + attachWorker(ws, buildWorkerEnv(message.env)) + }) + ws.on('close', () => settle()) +} + +function attachWorker(ws, workerEnv) { + const connectionId = ++nextConnectionId + // Required for isolated-vm on Node.js 20+ (issue #377). + const child = spawn(process.execPath, ['--no-node-snapshot', workerScript], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + serialization: 'json', + env: workerEnv, + }) + workers.add(child) + + let ready = false + let alive = true + let startupStderr = '' + child.stderr.on('data', (data) => { + if (ready || startupStderr.length >= MAX_STARTUP_STDERR_CHARS) return + startupStderr = `${startupStderr}${data.toString()}`.slice(0, MAX_STARTUP_STDERR_CHARS) + }) + + const startTimeout = setTimeout(() => { + log('error', 'Worker failed to start within timeout', { connectionId }) + child.kill('SIGKILL') + }, WORKER_START_TIMEOUT_MS) + + // Protocol pings find a dead client; the heartbeat frame lets a client find a + // dead host, since standard WebSocket clients cannot observe pings. + const heartbeat = setInterval(() => { + if (!alive) { + log('warn', 'Worker connection missed heartbeat', { connectionId }) + ws.terminate() + return + } + alive = false + ws.ping() + ws.send('{"type":"host:heartbeat"}') + }, HEARTBEAT_INTERVAL_MS) + ws.on('pong', () => { + alive = true + }) + + child.on('message', (message) => { + const becameReady = Boolean(message && message.type === 'ready') + if (becameReady) { + ready = true + clearTimeout(startTimeout) + } + if (ws.readyState !== ws.OPEN) return + ws.send(JSON.stringify(message)) + // The app registers a worker only once it is ready, so a drain notice that + // reached this connection earlier was dropped; repeat it now. + if (becameReady && draining) ws.send('{"type":"host:draining"}') + }) + + ws.on('message', (data, isBinary) => { + let message + try { + message = isBinary ? undefined : JSON.parse(data.toString()) + } catch {} + if (!message || typeof message !== 'object' || !ALLOWED_INBOUND_TYPES.has(message.type)) { + log('warn', 'Closing worker connection after an invalid frame', { connectionId }) + ws.close(1007, 'invalid frame') + return + } + if (!child.connected) return + try { + child.send(message) + } catch (error) { + log('error', 'Failed to deliver a frame to the worker', { + connectionId, + // utils-lint-allow: this standalone host is copied into the image without workspace packages + error: error instanceof Error ? error.message : String(error), + }) + ws.close(1011, 'worker unavailable') + } + }) + + ws.on('close', () => { + clearInterval(heartbeat) + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + }) + ws.on('error', (error) => { + log('warn', 'Worker connection error', { connectionId, error: error.message }) + }) + + child.on('exit', (code, signal) => { + clearTimeout(startTimeout) + clearInterval(heartbeat) + workers.delete(child) + if (!ready) { + log('error', 'Worker exited before becoming ready', { + connectionId, + code, + signal, + stderr: startupStderr, + }) + } + if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) { + ws.close(1011, 'worker exited') + } + if (draining && workers.size === 0) shutdown() + }) + child.on('error', (error) => { + log('error', 'Worker process error', { connectionId, error: error.message }) + // A child that never started may not emit 'exit'; release its slot here. + if (child.pid !== undefined) return + clearTimeout(startTimeout) + clearInterval(heartbeat) + workers.delete(child) + if (ws.readyState === ws.OPEN) ws.close(1011, 'worker unavailable') + if (draining && workers.size === 0) shutdown() + }) +} + +let shuttingDown = false +function shutdown() { + if (shuttingDown) return + shuttingDown = true + for (const child of workers) child.kill('SIGKILL') + wss.close() + server.close(() => process.exit(0)) + setTimeout(() => process.exit(0), 1000).unref() +} + +function startDraining(signal) { + if (draining) return + draining = true + log('info', 'Draining worker host', { signal, workers: workers.size }) + for (const client of wss.clients) { + if (client.readyState === client.OPEN) client.send('{"type":"host:draining"}') + } + if (workers.size === 0) { + shutdown() + return + } + setTimeout(() => { + log('warn', 'Drain timeout elapsed; terminating remaining workers', { workers: workers.size }) + shutdown() + }, DRAIN_TIMEOUT_MS).unref() +} + +process.on('SIGTERM', () => startDraining('SIGTERM')) +process.on('SIGINT', () => startDraining('SIGINT')) + +server.listen(PORT, () => { + log('info', 'isolated-vm worker host listening', { port: PORT, maxWorkers: MAX_WORKERS }) +}) diff --git a/apps/sim/lib/execution/isolated-vm-remote.ts b/apps/sim/lib/execution/isolated-vm-remote.ts new file mode 100644 index 00000000000..b43d0366faf --- /dev/null +++ b/apps/sim/lib/execution/isolated-vm-remote.ts @@ -0,0 +1,139 @@ +import { EventEmitter } from 'node:events' +import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' + +const logger = createLogger('IsolatedVMRemoteWorker') + +/** The host sends a heartbeat every 30s; 75s of silence is two missed beats plus slack. */ +const HOST_SILENCE_LIMIT_MS = 75_000 +const REMOTE_WORKER_URL_PATTERN = /^wss?:\/\/[^/]+\/v1\/worker$/ +const MIN_REMOTE_WORKER_TOKEN_LENGTH = 32 + +/** + * The slice of `ChildProcess` the isolated-vm pool uses, so a local child and a + * connection to a remote worker host are interchangeable. + */ +export interface IsolatedVMWorkerProcess { + send(message: unknown): boolean + kill(): boolean + on(event: 'message', listener: (message: unknown) => void): unknown + on(event: 'exit', listener: () => void): unknown + off(event: 'message', listener: (message: unknown) => void): unknown + readonly stderr?: NodeJS.ReadableStream | null + /** Why a remote worker closed, for the pool's startup-failure message. */ + readonly exitReason?: string +} + +export interface RemoteWorkerOptions { + url: string + token: string + /** Worker settings the app owns (limits, timezone, locale), so a remote worker matches a local one. */ + workerEnv: Record +} + +/** + * Opens one worker on a remote isolated-vm host (`isolated-vm-host.cjs`). + * + * The first frame authenticates the connection; the host then spawns a + * dedicated worker process for it and relays that worker's IPC messages as + * JSON text frames, so the returned object emits the worker's own `ready`, + * `result`, `fetch`, and `broker` messages. Closing the connection kills that + * worker; any close, error, or silent host surfaces as `exit` exactly once, + * which the pool treats like a crashed child. + */ +export function connectRemoteWorker(options: RemoteWorkerOptions): IsolatedVMWorkerProcess { + if (!REMOTE_WORKER_URL_PATTERN.test(options.url)) { + throw new Error('IVM_REMOTE_WORKER_URL must be a ws:// or wss:// URL ending in /v1/worker') + } + if (options.token.length < MIN_REMOTE_WORKER_TOKEN_LENGTH) { + throw new Error( + `IVM_REMOTE_WORKER_TOKEN must be at least ${MIN_REMOTE_WORKER_TOKEN_LENGTH} characters` + ) + } + + const emitter = new EventEmitter() + let exitReason: string | undefined + let exited = false + let silenceTimer: ReturnType | null = null + + const socket = new WebSocket(options.url) + + const emitExit = (reason: string) => { + if (exited) return + exited = true + exitReason ??= reason + if (silenceTimer) clearTimeout(silenceTimer) + emitter.emit('exit') + } + + /** Exits on a later tick, as a killed child does, so the pool never re-enters its own cleanup. */ + const terminate = (reason: string) => { + exitReason ??= reason + try { + socket.close() + } catch {} + setImmediate(() => emitExit(reason)) + } + + const resetSilenceTimer = () => { + if (silenceTimer) clearTimeout(silenceTimer) + silenceTimer = setTimeout( + () => terminate('Remote isolated-vm host stopped responding'), + HOST_SILENCE_LIMIT_MS + ) + } + + socket.addEventListener('open', () => { + resetSilenceTimer() + socket.send(JSON.stringify({ type: 'auth', token: options.token, env: options.workerEnv })) + }) + socket.addEventListener('message', (event) => { + resetSilenceTimer() + let message: unknown + try { + message = typeof event.data === 'string' ? JSON.parse(event.data) : undefined + } catch {} + if (!isRecordLike(message)) { + logger.error('Remote isolated-vm host sent an invalid frame; closing the worker') + terminate('Remote isolated-vm host sent an invalid frame') + return + } + if (message.type === 'host:heartbeat') return + emitter.emit('message', message) + }) + socket.addEventListener('error', () => { + exitReason ??= 'Remote isolated-vm host connection failed' + }) + socket.addEventListener('close', (event) => { + const detail = event.reason ? `: ${event.reason}` : '' + if (event.code !== 1000) { + logger.warn('Remote isolated-vm worker closed', { code: event.code, reason: event.reason }) + } + emitExit(`Remote isolated-vm worker closed (code ${event.code}${detail})`) + }) + + return { + /** Returns false once the connection is closing; the pending `exit` settles this worker's executions. */ + send(message) { + if (socket.readyState !== WebSocket.OPEN) return false + socket.send(JSON.stringify(message)) + return true + }, + kill() { + terminate('Remote isolated-vm worker closed by the app') + return true + }, + on(event: 'message' | 'exit', listener: (message: unknown) => void) { + emitter.on(event, listener) + return this + }, + off(event, listener) { + emitter.off(event, listener) + return this + }, + stderr: null, + get exitReason() { + return exitReason + }, + } +} diff --git a/apps/sim/lib/execution/isolated-vm.test.ts b/apps/sim/lib/execution/isolated-vm.test.ts index 606fdce994f..421d5af2bcc 100644 --- a/apps/sim/lib/execution/isolated-vm.test.ts +++ b/apps/sim/lib/execution/isolated-vm.test.ts @@ -6,6 +6,7 @@ import { redisConfigMockFns, setEnv, } from '@sim/testing' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' import { networkConfigMock, networkConfigMockFns } from '@sim/testing/mocks/network-config.mock' import { sleep } from '@sim/utils/helpers' import { describe, expect, it, vi } from 'vitest' @@ -183,6 +184,7 @@ networkConfigMockFns.mockIsOutboundRoutingEnabled.mockReturnValue(true) vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) vi.mock('@/lib/core/network/config.server', () => networkConfigMock) +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) vi.mock('node:child_process', () => ({ execSync: mockExecSync, spawn: mockSpawn, @@ -235,6 +237,8 @@ async function loadExecutionModule(options: { IVM_MAX_FETCH_URL_LENGTH: '', IVM_MAX_FETCH_OPTIONS_JSON_CHARS: '', REDIS_URL: '', + IVM_REMOTE_WORKER_URL: '', + IVM_REMOTE_WORKER_TOKEN: '', ...(options.envOverrides ?? {}), }) @@ -253,6 +257,24 @@ async function loadExecutionModule(options: { return { ...mod, spawnMock: mockSpawn, secureFetchMock: mockSecureFetch } } +/** A worker host that never accepts the connection, as when it is down or unroutable. */ +class UnreachableWebSocket extends EventTarget { + static readonly OPEN = 1 + readyState = 0 + + constructor() { + super() + setImmediate(() => { + this.readyState = 3 + this.dispatchEvent(new Event('error')) + this.dispatchEvent(Object.assign(new Event('close'), { code: 1006, reason: '' })) + }) + } + + send() {} + close() {} +} + describe('isolated-vm scheduler', () => { it('recovers from an initial spawn failure and drains queued work', async () => { const { executeInIsolatedVM, spawnMock } = await loadExecutionModule({ @@ -335,6 +357,65 @@ describe('isolated-vm scheduler', () => { } }) + it('never falls back to a local worker while a worker host is configured', async () => { + vi.stubGlobal('WebSocket', UnreachableWebSocket) + try { + const { executeInIsolatedVM, spawnMock } = await loadExecutionModule({ + envOverrides: { + IVM_REMOTE_WORKER_URL: 'ws://worker-host.internal:3100/v1/worker', + IVM_REMOTE_WORKER_TOKEN: 'remote-worker-token-of-at-least-32-chars', + }, + spawns: [() => createReadyProc('ran locally')], + }) + + const result = await executeInIsolatedVM({ + code: 'return "ran locally"', + params: {}, + envVars: {}, + contextVariables: {}, + timeoutMs: 100, + requestId: 'req-remote-down', + }) + + expect(result.result).toBeNull() + expect(result.error?.isSystemError).toBe(true) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it('runs local workers while the kill switch is on, even with a worker host configured', async () => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( + async (flag) => flag === 'isolated-vm-local-workers' + ) + vi.stubGlobal('WebSocket', UnreachableWebSocket) + try { + const { executeInIsolatedVM, spawnMock } = await loadExecutionModule({ + envOverrides: { + IVM_REMOTE_WORKER_URL: 'ws://worker-host.internal:3100/v1/worker', + IVM_REMOTE_WORKER_TOKEN: 'remote-worker-token-of-at-least-32-chars', + }, + spawns: [() => createReadyProc('ran locally')], + }) + + const result = await executeInIsolatedVM({ + code: 'return "ran locally"', + params: {}, + envVars: {}, + contextVariables: {}, + timeoutMs: 100, + requestId: 'req-kill-switch', + }) + + expect(result.result).toBe('ran locally') + expect(spawnMock).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + featureFlagsMockFns.mockIsFeatureEnabled.mockReset().mockResolvedValue(false) + } + }) + it('rejects new requests when the queue is full', async () => { const holder = createControllableReadyProc() const { executeInIsolatedVM } = await loadExecutionModule({ diff --git a/apps/sim/lib/execution/isolated-vm.ts b/apps/sim/lib/execution/isolated-vm.ts index 936186f9455..5da070f4e1a 100644 --- a/apps/sim/lib/execution/isolated-vm.ts +++ b/apps/sim/lib/execution/isolated-vm.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, execSync } from 'node:child_process' +import { execSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -8,6 +8,7 @@ import { filterUndefined } from '@sim/utils/object' import { randomFloat } from '@sim/utils/random' import { env } from '@/lib/core/config/env' import { getConfiguredCacheProvider } from '@/lib/core/config/env-capabilities.server' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getRedisClient } from '@/lib/core/config/redis' import { captureOutboundScope } from '@/lib/core/network/context.server' import { @@ -17,6 +18,10 @@ import { import type { CodePlaceholderRuntimeBinding } from '@/lib/execution/code-placeholders' import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime' import { MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS } from '@/lib/execution/isolated-vm-limits' +import { + connectRemoteWorker, + type IsolatedVMWorkerProcess, +} from '@/lib/execution/isolated-vm-remote' const logger = createLogger('IsolatedVMExecution') @@ -177,7 +182,9 @@ interface PendingExecution { } interface WorkerInfo { - process: ChildProcess + process: IsolatedVMWorkerProcess + /** {@link workerTargetKey} of the target this worker was opened on. */ + targetKey: string ready: boolean readyPromise: Promise | null activeExecutions: number @@ -242,6 +249,44 @@ let nextWorkerId = 0 let spawnInProgress = 0 let queueDrainRetryTimeout: ReturnType | null = null +/** + * Where workers run: connections to the dedicated worker host + * (`isolated-vm-host.cjs`) when `IVM_REMOTE_WORKER_URL` is set, otherwise + * local child processes. The `isolated-vm-local-workers` kill switch forces + * local workers during rollout. + */ +type WorkerTarget = { kind: 'local' } | { kind: 'remote'; url: string; token: string } + +const LOCAL_WORKER_TARGET: WorkerTarget = { kind: 'local' } +let workerTarget: WorkerTarget = LOCAL_WORKER_TARGET + +function workerTargetKey(target: WorkerTarget): string { + return target.kind === 'remote' ? `remote:${target.url}` : 'local' +} + +async function resolveWorkerTarget(): Promise { + const url = env.IVM_REMOTE_WORKER_URL?.trim() + if (!url || (await isFeatureEnabled('isolated-vm-local-workers'))) return LOCAL_WORKER_TARGET + return { kind: 'remote', url, token: env.IVM_REMOTE_WORKER_TOKEN ?? '' } +} + +/** + * Switches where new workers open. Workers on the previous target retire: they + * finish what they are running and take no new work, so a flag change moves + * every subsequent execution without interrupting one in flight. + */ +function activateWorkerTarget(target: WorkerTarget) { + const key = workerTargetKey(target) + if (key === workerTargetKey(workerTarget)) return + logger.info('Isolated-vm worker target changed', { from: workerTarget.kind, to: target.kind }) + workerTarget = target + for (const [workerId, workerInfo] of workers) { + if (workerInfo.targetKey === key || workerInfo.retiring) continue + workerInfo.retiring = true + if (workerInfo.activeExecutions === 0) cleanupWorker(workerId) + } +} + type IsolatedFetchOptions = RequestInit & { timeout?: number maxRedirects?: number @@ -815,6 +860,15 @@ function handleWorkerMessage(workerId: number, message: unknown) { return } + if (msg.type === 'host:draining') { + if (!workerInfo || workerInfo.retiring) return + workerInfo.retiring = true + logger.info('Remote worker host draining; retiring worker', { workerId }) + if (workerInfo.activeExecutions === 0) cleanupWorker(workerId) + drainQueue() + return + } + if (msg.type === 'fetch') { const pending = typeof msg.executionId === 'number' @@ -960,11 +1014,7 @@ function resetWorkerIdleTimeout(workerId: number) { function buildWorkerEnv(): NodeJS.ProcessEnv { const allowed: Record = { PATH: process.env.PATH, - IVM_MAX_STDOUT_CHARS: env.IVM_MAX_STDOUT_CHARS, - IVM_MAX_FETCH_OPTIONS_JSON_CHARS: env.IVM_MAX_FETCH_OPTIONS_JSON_CHARS, - TZ: process.env.TZ, - LANG: process.env.LANG, - LC_ALL: process.env.LC_ALL, + ...appWorkerEnv(), SYSTEMROOT: process.env.SYSTEMROOT, WINDIR: process.env.WINDIR, COMSPEC: process.env.COMSPEC, @@ -975,11 +1025,72 @@ function buildWorkerEnv(): NodeJS.ProcessEnv { return { ...filterUndefined(allowed), NODE_ENV: process.env.NODE_ENV } } +/** + * Worker settings whose value the app owns: its limits, and the timezone and locale that shape + * `Date` and `Intl` inside isolates. A remote worker receives them on connect, so moving workers + * to the host changes no results. + */ +function appWorkerEnv(): Record { + return { + IVM_MAX_STDOUT_CHARS: env.IVM_MAX_STDOUT_CHARS, + IVM_MAX_FETCH_OPTIONS_JSON_CHARS: env.IVM_MAX_FETCH_OPTIONS_JSON_CHARS, + TZ: process.env.TZ, + LANG: process.env.LANG, + LC_ALL: process.env.LC_ALL, + } +} + +/** + * Starts the process a worker runs in for `target`. + * + * A remote target never falls back to a local child: when the host is + * unreachable or misconfigured the spawn fails and executions report a system + * error, exactly as a local worker that cannot start. + */ +async function openWorkerProcess(target: WorkerTarget): Promise { + if (target.kind === 'remote') { + return connectRemoteWorker({ + url: target.url, + token: target.token, + workerEnv: appWorkerEnv(), + }) + } + + if (!checkNodeAvailable()) { + throw new Error( + 'Node.js is required for code execution but was not found. ' + + 'Please install Node.js (v20+) from https://nodejs.org' + ) + } + + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const candidatePaths = [ + path.join(currentDir, 'isolated-vm-worker.cjs'), + path.join(currentDir, '..', '..', 'lib', 'execution', 'isolated-vm-worker.cjs'), + path.join(process.cwd(), 'apps', 'sim', 'lib', 'execution', 'isolated-vm-worker.cjs'), + path.join(process.cwd(), 'lib', 'execution', 'isolated-vm-worker.cjs'), + ] + const workerPath = candidatePaths.find((p) => fs.existsSync(p)) + if (!workerPath) { + throw new Error(`Worker file not found at any of: ${candidatePaths.join(', ')}`) + } + + return import('node:child_process').then(({ spawn }) => + // Required for isolated-vm on Node.js 20+ (issue #377) + spawn('node', ['--no-node-snapshot', workerPath], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + serialization: 'json', + env: buildWorkerEnv(), + }) + ) +} + function spawnWorker(): Promise { const workerId = nextWorkerId++ spawnInProgress++ let spawnSettled = false - let childProcess: ChildProcess | null = null + let childProcess: IsolatedVMWorkerProcess | null = null + const target = workerTarget const settleSpawnInProgress = () => { if (spawnSettled) { @@ -1003,45 +1114,14 @@ function spawnWorker(): Promise { pendingExecutions: new Map(), idleTimeout: null, id: workerId, + targetKey: workerTargetKey(target), lifetimeExecutions: 0, retiring: false, } workerInfo.readyPromise = new Promise((resolve, reject) => { - if (!checkNodeAvailable()) { - settleSpawnInProgress() - reject( - new Error( - 'Node.js is required for code execution but was not found. ' + - 'Please install Node.js (v20+) from https://nodejs.org' - ) - ) - return - } - - const currentDir = path.dirname(fileURLToPath(import.meta.url)) - const candidatePaths = [ - path.join(currentDir, 'isolated-vm-worker.cjs'), - path.join(currentDir, '..', '..', 'lib', 'execution', 'isolated-vm-worker.cjs'), - path.join(process.cwd(), 'apps', 'sim', 'lib', 'execution', 'isolated-vm-worker.cjs'), - path.join(process.cwd(), 'lib', 'execution', 'isolated-vm-worker.cjs'), - ] - const workerPath = candidatePaths.find((p) => fs.existsSync(p)) - - if (!workerPath) { - settleSpawnInProgress() - reject(new Error(`Worker file not found at any of: ${candidatePaths.join(', ')}`)) - return - } - - import('node:child_process') - .then(({ spawn }) => { - // Required for isolated-vm on Node.js 20+ (issue #377) - const proc = spawn('node', ['--no-node-snapshot', workerPath], { - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - serialization: 'json', - env: buildWorkerEnv(), - }) + openWorkerProcess(target) + .then((proc) => { childProcess = proc proc.on('message', (message: unknown) => handleWorkerMessage(workerId, message)) @@ -1075,6 +1155,8 @@ function spawnWorker(): Promise { return } workerInfo.ready = true + // The target changed while this worker was starting, so it takes no work. + workerInfo.retiring = workerInfo.targetKey !== workerTargetKey(workerTarget) clearTimeout(startTimeout) proc.off('message', readyHandler) workers.set(workerId, workerInfo) @@ -1092,7 +1174,7 @@ function spawnWorker(): Promise { clearTimeout(startTimeout) if (!settleSpawnInProgress()) return - let errorMessage = 'Worker process exited unexpectedly' + let errorMessage = proc.exitReason ?? 'Worker process exited unexpectedly' if (stderrData.includes('isolated_vm') || stderrData.includes('MODULE_NOT_FOUND')) { errorMessage = 'Code execution requires the isolated-vm native module which failed to load. ' + @@ -1114,7 +1196,7 @@ function spawnWorker(): Promise { }) .catch((error) => { if (!settleSpawnInProgress()) return - reject(error instanceof Error ? error : new Error('Failed to load child_process module')) + reject(error instanceof Error ? error : new Error('Failed to start worker process')) }) }) @@ -1182,6 +1264,14 @@ function dispatchToWorker( return } + // A target change can retire (or retire and remove) the worker between its selection and this + // dispatch; the execution then waits for a worker on the current target. + if (workers.get(workerInfo.id) !== workerInfo || workerInfo.retiring) { + if (workerInfo.retiring && workerInfo.activeExecutions === 0) cleanupWorker(workerInfo.id) + enqueueExecution(ownerState, req, resolve, state, brokers) + return + } + const execId = ++executionIdCounter state.workerId = workerInfo.id state.execId = execId @@ -1367,7 +1457,10 @@ function drainQueue() { const currentPoolSize = activeWorkerCount + spawnInProgress if (currentPoolSize < POOL_SIZE) { spawnWorker() - .then(() => drainQueue()) + .then((spawned) => { + if (spawned.retiring && spawned.activeExecutions === 0) cleanupWorker(spawned.id) + drainQueue() + }) .catch((err) => { logger.error('Failed to spawn worker during drain', { err }) scheduleDrainRetry() @@ -1402,6 +1495,7 @@ export async function executeInIsolatedVM( req: IsolatedVMExecutionRequest, options?: IsolatedVMExecutionOptions ): Promise { + activateWorkerTarget(await resolveWorkerTarget()) const ownerKey = normalizeOwnerKey(req.ownerKey) const ownerWeight = normalizeOwnerWeight(req.ownerWeight) const ownerState = getOrCreateOwnerState(ownerKey, ownerWeight) diff --git a/apps/sim/lib/media/ffmpeg-process.ts b/apps/sim/lib/media/ffmpeg-process.ts index f014832ca14..8bff63448f4 100644 --- a/apps/sim/lib/media/ffmpeg-process.ts +++ b/apps/sim/lib/media/ffmpeg-process.ts @@ -1,5 +1,6 @@ import { execFile, execFileSync } from 'node:child_process' import { toError } from '@sim/utils/errors' +import { filterUndefined } from '@sim/utils/object' const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024 @@ -9,7 +10,37 @@ const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024 * makes ffmpeg prompt on an unread stdin pipe and hang until the timeout * kills it. */ -export const FFMPEG_BASE_ARGS = ['-y', '-nostdin'] as const +const FFMPEG_BASE_ARGS = ['-y', '-nostdin'] as const + +/** + * Inputs are always files this process wrote, so they may open local files and + * nothing else; a container that names a URL (a playlist, a reference movie) + * fails instead of reaching the network. The option applies to the input that + * follows it. + */ +const INPUT_PROTOCOL_WHITELIST = ['-protocol_whitelist', 'file,pipe'] as const + +/** FFmpeg argv: base flags, then `args` with the protocol whitelist before every input. */ +export function ffmpegArgs(args: readonly string[]): string[] { + return [ + ...FFMPEG_BASE_ARGS, + ...args.flatMap((arg) => (arg === '-i' ? [...INPUT_PROTOCOL_WHITELIST, arg] : [arg])), + ] +} + +/** FFprobe argv that prints the format and streams of `filePath` as JSON. */ +export function ffprobeJsonArgs(filePath: string): string[] { + return [ + ...INPUT_PROTOCOL_WHITELIST, + '-v', + 'error', + '-print_format', + 'json', + '-show_format', + '-show_streams', + filePath, + ] +} export interface ExecutableOptions { cwd?: string @@ -35,7 +66,23 @@ export function resolveExecutable(binary: string): string | null { } /** - * Run an executable with a shell-free argument vector and bounded output. + * The environment a media tool runs with: what it needs to find its libraries + * and fonts, never the variables this process was configured with. + */ +function executableEnv(): NodeJS.ProcessEnv { + const allowed = filterUndefined({ + PATH: process.env.PATH, + HOME: process.env.HOME, + FONTCONFIG_FILE: process.env.FONTCONFIG_FILE, + FONTCONFIG_PATH: process.env.FONTCONFIG_PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + }) + return { ...allowed, NODE_ENV: process.env.NODE_ENV } +} + +/** + * Run an executable with a shell-free argument vector, a minimal environment, + * and bounded output. * * `execFile` owns the timeout and abort handling, including killing the child. * `maxBuffer` bounds both stdout and stderr, which prevents FFmpeg diagnostics @@ -54,6 +101,7 @@ export function runExecutable( { cwd: options.cwd, encoding: 'utf-8', + env: executableEnv(), killSignal: 'SIGKILL', maxBuffer: options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, signal: options.signal, diff --git a/apps/sim/lib/media/ffmpeg.test.ts b/apps/sim/lib/media/ffmpeg.test.ts index db6d4474e5c..c14e96ecc7e 100644 --- a/apps/sim/lib/media/ffmpeg.test.ts +++ b/apps/sim/lib/media/ffmpeg.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { capturedArgs, + capturedEnvs, capturedVideoFilters, capturedCaptions, killSignals, @@ -12,6 +13,7 @@ const { saves, } = vi.hoisted(() => ({ capturedArgs: [] as string[][], + capturedEnvs: [] as Array, capturedVideoFilters: [] as string[], capturedCaptions: [] as string[], killSignals: [] as string[], @@ -26,9 +28,16 @@ vi.mock('node:child_process', () => ({ execFile: ( executable: string, args: string[], - options: { cwd?: string; killSignal?: string; signal?: AbortSignal; timeout?: number }, + options: { + cwd?: string + env?: NodeJS.ProcessEnv + killSignal?: string + signal?: AbortSignal + timeout?: number + }, callback: (error: Error | null, stdout: string, stderr: string) => void ) => { + capturedEnvs.push(options.env) if (executable.includes('ffprobe')) { callback(null, probeReport.json, '') return @@ -118,6 +127,7 @@ function nextSave(): Promise { beforeEach(() => { capturedArgs.length = 0 + capturedEnvs.length = 0 capturedVideoFilters.length = 0 capturedCaptions.length = 0 killSignals.length = 0 @@ -312,10 +322,14 @@ describe('runFfmpegOperation argument vectors', () => { expect(capturedArgs[0]).toEqual([ '-y', '-nostdin', + '-protocol_whitelist', + 'file,pipe', '-i', expect.stringMatching(/in-0\.mp4$/), '-stream_loop', '-1', + '-protocol_whitelist', + 'file,pipe', '-i', expect.stringMatching(/in-1\.mp4$/), '-map', @@ -337,6 +351,8 @@ describe('runFfmpegOperation argument vectors', () => { expect(capturedArgs[0]).toEqual([ '-y', '-nostdin', + '-protocol_whitelist', + 'file,pipe', '-i', expect.stringMatching(/in-0\.mp4$/), '-ss', @@ -355,6 +371,8 @@ describe('runFfmpegOperation argument vectors', () => { '-nostdin', '-ss', '3', + '-protocol_whitelist', + 'file,pipe', '-i', expect.stringMatching(/in-0\.mp4$/), '-frames:v', @@ -362,6 +380,24 @@ describe('runFfmpegOperation argument vectors', () => { expect.stringMatching(/out\.jpg$/), ]) }) + + it('runs FFmpeg and FFprobe without the app environment', async () => { + probeReport.json = + '{"streams":[{"codec_type":"video","width":64,"height":64}],"format":{"duration":"1"}}' + vi.stubEnv('SIM_APP_ONLY_SETTING', 'app-only') + try { + await runFfmpegOperation('concat', [videoInput, videoInput]) + } finally { + vi.unstubAllEnvs() + } + + expect(capturedEnvs.length).toBeGreaterThan(0) + for (const childEnv of capturedEnvs) { + expect(childEnv).toBeDefined() + expect(childEnv?.SIM_APP_ONLY_SETTING).toBeUndefined() + expect(childEnv?.PATH).toBe(process.env.PATH) + } + }) }) describe('runFfmpegOperation process bounds', () => { diff --git a/apps/sim/lib/media/ffmpeg.ts b/apps/sim/lib/media/ffmpeg.ts index 16c3f396250..24a8045d478 100644 --- a/apps/sim/lib/media/ffmpeg.ts +++ b/apps/sim/lib/media/ffmpeg.ts @@ -5,7 +5,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { FFMPEG_LIMITS } from '@/lib/media/ffmpeg-limits' -import { FFMPEG_BASE_ARGS, resolveExecutable, runExecutable } from '@/lib/media/ffmpeg-process' +import { + ffmpegArgs, + ffprobeJsonArgs, + resolveExecutable, + runExecutable, +} from '@/lib/media/ffmpeg-process' const logger = createLogger('MediaFfmpeg') @@ -289,7 +294,8 @@ async function runCommand( ensureFfmpeg() try { - await runExecutable(ffmpegPath as string, [...FFMPEG_BASE_ARGS, ...args, outputPath], { + const commandArgs = [...ffmpegArgs(args), outputPath] + await runExecutable(ffmpegPath as string, commandArgs, { cwd, maxOutputBytes: PROBE_MAX_OUTPUT_BYTES, signal: ctx.signal, @@ -330,15 +336,11 @@ async function probeFile(ctx: FfmpegRunContext, filePath: string): Promise +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, '127.0.0.1', () => { + const address = server.address() + server.close(() => { + if (address && typeof address === 'object') resolve(address.port) + else reject(new Error('No port assigned')) + }) + }) + }) +} + +/** + * `bun run` puts a `node` shim for Bun first on PATH, and isolated-vm only + * loads in real Node — the binary the production image runs the host with. + */ +function resolveNodeBinary(): string { + const candidates = execFileSync('which', ['-a', 'node'], { encoding: 'utf8' }) + .split('\n') + .filter(Boolean) + const node = candidates.find( + (candidate) => + execFileSync(candidate, ['-p', 'Boolean(process.versions.bun)'], { + encoding: 'utf8', + }).trim() === 'false' + ) + assert(node, 'Real Node.js is required to run the worker host') + return node +} + +const NODE_BINARY = resolveNodeBinary() + +async function startHost(maxWorkers: number): Promise { + const port = await freePort() + const token = generateRandomHex(32) + const child = spawn(NODE_BINARY, [HOST_SCRIPT], { + env: { + PATH: process.env.PATH, + NODE_ENV: 'test', + IVM_HOST_TOKEN: token, + IVM_HOST_PORT: String(port), + IVM_HOST_MAX_WORKERS: String(maxWorkers), + IVM_HOST_DRAIN_TIMEOUT_MS: '20000', + SIM_HOST_ONLY_SETTING: HOST_ONLY_VALUE, + }, + stdio: ['ignore', 'pipe', 'pipe'], + // Its own process group, so cleanup can stop the host and its worker children together. + detached: true, + }) + const relay = (data: Buffer) => logger.info(`host:${port} ${data.toString().trim()}`) + child.stdout?.on('data', relay) + child.stderr?.on('data', relay) + const exited = new Promise((resolve) => child.on('exit', (code) => resolve(code))) + for (let attempt = 0; attempt < 100; attempt++) { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`) + if (response.ok) { + const host = { process: child, port, token, exited } + hosts.push(host) + return host + } + } catch {} + await sleep(100) + } + child.kill('SIGKILL') + throw new Error('Worker host did not become healthy') +} + +/** Sends a bare WebSocket upgrade and returns the HTTP status the host answers with. */ +function upgradeStatus(host: Host, options: { path?: string } = {}): Promise { + return new Promise((resolve, reject) => { + const req = request({ + host: '127.0.0.1', + port: host.port, + path: options.path ?? '/v1/worker', + headers: { + connection: 'Upgrade', + upgrade: 'websocket', + 'sec-websocket-version': '13', + 'sec-websocket-key': Buffer.from(generateRandomHex(8)).toString('base64'), + }, + }) + req.on('upgrade', (res, socket) => { + socket.destroy() + resolve(res.statusCode ?? 0) + }) + req.on('response', (res) => { + res.resume() + resolve(res.statusCode ?? 0) + }) + req.on('error', reject) + req.end() + }) +} + +/** Opens a connection and sends `firstFrame`; resolves with the socket once it opens. */ +function openSocket(host: Host, firstFrame: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${host.port}/v1/worker`) + ws.addEventListener('open', () => { + ws.send(firstFrame) + resolve(ws) + }) + ws.addEventListener('error', () => reject(new Error('WebSocket connection failed'))) + }) +} + +function authFrame(token: string): string { + return JSON.stringify({ type: 'auth', token, env: {} }) +} + +/** Opens an authenticated worker connection; resolves once the worker is ready. */ +async function openWorker(host: Host): Promise { + const ws = await openSocket(host, authFrame(host.token)) + return new Promise((resolve) => { + ws.addEventListener('message', (event) => { + if (JSON.parse(String(event.data)).type === 'ready') resolve(ws) + }) + }) +} + +function closeCode(ws: WebSocket): Promise { + return new Promise((resolve) => ws.addEventListener('close', (event) => resolve(event.code))) +} + +function childProcessesOf(pid: number): { pid: number; command: string }[] { + const output = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,command='], { encoding: 'utf8' }) + return output.split('\n').flatMap((line) => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/) + if (!match || Number(match[2]) !== pid) return [] + return [{ pid: Number(match[1]), command: match[3] }] + }) +} + +function workerPidsOf(pid: number): number[] { + return childProcessesOf(pid) + .filter((child) => child.command.includes('isolated-vm-worker.cjs')) + .map((child) => child.pid) +} + +function environmentOf(pid: number): string { + return execFileSync('ps', ['eww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }) +} + +/** Waits until the host reports no live workers from earlier checks. */ +async function waitForNoWorkers(host: Host) { + for (let attempt = 0; attempt < 50; attempt++) { + const health = (await (await fetch(`http://127.0.0.1:${host.port}/health`)).json()) as { + workers: number + } + if (health.workers === 0) return + await sleep(100) + } + throw new Error('Host still reports live workers') +} + +/** A check that never settles is a failure, reported rather than left hanging. */ +const CHECK_DEADLINE_MS = 30_000 + +async function check(name: string, run: () => Promise) { + const started = performance.now() + let deadline: ReturnType | undefined + let timedOut = false + try { + await Promise.race([ + run(), + new Promise((_, reject) => { + deadline = setTimeout(() => { + timedOut = true + reject(new Error(`Did not settle within ${CHECK_DEADLINE_MS}ms`)) + }, CHECK_DEADLINE_MS) + }), + ]) + checks.push({ name, status: 'passed', durationMs: Math.round(performance.now() - started) }) + logger.info(`PASS ${name}`) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: getErrorMessage(error), + }) + logger.error(`FAIL ${name}`, { error: getErrorMessage(error) }) + process.exitCode = 1 + // A check that never settled may still be running against the shared hosts and pool, so + // later checks would not be independent of it. + if (timedOut) throw new Error(`Aborting: "${name}" did not settle`) + } finally { + clearTimeout(deadline) + } +} + +async function run() { + const host = await startHost(8) + const probeHost = await startHost(1) + + process.env.IVM_REMOTE_WORKER_URL = `ws://127.0.0.1:${host.port}/v1/worker` + process.env.IVM_REMOTE_WORKER_TOKEN = host.token + process.env.IVM_MAX_STDOUT_CHARS = String(STDOUT_LIMIT_CHARS) + process.env.IVM_QUEUE_TIMEOUT_MS = '5000' + process.env.IVM_POOL_SIZE = '2' + const { executeInIsolatedVM } = await import('@/lib/execution/isolated-vm') + + const execute = (code: string, extra: { timeoutMs?: number; signal?: AbortSignal } = {}) => + executeInIsolatedVM( + { + code, + params: {}, + envVars: {}, + contextVariables: {}, + timeoutMs: extra.timeoutMs ?? 10000, + requestId: `e2e-${generateRandomHex(4)}`, + ownerKey: 'e2e-owner', + }, + { signal: extra.signal } + ) + + await check('executes code on the remote host', async () => { + const result = await execute('return 6 * 7') + assert.equal(result.error, undefined, result.error?.message) + assert.equal(result.result, 42) + }) + + await check('runs the worker as a child of the host, never of the app', async () => { + assert(workerPidsOf(host.process.pid ?? -1).length > 0, 'Expected a worker under the host') + assert.deepEqual(workerPidsOf(process.pid), [], 'The app spawned a local worker') + }) + + await check("enforces the app's limits on the remote worker", async () => { + const result = await execute("console.log('y'.repeat(5000)); return 1") + assert.equal(result.result, 1, result.error?.message) + assert(result.stdout.endsWith('[stdout truncated]\n'), 'stdout was not truncated') + assert( + result.stdout.length <= STDOUT_LIMIT_CHARS + '\n[stdout truncated]\n'.length, + `stdout was ${result.stdout.length} chars; the app limit is ${STDOUT_LIMIT_CHARS}` + ) + }) + + await check("gives the worker neither the host token nor the host's environment", async () => { + const hostWorkers = workerPidsOf(host.process.pid ?? -1) + assert(hostWorkers.length > 0, 'Expected a worker process under the host') + for (const pid of hostWorkers) { + const environment = environmentOf(pid) + assert(!environment.includes(host.token), 'Worker environment contains the host token') + assert(!environment.includes('IVM_HOST_TOKEN'), 'Worker environment names the host token') + assert( + !environment.includes(HOST_ONLY_VALUE), + "Worker environment contains the host's variables" + ) + } + }) + + await check('brokers fetch through the app, the only side with a network client', async () => { + const marker = generateRandomHex(8) + const server = createHttpServer((_req, res) => res.end(marker)) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + assert(address && typeof address === 'object') + try { + const result = await execute( + `const r = await fetch('http://127.0.0.1:${address.port}/'); return await r.text()` + ) + assert.equal(result.result, marker, result.error?.message) + } finally { + server.close() + } + }) + + await check('carries a multi-megabyte result intact', async () => { + const result = await execute("return 'x'.repeat(5 * 1024 * 1024)") + assert.equal(result.error, undefined, result.error?.message) + assert.equal(typeof result.result, 'string') + assert.equal((result.result as string).length, 5 * 1024 * 1024) + }) + + await check('enforces the execution timeout', async () => { + const result = await execute('while (true) {}', { timeoutMs: 500 }) + assert.equal(result.error?.name, 'TimeoutError', result.error?.message) + }) + + await check('cancels an execution and keeps the worker usable', async () => { + const controller = new AbortController() + const pending = execute('while (true) {}', { timeoutMs: 30000, signal: controller.signal }) + await sleep(300) + controller.abort() + const cancelled = await pending + assert.equal(cancelled.termination, 'cancelled') + const next = await execute('return "still alive"') + assert.equal(next.result, 'still alive', next.error?.message) + }) + + await check('rejects a connection with the wrong token', async () => { + const ws = await openSocket(probeHost, authFrame(generateRandomHex(32))) + assert.equal(await closeCode(ws), 4401) + }) + + await check('rejects a connection whose first frame is not authentication', async () => { + const ws = await openSocket(probeHost, '{"type":"execute","executionId":1}') + assert.equal(await closeCode(ws), 4401) + }) + + await check('rejects an unparseable request target and keeps serving', async () => { + assert.equal(await upgradeStatus(probeHost, { path: '//[' }), 400) + const health = await fetch(`http://127.0.0.1:${probeHost.port}/health`) + assert.equal(health.status, 200) + }) + + await check('rejects paths other than the worker endpoint', async () => { + assert.equal(await upgradeStatus(probeHost, { path: '/v0/worker' }), 404) + }) + + await check('closes a connection that sends a frame outside the worker protocol', async () => { + const ws = await openWorker(probeHost) + const closed = closeCode(ws) + ws.send('{"type":"spawn"}') + assert.equal(await closed, 1007) + }) + + await check('refuses connections beyond the worker capacity', async () => { + await waitForNoWorkers(probeHost) + const first = await openWorker(probeHost) + assert.equal(await upgradeStatus(probeHost), 503) + first.close() + }) + + await check('reports a worker crash as a system error, not a hang', async () => { + const pending = execute('while (true) {}', { timeoutMs: 30000 }) + await sleep(300) + for (const pid of workerPidsOf(host.process.pid ?? -1)) process.kill(pid, 'SIGKILL') + const result = await pending + assert.equal(result.error?.isSystemError, true, JSON.stringify(result.error)) + }) + + await check('finishes in-flight work while draining, then exits', async () => { + const pending = execute( + 'const end = Date.now() + 1500; while (Date.now() < end) {} return "drained"', + { timeoutMs: 10000 } + ) + await sleep(300) + host.process.kill('SIGTERM') + await sleep(100) + const readiness = await fetch(`http://127.0.0.1:${host.port}/health`) + assert.equal(readiness.status, 503, 'Readiness must fail while draining') + const liveness = await fetch(`http://127.0.0.1:${host.port}/livez`) + assert.equal(liveness.status, 200, 'Liveness must hold while draining') + const result = await pending + assert.equal(result.result, 'drained', result.error?.message) + const code = await Promise.race([host.exited, sleep(10000).then(() => 'timeout' as const)]) + assert.notEqual(code, 'timeout', 'Host did not exit after draining') + }) + + await check('fails closed within the queue timeout once the host is gone', async () => { + assert.notEqual(host.process.exitCode, null, 'Precondition: the host has exited') + const started = Date.now() + const result = await execute('return 1') + assert.notEqual(result.result, 1, 'Execution ran without a host — it fell back locally') + assert(result.error, 'Expected an error with no host') + assert(Date.now() - started < 15000, 'Failure took longer than the queue timeout') + }) +} + +run() + .catch((error) => { + checks.push({ name: 'setup', status: 'failed', durationMs: 0, error: getErrorMessage(error) }) + logger.error('Remote isolated-vm E2E aborted', { error: getErrorMessage(error) }) + process.exitCode = 1 + }) + .finally(async () => { + for (const host of hosts) { + try { + if (host.process.pid) process.kill(-host.process.pid, 'SIGKILL') + } catch {} + } + const report = { + suite: 'isolated-vm-remote', + startedAt, + finishedAt: new Date().toISOString(), + status: process.exitCode ? 'failed' : 'passed', + checks, + } + if (process.env.IVM_REMOTE_E2E_REPORT_PATH) { + await writeFile( + process.env.IVM_REMOTE_E2E_REPORT_PATH, + `${JSON.stringify(report, null, 2)}\n` + ) + } + logger.info('Remote isolated-vm E2E finished', { + passed: checks.filter((c) => c.status === 'passed').length, + failed: checks.filter((c) => c.status === 'failed').length, + }) + process.exit(process.exitCode ?? 0) + }) diff --git a/bun.lock b/bun.lock index c5df653ec42..ee4e2181eea 100644 --- a/bun.lock +++ b/bun.lock @@ -390,6 +390,7 @@ "undici": "7.30.0", "unified": "11.0.5", "word-extractor": "1.0.4", + "ws": "8.21.3", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "y-protocols": "1.0.7", "yauzl": "3.4.0", diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 8eb5a79d29d..6a67f2332fd 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -213,6 +213,12 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules/@napi-rs ./node_modules # Copy the isolated-vm worker script COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-vm-worker.cjs ./apps/sim/lib/execution/isolated-vm-worker.cjs +# The same image runs as the dedicated worker host with +# `node apps/sim/lib/execution/isolated-vm-host.cjs`. The host requires `ws` +# directly, so copy the complete package rather than relying on the tracer. +COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-vm-host.cjs ./apps/sim/lib/execution/isolated-vm-host.cjs +COPY --from=deps --chown=nextjs:nodejs /app/node_modules/ws ./node_modules/ws + # Spreadsheet parse worker bundle (build:xlsx-worker, run by `bun run build`; # under the build task's declared `dist/**` output so a Turbo cache hit restores it). # Without it parsing falls back in-process, so a missing file fails the image diff --git a/knip.jsonc b/knip.jsonc index 6312a0654b5..ffbe9aaf7c2 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -30,6 +30,8 @@ "vitest.integration.setup.ts", // Spawned worker and assets bundled/loaded by filename. "lib/execution/isolated-vm-worker.cjs", + // The worker host runs as its own process (`node isolated-vm-host.cjs`). + "lib/execution/isolated-vm-host.cjs", // Oracle's client spawns this standalone Node worker by filename. "lib/internal/oracledb/oracle-worker.cjs", "lib/execution/sandbox/bundles/*.{ts,cjs}", From 772fcf73675376737220f21da5ac6586e3f559d5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 15:52:19 -0700 Subject: [PATCH 18/30] fix(egress): retry a gateway CONNECT once on connection failure and log each failed attempt (#9351) * fix(egress): retry a gateway CONNECT once on connection failure and log each failed attempt * test(egress): cover the 5xx CONNECT retry path --- .../lib/core/network/gateway.server.test.ts | 54 +++++++++++++++++++ apps/sim/lib/core/network/gateway.server.ts | 53 +++++++++++++++--- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/core/network/gateway.server.test.ts b/apps/sim/lib/core/network/gateway.server.test.ts index 478d86654ab..28ba87ab155 100644 --- a/apps/sim/lib/core/network/gateway.server.test.ts +++ b/apps/sim/lib/core/network/gateway.server.test.ts @@ -45,6 +45,8 @@ let originRequests = 0 let receivedHeaders: Record = {} let receivedTarget = '' const proxiedPorts = new Set() +/** Each queued entry makes one CONNECT fail: `reset` drops the connection, a number answers with that status. */ +const connectFailures: Array<'reset' | number> = [] const secureOrigin = httpsServer(async (req, res) => { res.setHeader('x-via-proxy', proxiedPorts.has(req.socket.remotePort ?? 0) ? 'yes' : 'no') originRequests++ @@ -70,6 +72,15 @@ proxy.on('connect', (req, socket, head) => { destination: req.url, sni: tlsSocket.servername, }) + const failure = connectFailures.shift() + if (failure === 'reset') { + socket.destroy() + return + } + if (failure !== undefined) { + socket.end(`HTTP/1.1 ${failure} Gateway Failure\r\nContent-Length: 0\r\n\r\n`) + return + } if (!req.headers['proxy-authorization']?.match(/^Bearer (alpha|bravo|a{48})$/)) { socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n') return @@ -150,6 +161,11 @@ afterAll(async () => { rmSync(certificateDirectory, { recursive: true, force: true }) }) const url = () => 'https://origin.invalid' +const gatewayFor = (organizationId: string): OutboundGateway => { + const gateway = state.gateways.get(organizationId) + if (!gateway) throw new Error(`No gateway for ${organizationId}`) + return gateway +} const options = { profile: 'selfHostedService' as const } describe('organization gateways over real TLS CONNECT sockets', () => { @@ -356,4 +372,42 @@ describe('organization gateways over real TLS CONNECT sockets', () => { await transport.dispatcher.destroy() } }) + + it.each(['reset', 503] as const)( + 'retries a CONNECT that fails with %s before the tunnel opens and completes the request', + async (failure) => { + connectFailures.push(failure) + const start = admissions.length + try { + const response = await runWithOutboundOrganization('org_a', () => + secureFetchWithPinnedIP(url(), '1.1.1.1', options) + ) + expect(await response.text()).toBe('tls reached') + expect(admissions.length).toBe(start + 2) + } finally { + connectFailures.length = 0 + } + } + ) + + it('does not retry a rejected proxy credential', async () => { + const start = admissions.length + await expect( + openGatewayTunnel({ ...gatewayFor('org_a'), token: 'denied' }, '1.1.1.1', 443) + ).rejects.toMatchObject({ code: 'GATEWAY_UNAVAILABLE' }) + expect(admissions.length).toBe(start + 1) + }) + + it('gives up after two failed CONNECT attempts', async () => { + connectFailures.push('reset', 503) + const start = admissions.length + try { + await expect(openGatewayTunnel(gatewayFor('org_a'), '1.1.1.1', 443)).rejects.toMatchObject({ + code: 'GATEWAY_UNAVAILABLE', + }) + expect(admissions.length).toBe(start + 2) + } finally { + connectFailures.length = 0 + } + }) }) diff --git a/apps/sim/lib/core/network/gateway.server.ts b/apps/sim/lib/core/network/gateway.server.ts index 190f4ce8730..cbe81a57e55 100644 --- a/apps/sim/lib/core/network/gateway.server.ts +++ b/apps/sim/lib/core/network/gateway.server.ts @@ -1,6 +1,8 @@ import { isIP, Socket } from 'node:net' import { checkServerIdentity, connect as connectTls } from 'node:tls' +import { createLogger } from '@sim/logger' import { evaluateAddress, STRICT_EGRESS_POLICY } from '@sim/security/egress' +import { toError } from '@sim/utils/errors' import { Agent, Client } from 'undici/index.js' import type { OutboundGateway } from '@/lib/core/network/config.server' import { OutboundRoutingError } from '@/lib/core/network/routing' @@ -8,6 +10,9 @@ import type { EgressProfile } from '@/lib/core/security/egress/profiles' import { checkResolvedEgress, validateEgressUrl } from '@/lib/core/security/egress/validate' const CONNECT_TIMEOUT_MS = 10_000 +const MAX_CONNECT_ATTEMPTS = 2 + +const logger = createLogger('OutboundGateway') /** The proxy CA never changes trust for the upstream service. */ export async function secureOutboundTunnel( @@ -64,7 +69,36 @@ export async function openGatewayTunnel( ) { throw new OutboundRoutingError('UNSUPPORTED_TRANSPORT') } - const authority = `${address}:443` + for (let attempt = 1; ; attempt++) { + const result = await attemptGatewayConnect(gateway, `${address}:443`) + if (result.socket) return result.socket + logger.warn('Gateway CONNECT failed', { + gatewayId: gateway.id, + organizationId: gateway.organizationId, + attempt, + statusCode: result.statusCode, + errorName: result.error?.name, + errorCode: getErrorCode(result.error), + }) + if (!result.retryable || attempt >= MAX_CONNECT_ATTEMPTS) { + throw new OutboundRoutingError('GATEWAY_UNAVAILABLE') + } + } +} + +type GatewayConnectResult = + | { socket: Socket; statusCode?: never; error?: never; retryable?: never } + | { socket?: never; statusCode?: number; error?: Error; retryable: boolean } + +/** + * A failure before the proxy answers 200 has sent no upstream bytes, so it is safe to retry. + * Credential or admission rejections (4xx) are not transient and never retry. + */ +async function attemptGatewayConnect( + gateway: OutboundGateway, + authority: string +): Promise { + /** A fresh client per attempt opens a new connection, which the NLB can route to another task. */ const client = new Client(gateway.url, { allowH2: false, maxHeaderSize: 16_384, @@ -77,19 +111,22 @@ export async function openGatewayTunnel( signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS), headers: { host: authority, 'proxy-authorization': `Bearer ${gateway.token}` }, }) - if (statusCode !== 200 || !(socket instanceof Socket)) { - socket.destroy() - throw new OutboundRoutingError('GATEWAY_UNAVAILABLE') - } - return socket - } catch { - throw new OutboundRoutingError('GATEWAY_UNAVAILABLE') + if (statusCode === 200 && socket instanceof Socket) return { socket } + socket.destroy() + return { statusCode, retryable: statusCode >= 500 } + } catch (error) { + return { error: toError(error), retryable: true } } finally { /** An upgraded socket is detached from the dispatcher and now belongs to its caller. */ await client.destroy() } } +function getErrorCode(error: Error | undefined): string | undefined { + const code = error && 'code' in error ? error.code : undefined + return typeof code === 'string' ? code : undefined +} + /** A dispatcher belongs to one organization and gateway, never a process-wide default. */ export function createGatewayDispatcher( gateway: OutboundGateway, From b9e6ffb5b42d694e4757ca8797de63259bc4a255 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 16:07:07 -0700 Subject: [PATCH 19/30] improvement(auth): encrypt stored integration tokens (#9346) * improvement(auth): encrypt stored integration tokens * fix(settings): read permission predicates from the db-free entry * improvement(security): detect sealed envelopes by their full shape * improvement(auth): bind sealed tokens to their column and guard the backfill key * improvement(auth): read the sealed context from the column definition * fix(audit): report the line of a raw sealed column name * improvement(audit): flag wildcard and identifier reads of sealed columns --- apps/sim/components/settings/navigation.ts | 2 +- apps/sim/instrumentation-node.ts | 3 + apps/sim/lib/core/config/env.ts | 1 + .../credential-revocation.integration.ts | 92 +++++++- apps/sim/lib/oauth/credential-service.test.ts | 5 + apps/sim/lib/oauth/credential-service.ts | 7 +- apps/sim/trigger.config.ts | 2 + bun.lock | 1 + docker/db.Dockerfile | 6 +- package.json | 1 + packages/db/package.json | 6 + packages/db/schema.ts | 5 +- .../0037_seal_account_tokens.integration.ts | 199 +++++++++++++++++ .../0037_seal_account_tokens.ts | 136 ++++++++++++ packages/db/script-migrations/types.ts | 6 +- packages/db/scripts/seal-account-tokens.ts | 57 +++++ packages/db/sealed-text.test.ts | 21 ++ packages/db/sealed-text.ts | 116 ++++++++++ packages/security/src/encryption.test.ts | 64 +++++- packages/security/src/encryption.ts | 95 +++++++- scripts/check-sealed-columns.test.ts | 79 +++++++ scripts/check-sealed-columns.ts | 202 ++++++++++++++++++ 22 files changed, 1094 insertions(+), 12 deletions(-) create mode 100644 packages/db/script-migrations/0037_seal_account_tokens.integration.ts create mode 100644 packages/db/script-migrations/0037_seal_account_tokens.ts create mode 100644 packages/db/scripts/seal-account-tokens.ts create mode 100644 packages/db/sealed-text.test.ts create mode 100644 packages/db/sealed-text.ts create mode 100644 scripts/check-sealed-columns.test.ts create mode 100644 scripts/check-sealed-columns.ts diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 0c51706e1e2..468c1a280cb 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -27,7 +27,7 @@ import { Users, Wrench, } from '@sim/emcn/icons' -import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' +import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/predicates' import { CodeIcon, McpIcon, SlackIcon } from '@/components/icons' import type { SettingsHeaderMeta } from '@/components/settings/settings-header' import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/workspaces' diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index 1cad219a68f..fa33c50b228 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -378,6 +378,9 @@ export async function register() { await initializeOpenTelemetry() + const { reportAccountTokenSealing } = await import('@sim/db/sealed-text') + reportAccountTokenSealing() + const shutdownPostHog = async () => { try { const { getPostHogClient } = await import('@/lib/posthog/server') diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index b0a10f0c6ee..c2b8c213a1d 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -144,6 +144,7 @@ export const env = createEnv({ TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS) + ENCRYPT_ACCOUNT_TOKENS: z.boolean().optional(), // Seal OAuth access/refresh tokens on write (read by @sim/db); takes effect only with a 64-hex ENCRYPTION_KEY. Enable only once every app, worker, and background-task deployment runs a release that reads sealed tokens INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication INTERNAL_JWT_SECRET: z.string().min(32).optional(), // Dedicated signing key for internal JWTs (falls back to INTERNAL_API_SECRET); separating limits blast radius if one leaks diff --git a/apps/sim/lib/oauth/credential-revocation.integration.ts b/apps/sim/lib/oauth/credential-revocation.integration.ts index 9ec7b2f4c51..4f88564c700 100644 --- a/apps/sim/lib/oauth/credential-revocation.integration.ts +++ b/apps/sim/lib/oauth/credential-revocation.integration.ts @@ -5,7 +5,7 @@ import { account, credential, user, workspace } from '@sim/db/schema' import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { createDeferred, type Deferred } from '@sim/testing/helpers/deferred' import { generateId } from '@sim/utils/id' -import { eq, inArray } from 'drizzle-orm' +import { eq, inArray, sql } from 'drizzle-orm' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { redisUrl } = await vi.hoisted(async () => { @@ -36,6 +36,7 @@ import { fanOutSlackTokenChain } from '@/lib/oauth/slack' import { completeOAuthCredentialToken } from '@/lib/oauth/token-resolution' const originalFetch = globalThis.fetch +const fixtureEncryptionKey = process.env.ENCRYPTION_KEY const userId = generateId() const workspaceId = generateId() const accountId = generateId() @@ -81,6 +82,17 @@ async function storedRow(rowId: string) { return row } +async function rawTokens() { + const [row] = await db.execute<{ access_token: string | null; refresh_token: string | null }>( + sql`SELECT access_token, refresh_token FROM account WHERE id = ${accountId}` + ) + return row +} + +function sealWrites(enabled: boolean) { + process.env.ENCRYPT_ACCOUNT_TOKENS = enabled ? 'true' : 'false' +} + async function resolveToken(rowId = accountId) { return refreshTokenIfNeeded('revocation-integration', await storedRow(rowId), rowId) } @@ -177,6 +189,8 @@ beforeEach(async () => { }) afterEach((context) => { + sealWrites(false) + process.env.ENCRYPTION_KEY = fixtureEncryptionKey pausedProvider?.release.resolve() checks.push({ name: context.task.name, @@ -453,3 +467,79 @@ describe('OAuth refresh-token revocation against PostgreSQL and Redis', () => { }) }) }) + +describe('OAuth refresh chains with sealed account tokens', () => { + async function sealStoredChain() { + sealWrites(true) + await db + .update(account) + .set({ accessToken: 'expired-access', refreshToken: 'issued-refresh' }) + .where(eq(account.id, accountId)) + } + + it('stores tokens sealed and reads them back as plaintext', async () => { + await sealStoredChain() + + const raw = await rawTokens() + expect(raw.access_token).toMatch(/^enc:v1:/) + expect(raw.refresh_token).toMatch(/^enc:v1:/) + expect(JSON.stringify(raw)).not.toContain('issued-refresh') + expect(await storedAccount()).toMatchObject({ + accessToken: 'expired-access', + refreshToken: 'issued-refresh', + }) + }) + + it('keeps writing readable tokens when sealing is requested without a usable key', async () => { + sealWrites(true) + process.env.ENCRYPTION_KEY = 'not-hex'.repeat(6) + providerAnswer = REFRESHED + + await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true }) + expect(await rawTokens()).toMatchObject({ access_token: 'fresh-access' }) + }) + + it('rotates a chain stored before sealing was enabled', async () => { + sealWrites(true) + providerAnswer = REFRESHED + + await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true }) + expect(await rawTokens()).toMatchObject({ + access_token: expect.stringMatching(/^enc:v1:/), + refresh_token: 'issued-refresh', + }) + expect((await storedAccount()).accessToken).toBe('fresh-access') + }) + + it('rotates a sealed chain on a process that does not seal writes', async () => { + await sealStoredChain() + sealWrites(false) + providerAnswer = REFRESHED + + await expect(resolveToken()).resolves.toEqual({ accessToken: 'fresh-access', refreshed: true }) + expect(await rawTokens()).toMatchObject({ + access_token: 'fresh-access', + refresh_token: expect.stringMatching(/^enc:v1:/), + }) + }) + + it('records a revocation against a sealed chain on a process that does not seal writes', async () => { + await sealStoredChain() + sealWrites(false) + + await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError) + await expect(getCredentialTerminalRefreshError(credentialId)).resolves.toEqual({ + errorCode: 'invalid_grant', + providerId: PROVIDER_ID, + }) + }) + + it('keeps a revocation recorded before the chain was sealed', async () => { + await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError) + await expireRedisFlags() + await sealStoredChain() + + await expect(resolveToken()).rejects.toBeInstanceOf(CredentialRevokedError) + expect(providerRequests).toBe(1) + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 4f54193f1ce..0553e56cce0 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -86,6 +86,11 @@ const serviceLogger = getMockLogger('OAuthCredentialService') const transportLogger = getMockLogger('GoogleServiceAccountTransport') const mocks = { ...hoisted, decryptSecret: encryptionMockFns.mockDecryptSecret } +beforeEach(() => { + // Schema mocks carry no table to seal against; credential-revocation.integration.ts covers sealing. + vi.stubEnv('ENCRYPTION_KEY', '') +}) + /** Every call the credential service and its token transport logged at the given levels. */ function loggedCalls(...levels: Array<'info' | 'warn' | 'error'>) { return levels.flatMap((level) => [ diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 2a7a79f0709..80ef8c8f9ad 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -1,6 +1,7 @@ import { createHmac, createSign } from 'crypto' import { db } from '@sim/db' import { account, credential } from '@sim/db/schema' +import { sealedTextEquals } from '@sim/db/sealed-text' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { and, desc, eq, isNotNull, sql } from 'drizzle-orm' @@ -973,7 +974,7 @@ async function recordRefreshRevocation( .where( and( chainRowsFilter(scope.accountId, scope.slackTeamId), - eq(account.refreshToken, rejectedRefreshToken), + sealedTextEquals(account.refreshToken, rejectedRefreshToken), // Raw sql params skip drizzle's column mapping, so the Date is serialized explicitly; // truncation keeps a version read back at millisecond precision comparable. A row that // vanished before it could be read has no version and matches nothing anyway. @@ -1366,7 +1367,9 @@ async function performCoalescedRefresh({ const rotated = await db .update(account) .set(updateData) - .where(and(eq(account.id, accountId), eq(account.refreshToken, refreshToken))) + .where( + and(eq(account.id, accountId), sealedTextEquals(account.refreshToken, refreshToken)) + ) .returning({ id: account.id }) if (rotated.length === 0) { const stored = await readStoredChain(accountId) diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index c6eca213fea..156c15712f7 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -133,6 +133,8 @@ export default defineConfig({ createLogger('TriggerInit').error('Could not distrust compromised CA roots', tlsTrust) } markInsideTriggerRun() + const { reportAccountTokenSealing } = await import('@sim/db/sealed-text') + reportAccountTokenSealing() const { warmRedisConnection } = await import('./lib/core/config/redis') await warmRedisConnection() }, diff --git a/bun.lock b/bun.lock index ee4e2181eea..264cbaac58d 100644 --- a/bun.lock +++ b/bun.lock @@ -501,6 +501,7 @@ "version": "0.1.0", "dependencies": { "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", "@sim/utils": "workspace:*", "drizzle-orm": "^0.45.4", "postgres": "^3.4.5", diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile index 30ae055ae0c..b9e59e50481 100644 --- a/docker/db.Dockerfile +++ b/docker/db.Dockerfile @@ -12,9 +12,10 @@ WORKDIR /app # Copy only package files needed for migrations (these change less frequently) COPY package.json bun.lock turbo.json ./ COPY patches ./patches -RUN mkdir -p packages/db packages/logger packages/tsconfig packages/utils +RUN mkdir -p packages/db packages/logger packages/security packages/tsconfig packages/utils COPY packages/db/package.json ./packages/db/package.json COPY packages/logger/package.json ./packages/logger/package.json +COPY packages/security/package.json ./packages/security/package.json COPY packages/tsconfig/package.json ./packages/tsconfig/package.json COPY packages/utils/package.json ./packages/utils/package.json @@ -52,6 +53,9 @@ COPY --chown=nextjs:nodejs packages/utils ./packages/utils # Copy logger package (needed by @sim/db's tx-tripwire at import time) COPY --chown=nextjs:nodejs packages/logger ./packages/logger +# Copy security package (the schema's sealed columns import its encryption) +COPY --chown=nextjs:nodejs packages/security ./packages/security + # Copy database package source code (changes most frequently - placed last) COPY --chown=nextjs:nodejs packages/db ./packages/db diff --git a/package.json b/package.json index a840e359384..78ea6bb3339 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "check:spec-example-ids": "bun run scripts/check-spec-example-ids.ts", "check:script-tests": "bun run scripts/check-script-test-coverage.ts", "check:test-patterns": "bun run scripts/check-test-patterns.ts", + "check:sealed-columns": "bun run scripts/check-sealed-columns.ts", "check:schema-mock": "bun run scripts/generate-schema-mock.ts --check", "check:audits": "bun run scripts/run-audits.ts", "check:skills": "bun run scripts/sync-skills.ts --check", diff --git a/packages/db/package.json b/packages/db/package.json index 7157481f85b..3a9be2e1358 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -25,6 +25,10 @@ "types": "./pool-profiles.ts", "default": "./pool-profiles.ts" }, + "./sealed-text": { + "types": "./sealed-text.ts", + "default": "./sealed-text.ts" + }, "./sso-primary-provider": { "types": "./sso-primary-provider.ts", "default": "./sso-primary-provider.ts" @@ -44,6 +48,7 @@ "db:migrate": "bun --env-file=.env run ./scripts/migrate.ts", "db:reconcile-fork-kb-file-ownership": "bun --env-file=.env run ./scripts/reconcile-fork-kb-file-ownership.ts", "db:reconcile-workspace-storage": "bun --env-file=.env run ./scripts/reconcile-workspace-storage.ts", + "db:seal-account-tokens": "bun --env-file=.env run ./scripts/seal-account-tokens.ts", "db:retire-indexed-search": "bun --no-env-file ./scripts/retire-indexed-search.ts", "db:studio": "bunx drizzle-kit studio --config=./drizzle.config.ts", "test": "vitest run", @@ -55,6 +60,7 @@ }, "dependencies": { "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", "@sim/utils": "workspace:*", "drizzle-orm": "^0.45.4", "postgres": "^3.4.5" diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 4781ebb9165..753c90e7993 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -28,6 +28,7 @@ import { vector, } from 'drizzle-orm/pg-core' import { DEFAULT_FREE_CREDITS, TAG_SLOTS } from './constants' +import { sealedText } from './sealed-text' /** * Drizzle push compares index options as JSON, while Postgres introspection @@ -176,8 +177,8 @@ export const account = pgTable( userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), - accessToken: text('access_token'), - refreshToken: text('refresh_token'), + accessToken: sealedText('account', 'access_token'), + refreshToken: sealedText('account', 'refresh_token'), idToken: text('id_token'), accessTokenExpiresAt: timestamp('access_token_expires_at'), refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), diff --git a/packages/db/script-migrations/0037_seal_account_tokens.integration.ts b/packages/db/script-migrations/0037_seal_account_tokens.integration.ts new file mode 100644 index 00000000000..0e3bb85de79 --- /dev/null +++ b/packages/db/script-migrations/0037_seal_account_tokens.integration.ts @@ -0,0 +1,199 @@ +import { sealAccountTokens } from '@sim/db/script-migrations/0037_seal_account_tokens' +import { openText } from '@sim/db/sealed-text' +import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { seal, sealingKeyId } from '@sim/security/encryption' +import { generateId } from '@sim/utils/id' +import postgres, { type Sql } from 'postgres' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +const databaseUrl = readTestDatabaseUrl() +const KEY_HEX = 'a'.repeat(64) +const KEY_ID = sealingKeyId(Buffer.from(KEY_HEX, 'hex')) +const ACCESS_CONTEXT = 'account.access_token' +const REFRESH_CONTEXT = 'account.refresh_token' + +interface StoredRow { + id: string + access_token: string | null + refresh_token: string | null + updated_at: string +} + +/** Copies the account table into an isolated disposable schema; no public rows are touched. */ +describe('sealing stored account tokens in PostgreSQL', () => { + let sql: Sql + let admin: Sql + const schemaName = `account_sealing_${generateId().replaceAll('-', '')}` + const previousKey = process.env.ENCRYPTION_KEY + + beforeAll(async () => { + process.env.ENCRYPTION_KEY = KEY_HEX + admin = postgres(databaseUrl, { max: 1, onnotice: () => undefined }) + await admin.unsafe(`CREATE SCHEMA "${schemaName}"`) + await admin.unsafe( + `CREATE TABLE "${schemaName}"."account" (LIKE public."account" INCLUDING DEFAULTS)` + ) + sql = postgres(databaseUrl, { + max: 2, + connection: { search_path: schemaName }, + onnotice: () => undefined, + }) + }) + + afterAll(async () => { + if (previousKey === undefined) Reflect.deleteProperty(process.env, 'ENCRYPTION_KEY') + else process.env.ENCRYPTION_KEY = previousKey + await sql?.end() + if (admin) { + await admin.unsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`) + await admin.end() + } + }) + + beforeEach(async () => { + await sql`TRUNCATE account` + }) + + async function insertAccount(accessToken: string | null, refreshToken: string | null) { + const id = generateId() + await sql`INSERT INTO account (id, account_id, provider_id, user_id, access_token, refresh_token, created_at, updated_at) + VALUES (${id}, ${`subject-${id}`}, 'google-drive', ${`user-${id}`}, ${accessToken}, ${refreshToken}, + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')` + return id + } + + async function storedRows() { + return sql` + SELECT id, access_token, refresh_token, updated_at::text FROM account ORDER BY id` + } + + it('seals every stored token across batches without moving the chain version', async () => { + await Promise.all( + Array.from({ length: 5 }, (_, index) => insertAccount(`access-${index}`, `refresh-${index}`)) + ) + const accessOnly = await insertAccount('access-only', null) + const before = await storedRows() + + expect( + await sealAccountTokens(sql, { + mode: 'seal', + expectedKeyId: KEY_ID, + batchSize: 2, + pauseMs: 0, + }) + ).toEqual({ + keyId: KEY_ID, + scanned: 6, + pending: 6, + updated: 6, + foreignKey: 0, + }) + + const after = await storedRows() + for (const row of after) { + const original = before.find((candidate) => candidate.id === row.id) + expect(row.updated_at).toBe(original?.updated_at) + expect(row.access_token).toMatch(/^enc:v1:/) + expect(openText(row.access_token ?? '', ACCESS_CONTEXT)).toBe(original?.access_token) + if (row.id === accessOnly) { + expect(row.refresh_token).toBeNull() + } else { + expect(row.refresh_token).toMatch(/^enc:v1:/) + expect(openText(row.refresh_token ?? '', REFRESH_CONTEXT)).toBe(original?.refresh_token) + } + } + }) + + it('finds nothing left to do on a rerun or a dry run after a completed pass', async () => { + await insertAccount('access', 'refresh') + await sealAccountTokens(sql, { mode: 'seal', expectedKeyId: KEY_ID, pauseMs: 0 }) + const sealed = await storedRows() + + expect( + await sealAccountTokens(sql, { mode: 'seal', expectedKeyId: KEY_ID, pauseMs: 0 }) + ).toMatchObject({ + pending: 0, + updated: 0, + }) + expect( + await sealAccountTokens(sql, { + mode: 'seal', + expectedKeyId: KEY_ID, + dryRun: true, + pauseMs: 0, + }) + ).toMatchObject({ + pending: 0, + }) + expect(await storedRows()).toEqual(sealed) + }) + + it('reports pending rows on a dry run without writing them', async () => { + await insertAccount('access', 'refresh') + const before = await storedRows() + + expect( + await sealAccountTokens(sql, { + mode: 'seal', + expectedKeyId: KEY_ID, + dryRun: true, + pauseMs: 0, + }) + ).toEqual({ + keyId: KEY_ID, + scanned: 1, + pending: 1, + updated: 0, + foreignKey: 0, + }) + expect(await storedRows()).toEqual(before) + }) + + it('restores the exact plaintext when unsealing for a rollback', async () => { + await insertAccount('ya29.access:with:colons', 'refresh') + await insertAccount(null, 'refresh-only') + const plaintext = await storedRows() + await sealAccountTokens(sql, { mode: 'seal', expectedKeyId: KEY_ID, pauseMs: 0 }) + + expect( + await sealAccountTokens(sql, { mode: 'unseal', expectedKeyId: KEY_ID, pauseMs: 0 }) + ).toMatchObject({ + updated: 2, + }) + expect(await storedRows()).toEqual(plaintext) + }) + + it('refuses to start under a key the fleet does not use, before touching any row', async () => { + await insertAccount('access', 'refresh') + const before = await storedRows() + + for (const dryRun of [false, true]) { + await expect( + sealAccountTokens(sql, { mode: 'seal', expectedKeyId: 'ffffffff', dryRun, pauseMs: 0 }) + ).rejects.toThrow(KEY_ID) + } + await expect(sealAccountTokens(sql, { mode: 'seal', pauseMs: 0 })).rejects.toThrow() + expect(await storedRows()).toEqual(before) + }) + + it('skips and reports rows sealed under another key instead of failing the pass', async () => { + const otherKey = Buffer.from('b'.repeat(64), 'hex') + const foreignAccess = seal('foreign-access', otherKey, ACCESS_CONTEXT) + const foreign = await insertAccount(foreignAccess, 'foreign-refresh') + await insertAccount('own-access', 'own-refresh') + await sealAccountTokens(sql, { mode: 'seal', expectedKeyId: KEY_ID, pauseMs: 0 }) + + expect( + await sealAccountTokens(sql, { mode: 'unseal', expectedKeyId: KEY_ID, pauseMs: 0 }) + ).toMatchObject({ scanned: 2, updated: 1, foreignKey: 1 }) + const rows = await storedRows() + expect(rows.find((row) => row.id === foreign)).toMatchObject({ + access_token: foreignAccess, + refresh_token: 'foreign-refresh', + }) + expect(rows.find((row) => row.id !== foreign)).toMatchObject({ + access_token: 'own-access', + refresh_token: 'own-refresh', + }) + }) +}) diff --git a/packages/db/script-migrations/0037_seal_account_tokens.ts b/packages/db/script-migrations/0037_seal_account_tokens.ts new file mode 100644 index 00000000000..6a4354bf8c7 --- /dev/null +++ b/packages/db/script-migrations/0037_seal_account_tokens.ts @@ -0,0 +1,136 @@ +import { account } from '@sim/db/schema' +import { openText, readEncryptionKey, sealedTextContext, sealText } from '@sim/db/sealed-text' +import { createLogger } from '@sim/logger' +import { sealedKeyId, sealingKeyId } from '@sim/security/encryption' +import { sleep } from '@sim/utils/helpers' +import type { Sql } from 'postgres' + +const logger = createLogger('AccountTokenSealing', { enabled: true, logLevel: 'INFO' }) + +interface AccountTokenSealingOptions { + mode: 'seal' | 'unseal' + /** + * The id the fleet logs at startup (`Account token sealing`). A pass refuses to start under any + * other `ENCRYPTION_KEY`, so it can never seal rows with a key the fleet cannot open. Optional + * only for a dry run. + */ + expectedKeyId?: string + /** Counts the rows that would change without writing any. */ + dryRun?: boolean + batchSize?: number + /** Pause between batches, which keeps the backfill off the write path's latency. */ + pauseMs?: number +} + +interface AccountTokenSealingResult { + keyId: string + scanned: number + pending: number + updated: number + /** Rows left untouched because a token is sealed under another key. */ + foreignKey: number +} + +interface StoredTokens { + id: string + access_token: string | null + refresh_token: string | null +} + +const ACCESS_TOKEN_CONTEXT = sealedTextContext(account.accessToken) +const REFRESH_TOKEN_CONTEXT = sealedTextContext(account.refreshToken) + +/** + * Rewrites `account.access_token` and `account.refresh_token` into the sealed form (`seal`) or back + * to plaintext (`unseal`, the rollback). Idempotent and resumable: a value already in the target + * form is skipped, so a rerun only finishes what an interrupted run left. Each row is rewritten only + * while it still holds the value this pass read, so a refresh that rotates the chain concurrently + * wins, and `updated_at` is left alone because the refresh path reads it as the chain version. Rows + * sealed under another key are skipped and counted rather than failing the pass. + */ +export async function sealAccountTokens( + sql: Sql, + { + mode, + expectedKeyId, + dryRun = false, + batchSize = 200, + pauseMs = 100, + }: AccountTokenSealingOptions +): Promise { + const keyId = sealingKeyId(readEncryptionKey()) + if ((!dryRun || expectedKeyId !== undefined) && expectedKeyId !== keyId) { + throw new Error( + `ENCRYPTION_KEY has key id ${keyId}, not the expected ${expectedKeyId ?? '(none)'}` + ) + } + const isForeign = (value: string | null) => { + const sealedUnder = value === null ? null : sealedKeyId(value) + return sealedUnder !== null && sealedUnder !== keyId + } + const convert = (value: string | null, context: string) => + value === null ? null : mode === 'seal' ? sealText(value, context) : openText(value, context) + const result: AccountTokenSealingResult = { + keyId, + scanned: 0, + pending: 0, + updated: 0, + foreignKey: 0, + } + let afterId = '' + + for (;;) { + const rows = await sql` + SELECT id, access_token, refresh_token FROM account + WHERE id > ${afterId} AND (access_token IS NOT NULL OR refresh_token IS NOT NULL) + ORDER BY id LIMIT ${batchSize} + ` + if (rows.length === 0) break + afterId = rows[rows.length - 1].id + result.scanned += rows.length + + const ownRows = rows.filter( + (row) => !isForeign(row.access_token) && !isForeign(row.refresh_token) + ) + result.foreignKey += rows.length - ownRows.length + const changes = ownRows + .map((row) => ({ + row, + accessToken: convert(row.access_token, ACCESS_TOKEN_CONTEXT), + refreshToken: convert(row.refresh_token, REFRESH_TOKEN_CONTEXT), + })) + .filter( + ({ row, accessToken, refreshToken }) => + accessToken !== row.access_token || refreshToken !== row.refresh_token + ) + result.pending += changes.length + + if (changes.length > 0 && !dryRun) { + const updated = await sql.begin(async (tx) => { + await tx`SELECT set_config('lock_timeout', '5s', true), + set_config('statement_timeout', '30s', true)` + return tx<{ id: string }[]>` + UPDATE account + SET access_token = change.access_token, refresh_token = change.refresh_token + FROM unnest( + ${changes.map((change) => change.row.id)}::text[], + ${changes.map((change) => change.row.access_token)}::text[], + ${changes.map((change) => change.row.refresh_token)}::text[], + ${changes.map((change) => change.accessToken)}::text[], + ${changes.map((change) => change.refreshToken)}::text[] + ) AS change(id, stored_access_token, stored_refresh_token, access_token, refresh_token) + WHERE account.id = change.id + AND account.access_token IS NOT DISTINCT FROM change.stored_access_token + AND account.refresh_token IS NOT DISTINCT FROM change.stored_refresh_token + RETURNING account.id + ` + }) + result.updated += updated.length + } + + logger.info('Account token batch processed', { mode, dryRun, afterId, ...result }) + if (pauseMs > 0) await sleep(pauseMs) + } + + return result +} diff --git a/packages/db/script-migrations/types.ts b/packages/db/script-migrations/types.ts index d7008144ce0..9228e7ebd97 100644 --- a/packages/db/script-migrations/types.ts +++ b/packages/db/script-migrations/types.ts @@ -11,9 +11,9 @@ import type { Sql } from 'postgres' * resolves; a crash mid-run means the whole migration re-runs from the top * on the next upgrade. Guard with cheap preconditions (`WHERE x IS NULL`, * `ON CONFLICT DO NOTHING`) so re-runs are near-free. - * - **db-level imports only** (`postgres`, `drizzle-orm`, `@sim/utils`). The - * migrations docker image ships `packages/db` + `utils` + `logger` and - * nothing from `apps/*`. + * - **db-level imports only** (`postgres`, `drizzle-orm`, `@sim/utils`, + * `@sim/security`). The migrations docker image ships `packages/db` + `utils` + * + `logger` + `security` and nothing from `apps/*`. * - **Runs at whatever schema HEAD the release ships** — scripts are not * interleaved with SQL migrations. A SQL migration must never drop or * repurpose a column that a registered script still reads; delete the diff --git a/packages/db/scripts/seal-account-tokens.ts b/packages/db/scripts/seal-account-tokens.ts new file mode 100644 index 00000000000..41c0970eb9c --- /dev/null +++ b/packages/db/scripts/seal-account-tokens.ts @@ -0,0 +1,57 @@ +import { parseArgs } from 'node:util' +import { sealAccountTokens } from '@sim/db/script-migrations/0037_seal_account_tokens' +import { createLogger } from '@sim/logger' +import postgres from 'postgres' + +const logger = createLogger('AccountTokenSealing', { enabled: true, logLevel: 'INFO' }) +const REQUIRED_ACK = 'sealed-readers-deployed' + +const { values } = parseArgs({ + options: { + unseal: { type: 'boolean', default: false }, + 'dry-run': { type: 'boolean', default: false }, + 'expect-key-id': { type: 'string' }, + }, +}) + +const url = process.env.MIGRATION_DATABASE_URL || process.env.DATABASE_URL +if (!url) throw new Error('Missing MIGRATION_DATABASE_URL or DATABASE_URL') +if ( + !values['dry-run'] && + !values.unseal && + process.env.ACCOUNT_TOKEN_SEALING_ACK !== REQUIRED_ACK +) { + throw new Error( + `Set ACCOUNT_TOKEN_SEALING_ACK=${REQUIRED_ACK} only once every app, worker, and background ` + + 'task deployment runs a release that reads sealed account tokens' + ) +} + +if (!values['dry-run'] && !values['expect-key-id']) { + throw new Error( + 'Pass --expect-key-id with the key id the fleet logs at startup (a dry run prints this key id)' + ) +} + +/** The pass reads and writes the unqualified `account` table, so a URL-supplied path cannot redirect it. */ +const databaseUrl = new URL(url) +databaseUrl.searchParams.set('search_path', 'public, pg_temp') + +const sql = postgres(databaseUrl.toString(), { + max: 1, + prepare: false, + connect_timeout: 10, + max_lifetime: null, + connection: { application_name: 'sim-account-token-sealing' }, +}) + +try { + const result = await sealAccountTokens(sql, { + mode: values.unseal ? 'unseal' : 'seal', + expectedKeyId: values['expect-key-id'], + dryRun: values['dry-run'], + }) + logger.info('Account token sealing finished', result) +} finally { + await sql.end() +} diff --git a/packages/db/sealed-text.test.ts b/packages/db/sealed-text.test.ts new file mode 100644 index 00000000000..0c99a2d0bb2 --- /dev/null +++ b/packages/db/sealed-text.test.ts @@ -0,0 +1,21 @@ +import { account } from '@sim/db/schema' +import { sealedTextEquals } from '@sim/db/sealed-text' +import { alias, PgDialect } from 'drizzle-orm/pg-core' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('sealedTextEquals', () => { + beforeEach(() => { + vi.stubEnv('ENCRYPTION_KEY', 'c'.repeat(64)) + vi.stubEnv('ENCRYPT_ACCOUNT_TOKENS', 'true') + }) + + it('matches the sealed form a write stores, through an alias of the table', () => { + const stored = account.refreshToken.mapToDriverValue('refresh-token') + const sibling = alias(account, 'sibling') + + const { params } = new PgDialect().sqlToQuery( + sealedTextEquals(sibling.refreshToken, 'refresh-token') + ) + expect(params).toEqual(['refresh-token', stored]) + }) +}) diff --git a/packages/db/sealed-text.ts b/packages/db/sealed-text.ts new file mode 100644 index 00000000000..abc6ec5abc2 --- /dev/null +++ b/packages/db/sealed-text.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { isSealed, seal, sealingKeyId, unseal } from '@sim/security/encryption' +import { toRecord } from '@sim/utils/object' +import { type SQL, sql } from 'drizzle-orm' +import { type AnyPgColumn, customType } from 'drizzle-orm/pg-core' + +const logger = createLogger('SealedText') +/** Operators read the key id from this report, so it logs at any configured level. */ +const reportLogger = createLogger('AccountTokenSealing', { enabled: true, logLevel: 'INFO' }) + +let reportedUnusableKey = false +let reportedSealing = false + +function readEncryptionKeyOrNull(): Buffer | null { + const hex = process.env.ENCRYPTION_KEY + return hex && /^[0-9a-f]{64}$/i.test(hex) ? Buffer.from(hex, 'hex') : null +} + +/** The deployment's `ENCRYPTION_KEY`; throws when it is not a 64-character hex string. */ +export function readEncryptionKey(): Buffer { + const key = readEncryptionKeyOrNull() + if (!key) throw new Error('ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)') + return key +} + +function isWriteSealingRequested(): boolean { + const flag = process.env.ENCRYPT_ACCOUNT_TOKENS?.toLowerCase() + return flag === 'true' || flag === '1' +} + +/** + * The key writes are sealed with, or null to write plaintext. Reads open sealed values regardless, + * so every process can read both forms before any process writes the sealed one. Sealing requested + * without a usable key writes plaintext, which every process can still read, and reports the + * misconfiguration once instead of failing every token write. + */ +function writeSealingKey(): Buffer | null { + if (!isWriteSealingRequested()) return null + const key = readEncryptionKeyOrNull() + if (!key && !reportedUnusableKey) { + reportedUnusableKey = true + logger.error( + 'ENCRYPT_ACCOUNT_TOKENS is set but ENCRYPTION_KEY is not a 64-character hex key; account tokens are written unsealed' + ) + } + return key +} + +/** Logs once per process whether writes seal account tokens, and the key id (never the key). */ +export function reportAccountTokenSealing(): void { + if (reportedSealing) return + reportedSealing = true + const writeKey = writeSealingKey() + const key = writeKey ?? readEncryptionKeyOrNull() + reportLogger.info(`Account token sealing: ${writeKey ? 'enabled' : 'disabled'}`, { + keyId: key ? sealingKeyId(key) : null, + }) +} + +/** + * The context a sealed-text column authenticates its values with (`account.access_token`), read + * from the column's own definition rather than rebuilt from its table, so an `alias()` of the + * table or a later rename never changes it. Drizzle keeps the field config on the column's + * `config`, which aliased columns share. + */ +export function sealedTextContext(column: AnyPgColumn): string { + const context = toRecord(toRecord(Reflect.get(column, 'config')).fieldConfig).context + if (typeof context !== 'string') + throw new Error(`Column ${column.name} is not a sealed-text column`) + return context +} + +/** Seals `plaintext` for `context` with `ENCRYPTION_KEY`; an already sealed value is returned as is. */ +export function sealText(plaintext: string, context: string): string { + return isSealed(plaintext) ? plaintext : seal(plaintext, readEncryptionKey(), context) +} + +/** The plaintext of a stored value, which is either sealed or written before sealing was enabled. */ +export function openText(stored: string, context: string): string { + return isSealed(stored) ? unseal(stored, readEncryptionKey(), context) : stored +} + +/** + * A `text` column whose values are sealed at rest (see `seal` in `@sim/security/encryption`) and + * bound to `.`, so a sealed value cannot be moved to another column. Drizzle reads + * return plaintext. Never compare the column with `eq`: a bound parameter is sealed on + * write-enabled processes and misses unsealed rows; use {@link sealedTextEquals} + * (`check:sealed-columns` enforces both). + */ +export function sealedText(table: string, column: string) { + const context = `${table}.${column}` + return customType<{ data: string; driverData: string; config: { context: string } }>({ + dataType() { + return 'text' + }, + toDriver(value) { + const key = isSealed(value) ? null : writeSealingKey() + return key ? seal(value, key, context) : value + }, + fromDriver(value) { + return openText(value, context) + }, + })(column, { context }) +} + +/** + * Matches rows whose sealed-text `column` holds `plaintext`, stored sealed or not. Sealing is + * deterministic, so equality stays a single atomic predicate. Without a usable key nothing can have + * been sealed, so only the plaintext form is compared. + */ +export function sealedTextEquals(column: AnyPgColumn, plaintext: string): SQL { + const key = readEncryptionKeyOrNull() + return key && !isSealed(plaintext) + ? sql`${column} IN (${plaintext}, ${seal(plaintext, key, sealedTextContext(column))})` + : sql`${column} = ${plaintext}` +} diff --git a/packages/security/src/encryption.test.ts b/packages/security/src/encryption.test.ts index cce33142a40..43f7da7fa0b 100644 --- a/packages/security/src/encryption.test.ts +++ b/packages/security/src/encryption.test.ts @@ -1,8 +1,9 @@ import { randomBytes } from 'node:crypto' import { describe, expect, it } from 'vitest' -import { decrypt, encrypt } from './encryption' +import { decrypt, encrypt, isSealed, seal, sealedKeyId, sealingKeyId, unseal } from './encryption' const KEY = Buffer.from('0'.repeat(64), 'hex') +const CONTEXT = 'account.refresh_token' describe('encrypt', () => { it('produces distinct ciphertexts for the same input', async () => { @@ -40,3 +41,64 @@ describe('decrypt', () => { await expect(decrypt(encrypted, otherKey)).rejects.toThrow() }) }) + +describe('seal', () => { + it('seals equal plaintexts to equal envelopes so stored values match by SQL equality', () => { + expect(seal('refresh-token', KEY, CONTEXT)).toBe(seal('refresh-token', KEY, CONTEXT)) + expect(seal('refresh-token', KEY, CONTEXT)).not.toBe(seal('refresh-tokem', KEY, CONTEXT)) + }) + + it('never leaves the plaintext readable in the envelope', () => { + const plaintext = 'xoxe-1-plaintext-token' + const sealed = seal(plaintext, KEY, CONTEXT) + expect(isSealed(sealed)).toBe(true) + expect(sealed).not.toContain(plaintext) + expect(sealed).not.toContain(Buffer.from(plaintext).toString('hex')) + }) +}) + +describe('unseal', () => { + it.each(['', 'ya29.a0:with:colons', 'unicode ✓ token'])('round-trips %j', (plaintext) => { + expect(unseal(seal(plaintext, KEY, CONTEXT), KEY, CONTEXT)).toBe(plaintext) + }) + + it('treats a hex-only or colon-separated raw token as unsealed', () => { + expect(isSealed('0123456789abcdef0123456789abcdef')).toBe(false) + expect(isSealed('aa:bb:cc')).toBe(false) + expect(() => unseal('aa:bb:cc', KEY, CONTEXT)).toThrow() + }) + + it('rejects an envelope moved to a different context', () => { + expect(() => unseal(seal('token', KEY, CONTEXT), KEY, 'account.access_token')).toThrow() + }) + + it('names the key an envelope was sealed under', () => { + expect(sealedKeyId(seal('token', KEY, CONTEXT))).toBe(sealingKeyId(KEY)) + expect(sealedKeyId('raw-token')).toBeNull() + }) + + it('rejects an envelope sealed under a different key', () => { + expect(() => unseal(seal('token', KEY, CONTEXT), randomBytes(32), CONTEXT)).toThrow( + /different key/ + ) + }) + + it('rejects a tampered ciphertext or auth tag', () => { + const parts = seal('token-value', KEY, CONTEXT).split(':') + const flip = (hex: string) => `${hex.slice(0, -1)}${hex.endsWith('0') ? '1' : '0'}` + const ciphertextIndex = parts.length - 2 + const tagIndex = parts.length - 1 + for (const index of [ciphertextIndex, tagIndex]) { + const tampered = [...parts] + tampered[index] = flip(tampered[index]) + expect(() => unseal(tampered.join(':'), KEY, CONTEXT)).toThrow() + } + }) + + it('treats a raw token that only starts like an envelope as unsealed', () => { + for (const value of ['enc:v1:raw-provider-token', `${seal('token', KEY, CONTEXT)}:extra`]) { + expect(isSealed(value)).toBe(false) + expect(() => unseal(value, KEY, CONTEXT)).toThrow(/not a sealed envelope/) + } + }) +}) diff --git a/packages/security/src/encryption.ts b/packages/security/src/encryption.ts index c1bbf078e38..31dfec3efb6 100644 --- a/packages/security/src/encryption.ts +++ b/packages/security/src/encryption.ts @@ -1,4 +1,11 @@ -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' +import { + createCipheriv, + createDecipheriv, + createHmac, + hkdfSync, + randomBytes, + timingSafeEqual, +} from 'node:crypto' /** * AES-256-GCM encryption primitive. Produces a self-contained string in the @@ -66,3 +73,89 @@ function assertKey(key: Buffer): void { throw new Error('Encryption key must be 32 bytes (256 bits)') } } + +const SEALED_PREFIX = 'enc:v1:' +const SEALED_IV_BYTES = 12 +const SEALED_KEY_ID_BYTES = 4 +const SEALED_ENVELOPE = /^enc:v1:([0-9a-f]{8}):([0-9a-f]{24}):((?:[0-9a-f]{2})*):([0-9a-f]{32})$/ + +interface SealingKeys { + key: Buffer + keyId: string + cipherKey: Buffer + ivKey: Buffer +} + +let cachedSealingKeys: SealingKeys | undefined + +function deriveSealingKeys(key: Buffer): SealingKeys { + const cached = cachedSealingKeys + if (cached && cached.key.length === key.length && timingSafeEqual(cached.key, key)) return cached + assertKey(key) + const derive = (info: string, length: number) => + Buffer.from(hkdfSync('sha256', key, Buffer.alloc(0), `sim:sealed:v1:${info}`, length)) + cachedSealingKeys = { + key: Buffer.from(key), + keyId: derive('key-id', SEALED_KEY_ID_BYTES).toString('hex'), + cipherKey: derive('cipher', 32), + ivKey: derive('iv', 32), + } + return cachedSealingKeys +} + +/** The non-secret id that envelopes sealed under `key` carry; safe to log. */ +export function sealingKeyId(key: Buffer): string { + return deriveSealingKeys(key).keyId +} + +/** Whether `value` is an envelope produced by {@link seal}. */ +export function isSealed(value: string): boolean { + return SEALED_ENVELOPE.test(value) +} + +/** The id of the key `value` was sealed under, or null when it is not an envelope. */ +export function sealedKeyId(value: string): string | null { + return SEALED_ENVELOPE.exec(value)?.[1] ?? null +} + +/** + * Deterministic AES-256-GCM envelope, `enc:v1::::` (hex). The IV + * is an HMAC of `context` and the plaintext under a subkey derived from `key` (the SIV + * construction), so equal plaintexts seal to equal envelopes and a stored envelope can be matched + * with SQL equality. That reveals only whether two sealed values are equal: use it for high-entropy + * secrets such as OAuth tokens, never for low-entropy values. `context` is authenticated, so an + * envelope opens only under the context it was sealed for (e.g. the column that stores it). + */ +export function seal(plaintext: string, key: Buffer, context: string): string { + const { keyId, cipherKey, ivKey } = deriveSealingKeys(key) + const iv = createHmac('sha256', ivKey) + .update(`${context}\0`, 'utf8') + .update(plaintext, 'utf8') + .digest() + .subarray(0, SEALED_IV_BYTES) + const cipher = createCipheriv('aes-256-gcm', cipherKey, iv, { authTagLength: 16 }) + cipher.setAAD(Buffer.from(context, 'utf8')) + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + return `${SEALED_PREFIX}${keyId}:${iv.toString('hex')}:${ciphertext.toString('hex')}:${cipher.getAuthTag().toString('hex')}` +} + +/** + * Opens an envelope produced by {@link seal}. Throws when the value is not a well-formed envelope, + * was sealed under a different key or context, or fails GCM authentication. + */ +export function unseal(value: string, key: Buffer, context: string): string { + const match = SEALED_ENVELOPE.exec(value) + if (!match) throw new Error('Value is not a sealed envelope') + const [, keyId, ivHex, ciphertextHex, authTagHex] = match + const keys = deriveSealingKeys(key) + if (keyId !== keys.keyId) throw new Error('Sealed envelope was sealed under a different key') + const decipher = createDecipheriv('aes-256-gcm', keys.cipherKey, Buffer.from(ivHex, 'hex'), { + authTagLength: 16, + }) + decipher.setAAD(Buffer.from(context, 'utf8')) + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')) + return Buffer.concat([ + decipher.update(Buffer.from(ciphertextHex, 'hex')), + decipher.final(), + ]).toString('utf8') +} diff --git a/scripts/check-sealed-columns.test.ts b/scripts/check-sealed-columns.test.ts new file mode 100644 index 00000000000..6d9e92a2154 --- /dev/null +++ b/scripts/check-sealed-columns.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { findViolations, readSealedColumns } from './check-sealed-columns' + +const COLUMNS = readSealedColumns(` +export const account = pgTable( + 'account', + { + accessToken: sealedText('account', 'access_token'), + refreshToken: sealedText('account', 'refresh_token'), + } +) +`) + +function found(source: string, file = 'lib/file.ts') { + return findViolations(file, source, COLUMNS) + .map(({ rule, line }) => [rule, line]) + .sort(([, a], [, b]) => Number(a) - Number(b)) +} + +describe('check-sealed-columns', () => { + it('reads every sealed column with its physical name', () => { + expect(COLUMNS).toMatchObject([ + { reference: 'account.accessToken', columnName: 'access_token' }, + { reference: 'account.refreshToken', columnName: 'refresh_token' }, + ]) + }) + + it('flags comparison operators and raw interpolations but not the sealed helper', () => { + const source = [ + 'where(eq(account.refreshToken, token))', + 'where(inArray(schema.account.accessToken, tokens))', + `sql\`\${account.accessToken} IS NOT NULL\``, + 'where(like(account.accessToken, prefix))', + 'where(gte(account.refreshToken, token))', + 'where(sealedTextEquals(account.refreshToken, token))', + 'where(isNotNull(account.refreshToken))', + 'select({ accessToken: account.accessToken })', + ].join('\n') + expect(found(source)).toEqual([ + ['comparison', 1], + ['comparison', 2], + ['raw-interpolation', 3], + ['comparison', 4], + ['comparison', 5], + ]) + }) + + it('follows renamed imports and alias() bindings of the table', () => { + const source = [ + "import { account as accountTable, user } from '@sim/db/schema'", + 'where(eq(accountTable.refreshToken, token))', + "const sibling = alias(account, 'sibling')", + 'where(ne(sibling.accessToken, token))', + ].join('\n') + expect(found(source)).toEqual([ + ['comparison', 2], + ['comparison', 4], + ]) + }) + + it('flags physical column names in raw SQL outside tests and the backfill', () => { + const source = [ + `await db.execute(sql\`SELECT id FROM account WHERE id = \${id}\nAND access_token IS NULL\`)`, + "sql.raw('refresh_token IS NULL')", + `sql\`SELECT id FROM oauth_refresh_token WHERE grant = \${'refresh_token'}\``, + "sql`SELECT * FROM account WHERE user_id = 'u'`", + 'sql`SELECT a.* FROM account_activity a`', + "sql.identifier('refresh_token')", + ].join('\n') + expect(found(source)).toEqual([ + ['raw-column-name', 2], + ['raw-column-name', 3], + ['raw-column-name', 5], + ['raw-column-name', 7], + ]) + expect(found(source, 'lib/file.integration.ts')).toEqual([]) + expect(found(source, 'packages/db/script-migrations/0037_seal_account_tokens.ts')).toEqual([]) + }) +}) diff --git a/scripts/check-sealed-columns.ts b/scripts/check-sealed-columns.ts new file mode 100644 index 00000000000..ff2ef89c48f --- /dev/null +++ b/scripts/check-sealed-columns.ts @@ -0,0 +1,202 @@ +#!/usr/bin/env bun +/** + * Fails when a sealed-text column (`sealedText(...)` in `packages/db/schema.ts`) is compared or + * read in a way that bypasses its sealing. + * + * - `comparison`: a Drizzle comparison operator against the column. Its bound parameter goes + * through the column's mapper, so a process that seals writes compares the sealed form and + * misses rows stored before sealing (ordering and pattern operators are meaningless on + * ciphertext). `sealedTextEquals` from `@sim/db/sealed-text` matches both forms; `isNull` and + * `isNotNull` are unaffected and allowed. + * - `raw-interpolation`: `${table.column}` inside a raw `sql` template. A raw expression is read + * or compared as stored, so it sees ciphertext; select the column itself, or use the helper. + * - `raw-column-name`: the physical column name inside a `sql` template or `sql.raw(...)` body, a + * `sql.identifier(...)` of it, or a `* FROM
` wildcard over its table, all of which read + * the stored form the same way. Tests and the sealing backfill read it on purpose. + * + * Table aliases count as the table: renamed imports from `@sim/db` / `@sim/db/schema` and + * `alias(table, ...)` bindings. + * + * Swept clean when it landed, so there is no baseline: every hit fails. + * + * Run: `bun run check:sealed-columns` + */ +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { escapeRegExp } from '@sim/utils/string' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const SCHEMA = 'packages/db/schema.ts' +const SKIPPED_FILES = new Set([ + 'packages/db/sealed-text.ts', + 'scripts/check-sealed-columns.test.ts', +]) +/** Files that read the stored form on purpose, so only `raw-column-name` skips them. */ +const STORED_FORM_READERS = + /(?:\.test\.tsx?|\.integration\.ts|\/scripts\/test-[\w-]+-e2e\.ts)$|^packages\/db\/script-migrations\/0037_seal_account_tokens\.ts$/ +const SCAN_DIRS = ['apps', 'packages', 'scripts'] +const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage', 'dist', 'build', 'out']) +const SOURCE_FILE = /\.(?:ts|tsx|mts|cts)$/ + +const TABLE_DECLARATION = /export const (\w+) = pgTable\(\s*'(\w+)'/g +const SEALED_COLUMN = /(\w+):\s*sealedText\(\s*'(\w+)'\s*,\s*'(\w+)'\s*\)/g +const COMPARISON_OPERATORS = [ + 'eq', + 'ne', + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + 'notBetween', + 'like', + 'notLike', + 'ilike', + 'notIlike', + 'inArray', + 'notInArray', + 'arrayContains', + 'arrayContained', + 'arrayOverlaps', +] +const SQL_TEMPLATE = /\bsql(?:<[^>\n]*>)?`((?:[^`\\]|\\.)*)`/g +const SQL_RAW = /\bsql\.raw\(\s*(['"`])((?:(?!\1)[\s\S])*)\1/g +const SQL_IDENTIFIER = /\bsql\.identifier\(\s*(['"`])(\w+)\1/g + +type Rule = 'comparison' | 'raw-interpolation' | 'raw-column-name' + +interface Violation { + file: string + line: number + rule: Rule + text: string +} + +interface SealedColumn { + /** The table's export name (`account`). */ + table: string + /** The column's property (`accessToken`). */ + property: string + /** The physical table name (`account`). */ + tableName: string + /** The physical column name (`access_token`). */ + columnName: string + /** How code references it, for reports (`account.accessToken`). */ + reference: string +} + +function lineOf(source: string, index: number): number { + return source.slice(0, index).split('\n').length +} + +/** Reads every sealed column from the schema. */ +export function readSealedColumns(schemaSource: string): SealedColumn[] { + const tables = [...schemaSource.matchAll(TABLE_DECLARATION)].map((match) => ({ + exportName: match[1], + tableName: match[2], + index: match.index ?? 0, + })) + const columns: SealedColumn[] = [] + for (const match of schemaSource.matchAll(SEALED_COLUMN)) { + const [, property, , columnName] = match + const table = tables.filter((candidate) => candidate.index < (match.index ?? 0)).at(-1) + if (!table) continue + columns.push({ + table: table.exportName, + tableName: table.tableName, + property, + columnName, + reference: `${table.exportName}.${property}`, + }) + } + return columns +} + +/** + * The local names a file binds `table` to: itself, any `import { table as x }` rename, and any + * `const x = alias(table, ...)`. + */ +function tableNames(source: string, table: string): string[] { + const escaped = escapeRegExp(table) + const rename = new RegExp(`\\b${escaped}\\s+as\\s+(\\w+)`, 'g') + const imports = + source.match(/import\s+(?:type\s+)?\{[^}]*\}\s*from\s*'@sim\/db(?:\/schema)?'/g) ?? [] + const renames = imports.flatMap((statement) => [...statement.matchAll(rename)].map((m) => m[1])) + const aliasBinding = new RegExp(`\\b(\\w+)\\s*=\\s*alias\\(\\s*(?:\\w+\\.)?${escaped}\\b`, 'g') + const aliases = [...source.matchAll(aliasBinding)].map((m) => m[1]) + return [table, ...renames, ...aliases] +} + +/** Finds comparisons and raw reads of `columns` in one source file. */ +export function findViolations(file: string, source: string, columns: SealedColumn[]): Violation[] { + const violations: Violation[] = [] + const report = (rule: Rule, index: number, text: string) => + violations.push({ file, line: lineOf(source, index), rule, text }) + const operators = COMPARISON_OPERATORS.join('|') + for (const { table, property } of columns) { + const names = tableNames(source, table).map(escapeRegExp).join('|') + const column = `(?:\\w+\\.)?(?:${names})\\.${escapeRegExp(property)}\\b` + const patterns: [Rule, RegExp][] = [ + ['comparison', new RegExp(`\\b(?:${operators})\\(\\s*${column}`, 'g')], + ['raw-interpolation', new RegExp(`\\$\\{\\s*${column}\\s*\\}`, 'g')], + ] + for (const [rule, pattern] of patterns) { + for (const match of source.matchAll(pattern)) report(rule, match.index ?? 0, match[0]) + } + } + if (STORED_FORM_READERS.test(file)) return violations + const columnNames = new Set(columns.map((column) => column.columnName)) + const names = new RegExp(`\\b(?:${[...columnNames].map(escapeRegExp).join('|')})\\b`) + const tables = [...new Set(columns.map((column) => column.tableName))].map(escapeRegExp).join('|') + const wildcard = new RegExp(`\\*\\s*from\\s+(?:"?public"?\\.)?"?(?:${tables})"?(?![\\w"])`, 'i') + for (const pattern of [SQL_TEMPLATE, SQL_RAW]) { + for (const match of source.matchAll(pattern)) { + const capture = match[match.length - 1] + const bodyOffset = (match.index ?? 0) + match[0].lastIndexOf(capture) + const body = capture.replace(/\$\{[^}]*\}/g, (value) => ' '.repeat(value.length)) + const hit = names.exec(body) ?? wildcard.exec(body) + if (hit) report('raw-column-name', bodyOffset + hit.index, hit[0]) + } + } + for (const match of source.matchAll(SQL_IDENTIFIER)) { + if (columnNames.has(match[2])) report('raw-column-name', match.index ?? 0, match[0]) + } + return violations +} + +function* sourceFiles(dir: string): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) yield* sourceFiles(path.join(dir, entry.name)) + } else if (SOURCE_FILE.test(entry.name) && !entry.name.endsWith('.d.ts')) { + yield path.join(dir, entry.name) + } + } +} + +if (import.meta.main) { + const columns = readSealedColumns(readFileSync(path.join(ROOT, SCHEMA), 'utf8')) + const violations: Violation[] = [] + if (columns.length === 0) { + console.error(`No sealedText columns found in ${SCHEMA}; update this audit with the schema.`) + process.exit(1) + } + for (const dir of SCAN_DIRS) { + for (const absolute of sourceFiles(path.join(ROOT, dir))) { + const file = path.relative(ROOT, absolute) + if (SKIPPED_FILES.has(file)) continue + violations.push(...findViolations(file, readFileSync(absolute, 'utf8'), columns)) + } + } + if (violations.length > 0) { + for (const violation of violations) { + console.error(`${violation.file}:${violation.line} [${violation.rule}] ${violation.text}`) + } + console.error( + `\n${violations.length} sealed-column violation(s). Compare with sealedTextEquals from @sim/db/sealed-text, and select the column instead of interpolating it into raw SQL.` + ) + process.exit(1) + } + console.log(`✓ sealed columns (${columns.map((column) => column.reference).join(', ')})`) +} From fe743ef64d15adbb6c5d4431af58fdfdc1f67e88 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 16:17:15 -0700 Subject: [PATCH 20/30] docs(library): update what-is-an-mcp-server (#9352) * docs(library): update what-is-an-mcp-server * Pi Babysit: address PR #9352 feedback --------- Co-authored-by: Sim Pi Agent --- .../library/what-is-an-mcp-server/index.mdx | 104 +++++++++++++++--- 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/apps/sim/content/library/what-is-an-mcp-server/index.mdx b/apps/sim/content/library/what-is-an-mcp-server/index.mdx index 86209fdd340..ad43ff539da 100644 --- a/apps/sim/content/library/what-is-an-mcp-server/index.mdx +++ b/apps/sim/content/library/what-is-an-mcp-server/index.mdx @@ -3,26 +3,26 @@ slug: what-is-an-mcp-server title: 'What Is an MCP Server?' description: 'Learn what an MCP server is, how Model Context Protocol tools, resources, and prompts work, and how Sim acts as both an MCP client and server.' date: 2026-07-24 -updated: 2026-10-10 +updated: 2026-10-23 authors: - andrew -readingTime: 12 +readingTime: 17 tags: [MCP, AI Agents, Model Context Protocol, Sim] ogImage: /library/what-is-an-mcp-server/cover.jpg draft: false faq: - q: "What does MCP server mean?" - a: "An MCP server is a program that gives AI applications standardized access to tools and data through the Model Context Protocol. It can also provide reusable prompts, as defined by the official MCP specification. With Sim, you can connect to external MCP servers and publish Sim workflows through our MCP server. Sim lets you add external capabilities to workflows or publish a complete workflow as one reusable tool." + a: "An MCP server is a program that gives AI applications standardized access to tools and data through the Model Context Protocol. It can also provide reusable prompts, as defined by the official MCP specification. With Sim, you can connect to external MCP servers and deploy Sim workflows as tools through a workflow MCP server. Sim lets you add external capabilities to workflows or deploy a complete workflow as one reusable tool." - q: "Where can I find the official Model Context Protocol documentation?" - a: "The official Model Context Protocol documentation is the primary source for the specification and implementation guides. At Sim, we follow this protocol when connecting to external MCP servers and publishing workflows as MCP tools. Use the documentation to verify compatibility and technical requirements before you configure a connection." + a: "The official Model Context Protocol documentation is the primary source for the specification and implementation guides. At Sim, we follow this protocol when connecting to external MCP servers and deploying workflows as MCP tools. Use the documentation to verify compatibility and technical requirements before you configure a connection." - q: "Is an MCP server a real server?" - a: "An MCP server is a software component that can run as a local process or a remote network service. At Sim, we act as an MCP client when connecting to these servers and as an MCP server when publishing workflows. Running an MCP server locally or remotely lets you choose a setup that can reach the required tools and data." + a: "An MCP server is a software component that can run as a local process or a remote network service. Sim acts as an MCP client when connecting to these servers, and a workflow MCP server can expose deployed Sim workflows as tools. Running an MCP server locally or remotely lets you choose a setup that can reach the required tools and data." - q: "MCP server vs. microservice: What is the difference?" - a: "An MCP server exposes discoverable tools, resources, or prompts to AI clients, while a microservice typically exposes application functions through an API. In Sim, a workflow can call one or more microservices and be published as a single MCP tool. Compatible AI clients can then use the workflow without integrating separately with every underlying API." + a: "An MCP server exposes discoverable tools, resources, or prompts to AI clients, while a microservice typically exposes application functions through an API. In Sim, a workflow can call one or more microservices and be deployed as a single MCP tool. Compatible AI clients can then use the workflow without integrating separately with every underlying API." - q: "Does MCP replace APIs?" a: "MCP is a standard interface for discovering and invoking capabilities that may rely on APIs, not a replacement for those APIs. A Sim workflow can combine multiple API calls and expose the result as an MCP tool. A Sim workflow can reuse existing APIs while giving compatible AI clients one callable interface." - q: "Can Sim act as both an MCP client and an MCP server?" - a: "An MCP client consumes server capabilities, while an MCP server exposes capabilities to compatible clients. At Sim, we support both roles by connecting workflows to external MCP servers and publishing workflows as MCP tools. Supporting both roles lets you consume external capabilities and distribute reusable workflows through the same protocol." + a: "An MCP client consumes server capabilities, while an MCP server exposes capabilities to compatible clients. Sim supports both patterns by connecting workflows to external MCP servers and deploying workflows as MCP tools through workflow MCP servers. Supporting both patterns lets you consume external capabilities and distribute reusable workflows through the same protocol." - q: "What is an MCP server?" a: "An MCP server is a program or service that exposes tools, resources, or reusable prompts to compatible AI applications through the Model Context Protocol." - q: "What is Model Context Protocol?" @@ -71,11 +71,25 @@ faq: a: "Sim and n8n should be evaluated against the workflow-building, deployment, security, and governance requirements described in How to Turn a Workflow Into a Reusable MCP Tool (Sim vs n8n)." - q: "Where can I learn about MCP security?" a: "MCP Security: A Practical Guide to Secure MCP Server Development explains authentication, permissions, validation, credential handling, and other controls for MCP deployments." + - q: "What is the difference between an MCP server and an API?" + a: "An MCP server exposes discoverable, model-oriented capabilities through the Model Context Protocol, while an API exposes application endpoints through an interface such as REST, GraphQL, or an SDK." + - q: "Do I need an MCP server to build an AI agent?" + a: "An AI agent does not require an MCP server because it can use native integrations or direct APIs, but an MCP server can make compatible tools and data sources easier to reuse across AI applications." + - q: "Are MCP servers safe?" + a: "MCP servers are safe only when their code, operator, permissions, inputs, outputs, credentials, and updates are treated as part of an explicit security boundary." + - q: "How do I choose an MCP server?" + a: "An MCP server should be chosen by evaluating maintainer trust, capability scope, authentication, deployment model, source availability, update practices, auditability, and the sensitivity of the systems it can access." + - q: "Can Sim connect to an MCP server?" + a: "Sim can connect to an external MCP server so a Sim workflow can use the server’s exposed capabilities as tools, subject to the server’s configuration and permissions." + - q: "Can Sim deploy a workflow as an MCP server?" + a: "Sim can deploy a workflow so compatible MCP clients can use that workflow as a reusable tool with structured inputs and outputs." + - q: "How do Sim and n8n compare for MCP workflows?" + a: "Sim and n8n both support MCP-related workflow patterns, but teams should compare how each product builds, deploys, governs, and secures the reusable capability required for their environment." --- An MCP server is a program or service that exposes tools, resources, or reusable prompts to AI applications through the Model Context Protocol. -The Model Context Protocol is an open protocol that standardizes how AI applications connect to external systems and context. +The Model Context Protocol is an open protocol that standardizes how AI applications connect to external systems and context. As of October 2026, the authoritative definitions and requirements are maintained in the [MCP architecture documentation](https://modelcontextprotocol.io/docs/learn/architecture) and [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/). ## TL;DR @@ -125,10 +139,14 @@ The Model Context Protocol separates the AI application that needs a capability | MCP tool | An MCP tool is an action that an AI application can request through an MCP server. | | MCP resource | An MCP resource is readable context that an MCP server makes available to a compatible application. | | MCP prompt | An MCP prompt is a reusable prompt template exposed by an MCP server. | +| Local transport | The stdio transport lets a client launch a server as a local subprocess and exchange messages through standard input and output. | +| Remote transport | Streamable HTTP lets a client communicate with a remote MCP server over HTTP. | +| Authentication | Authentication depends on the transport and deployment: HTTP servers can use MCP authorization flows, while stdio servers generally receive credentials through their process environment. | +| Security boundary | Every MCP connection requires explicit trust, least-privilege access, input validation, and auditability. | ## How an MCP server works -An MCP server exposes selected capabilities to an AI application through a structured connection. During initialization, the client and server negotiate a protocol version and declare the capability groups they support. The client can then list the tools, resources, or prompts available through that connection. The client and server exchange JSON-RPC messages through a supported transport, such as standard input and output for local processes or Streamable HTTP for remote connections. +An MCP server exposes selected capabilities to an AI application through structured requests. The October 2026 specification distinguishes two connection flows: the `2026-07-28` revision and later use a stateless model in which every request declares its protocol version and client capabilities in metadata, while revisions through `2025-11-25` negotiate the version and capabilities with an `initialize` handshake. Sim currently supports the handshake-based flow: its MCP client initializes connections, and its workflow MCP server handles `initialize` and negotiates an SDK-supported protocol version. The client can then list the tools, resources, or prompts available from the server. Client and server exchange JSON-RPC messages through a supported transport, such as standard input and output for local processes or Streamable HTTP for remote communication. The [July 2026 MCP changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog) documents the newer stateless request model and removal of protocol-level sessions, while the [MCP versioning specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning) defines modern, legacy, and dual-era compatibility. The [official MCP specification](https://modelcontextprotocol.io/specification/latest) groups server capabilities into tools, resources, and prompts. Each capability has a different purpose and uses its own discovery and request methods. A server can expose any subset of these capabilities. @@ -142,12 +160,12 @@ The MCP client controls the connection and routes each request to the server. Af A typical MCP interaction follows this sequence: -1. An MCP host starts or connects to an MCP client. -2. The MCP client connects to an MCP server and negotiates supported capabilities. -3. The MCP server describes the tools, resources, or prompts it offers. +1. An MCP host creates an MCP client for a server. +2. The client sends a request with the protocol version and its supported client capabilities. +3. The client asks the server to list the tools, resources, or prompts it offers. 4. The host decides when a capability should be shown, read, or invoked. -5. The MCP client sends the request to the server. -6. The MCP server interacts with the underlying system and returns a structured result. +5. The MCP client sends the permitted request to the server. +6. The server validates the request, interacts with the underlying system, and returns a structured result. 7. The host gives the result to the model, application, or user. The [official MCP architecture documentation](https://modelcontextprotocol.io/docs/learn/architecture) explains the host, client, and server roles in greater depth. @@ -158,6 +176,28 @@ An MCP host runs the user-facing AI experience, an MCP client manages a server c The host may be an AI assistant, coding environment, or agent workspace. The client handles protocol communication on the host's behalf. The server sits at the boundary of an external system and decides which operations or context to expose. An MCP server is not itself an AI model; it provides capabilities that a model-enabled host may choose to use. +## How do MCP clients, servers, transports, and authentication fit together? + +The host is the user-facing AI application, the client speaks MCP for one server connection, the server supplies capabilities, and the transport carries their messages. Authentication establishes identity and authorization limits what that identity may do. + +### What is an MCP client? + +An MCP client is the protocol component inside a host that establishes and maintains a connection to one MCP server. A host can run multiple clients at once, such as separate connections to Postgres, GitHub, and Google Drive servers. + +### What is the difference between an MCP host and an MCP client? + +The host manages the model, user experience, consent, security policy, and capability selection. For the handshake-based revisions that Sim currently supports, each client handles initialization, discovery, requests, responses, and notifications for its server connection. The [official MCP client documentation for the `2025-11-25` revision](https://modelcontextprotocol.io/docs/2025-11-25/learn/client-concepts) confirms that a host can coordinate multiple clients while each client communicates directly with one server. + +### Which transports do MCP servers use? + +The current specification defines [stdio and Streamable HTTP as its standard transports](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports). With stdio, the client launches the server as a local subprocess and exchanges messages through standard input and output. With Streamable HTTP, the server runs as a network service that authorized clients can reach over HTTP. + +A local child process can inherit environment variables, files, and operating-system permissions, so its launch configuration matters. A remote deployment adds network protection, authorization, rate limiting, and monitoring responsibilities. Older servers may use the earlier HTTP-and-SSE transport, so compatibility should be checked rather than assumed. + +### Does MCP replace REST APIs or SDKs? + +MCP does not replace REST APIs or SDKs. An MCP server often wraps those interfaces so AI applications can discover and invoke their capabilities consistently, while the underlying service still needs reliable authentication, validation, retries, and error handling. + ## MCP server examples: GitHub, filesystems, and databases MCP servers can wrap familiar systems, including GitHub and local data stores. Each server describes its available capabilities so an MCP client can discover and use them without service-specific client code. In Sim, you can connect these servers to workflows and use their tools and data in later steps. @@ -182,6 +222,8 @@ A database MCP server can help an assistant identify which products generated th Other MCP servers can expose support systems, business applications, internal services, or reusable workflows. A reusable-workflow server can turn a defined workflow into an AI-callable tool. +The implementation and security boundary vary by connected system. See the guides to using MCP with [Postgres](https://www.sim.ai/library/postgres-mcp-server-ai-agents), [Google Drive](https://www.sim.ai/library/google-drive-mcp-server-ai-agent-setup), [Slack](https://www.sim.ai/library/best-slack-mcp-agent-builders), and [GitHub](https://www.sim.ai/library/github-mcp-server-ai-agents) for system-specific setup and evaluation. + ## Is an MCP server the same as an API? An MCP server is not the same as an API, although an MCP server often calls one or more APIs behind the scenes. @@ -200,6 +242,14 @@ A direct API integration connects an application to one service-specific interfa | MCP server | An MCP client discovers and calls capabilities through a shared protocol. | The model can access the capabilities the server exposes. | The server operator maintains the service-specific implementation behind the MCP interface. | | Sim workflow as MCP tool | Sim exposes a workflow through an MCP server. | The model can call the complete workflow as a reusable tool. | You update the workflow in Sim without rebuilding the client integration. | +## How does MCP authentication and security work? + +MCP security depends on the host and server enforcing authentication, authorization, consent, validation, and least privilege around every exposed capability. Authentication proves an identity; authorization determines what that identity may do. Protocol compatibility alone does not make a server trustworthy. + +For remote HTTP servers, follow the current MCP authorization requirements. The specification's [authorization security considerations](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations) require clients to request tokens for the intended resource, servers to validate token audience, and implementations not to pass a client's MCP token through to an unrelated downstream service. Protect redirect flows and keep credentials out of prompts and tool output. + +For local stdio servers, control which executable can run and which environment variables, files, sockets, and operating-system permissions it receives. The MCP authorization specification says stdio implementations should retrieve credentials from the environment rather than use the HTTP authorization flow. + ## Are MCP servers secure? MCP servers are only as secure as their permissions, authentication, input validation, deployment, and underlying integrations make them. @@ -208,6 +258,20 @@ MCP standardizes communication; it does not make every server or tool safe by de A server description should never be treated as proof that a tool is trustworthy. Operators should review the server's code or provider, understand what data it can access, and test how the host handles tool calls and returned content. See [MCP Security: A Practical Guide to Secure MCP Server Development](https://www.sim.ai/library/mcp-security) for a focused security checklist. +### What are the main MCP security risks? + +MCP servers face prompt injection, malicious or changed tool descriptions, excessive permissions, credential leakage, unsafe inputs, unauthorized side effects, and compromised server code. Use this checklist before trusting a connection: + +- **Server provenance:** Identify the maintainer, distribution channel, release process, and dependency maintenance practices. +- **Tool definitions:** Review names, descriptions, parameters, and updates so a trusted tool cannot quietly change behavior. +- **Least privilege:** Limit every credential to the required records, repositories, channels, databases, and actions. +- **Input validation:** Treat model-generated arguments as untrusted and validate types, ranges, paths, query structure, and allowed actions. +- **Output handling:** Treat returned documents, issues, web pages, and database fields as untrusted content that may contain prompt injection. +- **Human approval:** Require confirmation before destructive, expensive, external, or high-impact actions. +- **Isolation:** Separate sensitive environments and avoid broad access to unrelated systems. +- **Monitoring:** Record tool selection, arguments, authorization decisions, results, errors, and configuration changes without recording secrets. +- **Revocation:** Make credentials, sessions, and server access quick to disable after a compromise. + ## How do you build an MCP server? An MCP server is built by defining a narrow set of capabilities, connecting them to an underlying system, and exposing them through an MCP implementation. @@ -227,11 +291,17 @@ A narrow server with explicit permissions is generally easier to test and govern ## Using Sim as an MCP client and MCP server -In Sim, you can connect a workflow to an external MCP server and call its tools. For example, your workflow can use a GitHub MCP server to read an issue or create a pull request, then use the returned data in later steps. +Sim supports two verified MCP patterns: workflows can use tools from connected external MCP servers, and deployed workflows can be exposed as tools through a workflow MCP server. These are separate configurations; Sim does not create a distinct hosted MCP endpoint for every application. + +### When should you connect an external MCP server in Sim? + +Connect an external MCP server when it already exposes the required system capabilities with an acceptable operator, authentication model, permission boundary, data policy, and update process. In Sim, a workflow can call that server's tools. For example, it can use a GitHub MCP server to read an issue or create a pull request, then use the returned data in later steps. + +### When should you deploy a Sim workflow as an MCP server? -You can also expose a Sim workflow as a callable MCP tool. External MCP clients can discover and run the tool with the expected inputs, then receive the workflow's output. One MCP tool can contain several workflow steps behind a single interface. For a broader workflow tutorial, learn [how to create an AI agent with Sim](https://www.sim.ai/library/how-to-create-an-ai-agent). +Use a workflow MCP server when another compatible application needs a controlled multi-step capability rather than direct access to every underlying integration. A deployed Sim workflow can become a callable MCP tool with structured inputs and outputs. External clients can discover and run that tool, while its models, service calls, business logic, and credentials remain behind the workflow's narrower interface. For a broader workflow tutorial, learn [how to create an AI agent with Sim](https://www.sim.ai/library/how-to-create-an-ai-agent). -Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. For practical workflow guidance, read [How to Turn a Workflow Into a Reusable MCP Tool (Sim vs n8n)](https://www.sim.ai/library/how-to-turn-a-workflow-into-a-reusable-mcp-tool). +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. For practical workflow guidance, read [How to Turn a Workflow Into a Reusable MCP Tool (Sim vs n8n)](https://www.sim.ai/library/how-to-turn-a-workflow-into-a-reusable-mcp-tool), or compare options in [Best AI Agent Builders with MCP Support](https://www.sim.ai/library/best-ai-agent-builders-with-mcp-support). ## How do Sim and n8n relate to MCP? From 0a6f8f9748fe5db44f4feed6c4bb4bb7d46aeb11 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 16:18:10 -0700 Subject: [PATCH 21/30] docs(library): update best-ai-automation-tools-2026 (#9353) * docs(library): update best-ai-automation-tools-2026 * Pi Babysit: address PR #9353 feedback --------- Co-authored-by: Sim Pi Agent --- .../best-ai-automation-tools-2026/index.mdx | 266 ++++++++++++++++-- 1 file changed, 245 insertions(+), 21 deletions(-) diff --git a/apps/sim/content/library/best-ai-automation-tools-2026/index.mdx b/apps/sim/content/library/best-ai-automation-tools-2026/index.mdx index ec793dcf21d..3f0e18b8aa2 100644 --- a/apps/sim/content/library/best-ai-automation-tools-2026/index.mdx +++ b/apps/sim/content/library/best-ai-automation-tools-2026/index.mdx @@ -1,13 +1,14 @@ --- slug: best-ai-automation-tools-2026 -title: 'Best AI Automation Tools in 2026' -description: 'Compare the best AI automation tools in 2026, including Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate, by deployment, licensing, AI depth, and billing model.' -seoDescription: 'Sim, n8n, Zapier, Make, Gumloop, and Power Automate compared on deployment, licensing, AI depth, and billing, with the best AI automation tool per use case.' +title: '7 Best Workflow Automation Tools in 2026: AI and Traditional Options' +seoTitle: '7 Best Workflow Automation Tools in 2026' +description: 'Compare seven workflow automation tools for AI workflows, app automation, self-hosting, governance, and enterprise integration in 2026.' +seoDescription: 'Compare seven workflow automation tools for AI workflows, app automation, self-hosting, governance, and enterprise integration in 2026.' date: 2026-08-01 -updated: 2026-10-09 +updated: 2026-10-23 authors: - andrew -readingTime: 13 +readingTime: 23 tags: [AI Automation, AI Agents, Workflow Automation, Comparison, Sim] ogImage: /library/best-ai-automation-tools-2026/cover.jpg draft: false @@ -92,13 +93,39 @@ faq: a: "AI automation tools such as Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate are best used to execute bounded tasks with explicit controls rather than as unsupervised replacements for accountable employees." - q: "How should a company evaluate AI automation tools?" a: "A company should evaluate Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate with the same representative workflow and measure reliability, build effort, oversight, debugging, deployment fit, and total usage cost." + - q: "What is the best workflow automation tool?" + a: "Sim's workflow builder is the best overall choice for AI-first work because it combines model choice, tool use, branching, human input, deployment options, and more than 270 integrations in one workspace. Sim is the open-source AI workspace; its core is open source under Apache 2.0, while apps/sim/ee uses the separate Sim Enterprise License. Zapier is better for simple SaaS triggers and actions, while n8n is better for technical teams prioritizing source-available self-hosting." + - q: "What is workflow automation software?" + a: "Workflow automation software is software that connects triggers, data, rules, applications, and actions so a repeatable business process can run with less manual coordination. Modern products may also use AI models for classification, extraction, generation, and tool selection." + - q: "What is the difference between workflow automation and AI automation?" + a: "Workflow automation follows defined triggers, rules, and actions, while AI automation adds models that interpret unstructured input or make bounded decisions. Sim supports both patterns by combining deterministic workflow controls with model and agent steps." + - q: "How do Sim and n8n compare?" + a: "Sim is the open-source AI workspace with an Apache 2.0 core, while n8n is a source-available workflow automation product under the Sustainable Use License. Sim is the stronger choice for AI-first workflows and flexible model deployment; n8n is a strong choice for technical teams building conventional integrations with self-hosting." + - q: "How do Sim and Gumloop compare?" + a: "Sim is the stronger choice for teams that need an Apache 2.0 core, self-hosting, local models, BYOK, and deployment flexibility, while Gumloop is a strong proprietary cloud option for visual AI automation. The better product depends on whether infrastructure control or managed-cloud convenience matters more." + - q: "Should I choose Zapier, Make, or n8n?" + a: "Zapier is best for straightforward SaaS automation, Make is best for visual multi-branch scenarios, and n8n is best for technical teams that want source-available self-hosting and deeper customization. The guide n8n vs Zapier vs Make: Which Automation Platform Is Best in 2026? provides the focused comparison." + - q: "Is an AI agent platform the same as workflow automation software?" + a: "The categories overlap. Workflow automation software primarily executes predefined steps, while an AI agent platform lets models choose tools and actions based on context. Some products, including Sim, support both structured workflows and tool-using agents." + - q: "Do I need BYOK support, or is hosted model access enough?" + a: "Hosted access works when the platform offers suitable models, predictable limits, and acceptable data handling. Bring your own key becomes useful when you need direct provider billing, specific model access, or greater control over credentials." + - q: "What is the difference between self-hosted and cloud-hosted governance?" + a: "Cloud hosting places infrastructure maintenance and security operations with the vendor. Self-hosting gives you more control over data location, networking, and model access, but you must manage updates, scaling, monitoring, and hardening. Enterprise controls such as SSO and audit logs may still require a paid license." + - q: "How do credit-based and task-based pricing compare at high volume?" + a: "Task-based plans usually charge for each successful action, so workflows with many actions consume more quota. Credit-based plans may assign different costs to AI calls, enrichment, and other nodes. You should price several representative workflows rather than compare headline monthly allowances." + - q: "Can I switch platforms later without rebuilding everything?" + a: "Most migrations require some rebuilding because platforms define triggers, branches, credentials, and data mappings differently. Standard APIs, webhooks, Model Context Protocol (MCP) tools that let compatible AI clients call connected tools, portable prompts, and external data stores can reduce the work. Before buying, ask whether you can export workflow definitions, logs, and stored data." --- ## TL;DR -Sim is the best overall AI automation tool in 2026 for teams that need model-driven decisions, multi-step workflows, human input, model flexibility, and deployment choice in one workspace. Sim's core uses the [Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), while `apps/sim/ee` uses the [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and requires an active Enterprise subscription for production use. +**Best overall: Sim.** Sim is the open-source AI workspace, and its workflow builder ranks first in 2026 for teams building AI-first workflows. Sim's core uses the [Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE), while `apps/sim/ee` uses the [separate Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) and requires an active Enterprise subscription for production use. -The right choice depends on whether the work is adaptive or deterministic. Sim is strongest when a model must interpret context and choose what happens next. [Zapier](https://zapier.com/pricing) and [Make](https://www.make.com/en/pricing) are stronger defaults for predictable app-to-app rules, [n8n](https://docs.n8n.io/choose-how-to-use-n8n) gives technical teams extensive workflow control and a self-hosting option, [Gumloop](https://docs.gumloop.com/getting-started/introduction) emphasizes accessible AI workflow creation, and [Microsoft Power Automate](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) fits organizations standardized on Microsoft 365 and Power Platform. +The right workflow automation software depends on whether the work needs AI reasoning, deterministic rules, human approval, self-hosting, or enterprise governance. Sim is strongest when a model must interpret context and choose what happens next. [Zapier](https://zapier.com/pricing) and [Make](https://www.make.com/en/pricing) are stronger defaults for predictable app-to-app rules, [n8n](https://docs.n8n.io/choose-how-to-use-n8n) gives technical teams extensive workflow control and a self-hosting option, [Gumloop](https://docs.gumloop.com/getting-started/introduction) emphasizes accessible AI workflow creation, [Microsoft Power Automate](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) fits Microsoft environments, and [Workato](https://www.workato.com/pricing) serves governed enterprise integration programs. + +## What are the best workflow automation tools in 2026? + +Sim ranks first among these seven workflow automation tools because its workflow builder combines AI models, agents, tools, branching, human input, and deployment choice. Gumloop is strongest for managed visual AI automation, n8n for source-available self-hosting, Zapier for straightforward SaaS triggers and actions, Make for complex visual scenarios, Microsoft Power Automate for Microsoft environments, and Workato for governed enterprise integration. ## What is the best AI automation tool in 2026? @@ -123,7 +150,7 @@ Traditional automation usually follows fixed rules: when an event occurs, perfor ## How were these AI automation tools compared? -Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate were compared by automation model, AI depth, deployment options, licensing, technical flexibility, integration approach, and billing unit. +Sim, Gumloop, n8n, Zapier, Make, Microsoft Power Automate, and Workato were compared by automation model, AI depth, deployment options, licensing, technical flexibility, integration approach, billing unit, and governance. The evaluation emphasizes criteria that materially change a buying decision: @@ -134,21 +161,23 @@ The evaluation emphasizes criteria that materially change a buying decision: 5. **Ease of use:** How quickly can a nontechnical buyer build and maintain an automation? 6. **Integration fit:** Does the platform prioritize broad SaaS connectors, API orchestration, enterprise systems, or AI tools? 7. **Billing unit:** Does usage depend on tasks, credits, workflow executions, or enterprise capacity? +8. **Governance and operations:** Does the product provide execution history, error handling, permissions, environment controls, and support for production maintenance? -The comparison uses first-party capabilities and common buyer requirements rather than unsupported numerical scores. Exact prices and plan limits are intentionally excluded because vendors change them frequently; the official pricing and licensing pages linked below were checked in October 2026 and should be reviewed again before purchase. +The comparison uses first-party capabilities and common buyer requirements rather than unsupported numerical scores. Exact prices and plan limits are intentionally excluded because vendors change them frequently; as of October 2026, the official pricing and licensing pages linked below support the descriptions here and should be reviewed again before purchase. ## How do the best AI automation tools compare? Sim offers the clearest combination of an AI-native visual builder, self-hosting, and an [Apache 2.0 core](https://github.com/simstudioai/sim/blob/main/LICENSE), while `apps/sim/ee` has [separate enterprise terms](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); each competitor leads a different buying category. -| Tool | Best for | Product type | Self-hosting | License model | Typical hosted billing unit | -|---|---|---|---|---|---| -| **Sim** | Open-source AI workflows and agents | Adaptive AI workflows and agents | [Yes](https://docs.sim.ai/platform/self-hosting) | Core under the [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE); `apps/sim/ee` under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | [Usage or plan allowance](https://www.sim.ai/pricing) | -| **n8n** | Technical workflow automation with AI steps | General workflow automation platform | [Yes](https://docs.n8n.io/choose-how-to-use-n8n) | [Sustainable Use License; source-available, not OSI-approved](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | [Workflow executions](https://n8n.io/pricing/) | -| **Zapier** | Fast SaaS app automation | Hosted automation platform with AI features | [No general self-hosted edition listed](https://zapier.com/pricing) | [Proprietary service](https://zapier.com/legal/website-terms-of-use) | [Task allowances and feature tiers](https://zapier.com/pricing) | -| **Make** | Visual multi-step automation and data mapping | Hosted visual automation platform with AI features | [No general self-hosted edition listed](https://www.make.com/en/pricing) | [Proprietary service](https://www.make.com/en/terms-and-conditions) | [Credits](https://www.make.com/en/pricing) | -| **Gumloop** | No-code, hosted AI automation | [AI-native hosted automation builder](https://docs.gumloop.com/getting-started/introduction) | [No general self-hosted edition listed](https://www.gumloop.com/pricing) | [Proprietary service](https://www.gumloop.com/tos) | [Credits based on model, tools, and runtime](https://docs.gumloop.com/core-concepts/credits); [plan tiers](https://www.gumloop.com/pricing) | -| **Microsoft Power Automate** | Microsoft 365, Dynamics, and enterprise process automation | Enterprise automation and robotic process automation platform | [Cloud service with on-premises connectivity options](https://learn.microsoft.com/en-us/power-automate/gateway-reference) | [Proprietary service](https://www.microsoft.com/licensing/terms/productoffering/MicrosoftPowerPlatform/all) | [Plan-dependent user, bot, process, or capacity licensing](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) | +| Rank | Tool | Best for | AI workflow support | Self-hosting | License model | Typical hosted billing unit | +|---:|---|---|---|---|---|---| +| 1 | **Sim** | AI-first workflows and agents | Native model, agent, tool, branching, and human-input steps | [Yes](https://docs.sim.ai/platform/self-hosting) | Core under the [Apache License 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE); `apps/sim/ee` under the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) | [Usage or plan allowance](https://www.sim.ai/pricing) | +| 2 | **Gumloop** | No-code, hosted visual AI automation | [AI-native hosted automation builder](https://docs.gumloop.com/getting-started/introduction) | [No general self-hosted edition listed](https://www.gumloop.com/pricing) | [Proprietary service](https://www.gumloop.com/tos) | [Credits based on model, tools, and runtime](https://docs.gumloop.com/core-concepts/credits); [plan tiers](https://www.gumloop.com/pricing) | +| 3 | **n8n** | Technical workflow automation with AI steps | AI nodes and agent-oriented workflow components | [Yes](https://docs.n8n.io/choose-how-to-use-n8n) | [Sustainable Use License; source-available, not OSI-approved](https://docs.n8n.io/privacy-and-security/sustainable-use-license) | [Workflow executions](https://n8n.io/pricing/) | +| 4 | **Zapier** | Fast SaaS app automation | AI features alongside trigger-and-action automation | [No general self-hosted edition listed](https://zapier.com/pricing) | [Proprietary service](https://zapier.com/legal/website-terms-of-use) | [Task allowances and feature tiers](https://zapier.com/pricing) | +| 5 | **Make** | Visual multi-step automation and data mapping | AI modules within scenario-based automation | [No general self-hosted edition listed](https://www.make.com/en/pricing) | [Proprietary service](https://www.make.com/en/terms-and-conditions) | [Credits](https://www.make.com/en/pricing) | +| 6 | **Microsoft Power Automate** | Microsoft 365, Dynamics, and enterprise process automation | AI Builder and assisted automation | [Cloud service with on-premises connectivity options](https://learn.microsoft.com/en-us/power-automate/gateway-reference) | [Proprietary service](https://www.microsoft.com/licensing/terms/productoffering/MicrosoftPowerPlatform/all) | [Plan-dependent user, bot, process, or capacity licensing](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) | +| 7 | **Workato** | Governed enterprise integration programs | AI capabilities within enterprise recipes and orchestration | [Cloud platform with an on-premises connectivity agent](https://docs.workato.com/on-prem/) | [Proprietary service](https://www.workato.com/legal/terms-of-service) | [Platform editions plus usage measured in credits](https://docs.workato.com/en/pricing/overview) | As of October 2026, these licensing, deployment, and billing-model descriptions match the vendors' official pages, but commercial terms can change. @@ -162,6 +191,109 @@ Sim is the only product in this comparison combining an [Apache 2.0 core license - **Make:** Make is a [proprietary hosted visual automation service](https://www.make.com/en/terms-and-conditions) with [no general self-hosted edition listed](https://www.make.com/en/pricing), and its current commercial model uses [credits](https://www.make.com/en/pricing). - **Gumloop:** Gumloop is a [proprietary hosted AI automation service](https://www.gumloop.com/tos) with [no general self-hosted edition listed](https://www.gumloop.com/pricing); usage is measured in [credits based on the model, tools, and runtime](https://docs.gumloop.com/core-concepts/credits). - **Microsoft Power Automate:** Microsoft Power Automate is a [proprietary enterprise automation platform](https://www.microsoft.com/licensing/terms/productoffering/MicrosoftPowerPlatform/all) with [on-premises connectivity](https://learn.microsoft.com/en-us/power-automate/gateway-reference) rather than a generally self-hosted platform, and [licensing varies by user, bot, process, and capacity scenario](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing). +- **Workato:** Workato is a [proprietary cloud platform](https://www.workato.com/legal/terms-of-service) with an [on-premises connectivity agent](https://docs.workato.com/on-prem/), and its current billing documentation describes [platform editions plus usage measured in credits](https://docs.workato.com/en/pricing/overview). + +## Which workflow automation tool is best for each use case? + +Sim is the best overall choice for AI-first workflows, and the visible profile order below follows the comparison table's ranking as of October 2026. + +### 1. Sim: best overall for AI-first workflows + +Sim is the open-source AI workspace, and its workflow builder is the strongest overall choice here when AI models must reason over data, call tools, branch, accept controlled human input, and deploy as a managed workflow. + +**Best for:** Teams building AI workflows that combine models, APIs, business applications, conditions, and human decisions. + +**Pricing:** As of October 2026, [Sim's cost documentation](https://docs.sim.ai/platform/costs) explains cloud plan access and model usage; workspace BYOK is available on every Sim Cloud plan, organization-level keys require Pro for Teams, Max for Teams, or Enterprise, and hosted models are billed at the applicable Sim rate. + +**Strengths:** Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Its workflow builder supports model calls, tool use, branching, API connections, and [more than 270 integrations](https://www.sim.ai/pricing). Self-hosted Sim can use [Ollama, vLLM, LM Studio, or LiteLLM](https://docs.sim.ai/platform/self-hosting) for local models. Human in the Loop pauses a run and resumes it with submitted form fields; a downstream Condition evaluates an approval or rejection field. + +**Limitations:** Sim is optimized for AI-first work. Teams that only need simple SaaS triggers and actions may find a conventional workflow automation product more familiar. + +**Sources:** [Sim](https://www.sim.ai), [Sim pricing](https://www.sim.ai/pricing), [Sim costs and BYOK](https://docs.sim.ai/platform/costs), [Sim self-hosting](https://docs.sim.ai/platform/self-hosting), [Sim core license](https://github.com/simstudioai/sim/blob/main/LICENSE), and [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). + +### 2. Gumloop: best for visual AI automation + +Gumloop is the strongest fit for buyers who want managed visual AI automation for document, web, and operations work. + +**Best for:** Operations teams assembling AI workflows visually without operating workflow infrastructure. + +**Pricing:** As of October 2026, [Gumloop's pricing page](https://www.gumloop.com/pricing) presents cloud plans with credit allowances, while its [credit documentation](https://docs.gumloop.com/core-concepts/credits) explains that consumption varies by model, tools, and run length. + +**Strengths:** Gumloop documents [tool-using, context-aware agents](https://docs.gumloop.com/core-concepts/agents), [model selection](https://docs.gumloop.com/core-concepts/ai_models), and [reusable subflows](https://docs.gumloop.com/core-concepts/subflows) for approachable data-heavy automation. + +**Limitations:** Gumloop's public pricing describes a managed cloud service rather than a generally distributed self-hosted platform, and credit consumption depends on workflow design. + +**Sources:** [Gumloop product](https://www.gumloop.com), [Gumloop pricing](https://www.gumloop.com/pricing), and [Gumloop documentation](https://docs.gumloop.com). + +### 3. n8n: best for source-available self-hosting + +n8n is the strongest fit here for technical teams that want source-available self-hosting and conventional workflow automation with AI components. + +**Best for:** Developers and automation teams that want to host the workflow engine and customize integrations or code steps. + +**Pricing:** As of October 2026, [n8n pricing](https://n8n.io/pricing/) is based primarily on monthly workflow executions and includes hosted and self-hosted plan options. + +**Strengths:** n8n combines a node editor, [code steps](https://docs.n8n.io/build/code-in-n8n/using-the-code-node), webhooks, API connectivity, [sub-workflows](https://docs.n8n.io/build/flow-logic/break-workflows-into-smaller-parts), and AI components. + +**Limitations:** n8n is not OSI-approved open-source software. Its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) is source-available and restricts some commercial uses, so buyers should review it rather than treating self-hosting as equivalent to Apache 2.0 software. + +**Sources:** [n8n product](https://n8n.io), [n8n pricing](https://n8n.io/pricing/), [n8n license](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), and [n8n self-hosting documentation](https://docs.n8n.io/hosting/). + +### 4. Zapier: best for straightforward SaaS automation + +Zapier is the strongest fit for teams that prioritize straightforward trigger-and-action automation across common cloud applications. + +**Best for:** Business users connecting SaaS applications for lead routing, notifications, record updates, and repeatable event-driven processes. + +**Pricing:** As of October 2026, [Zapier's pricing page](https://zapier.com/pricing) organizes plans around features and task allowances. + +**Strengths:** Zapier offers a documented [trigger-and-action model](https://help.zapier.com/hc/en-us/articles/8496309697421-What-is-a-Zap), packaged application connections, templates, and a short path to a basic automation. + +**Limitations:** Zapier is a proprietary hosted service, and task-based billing makes it important to model all billable actions in a complete workflow. + +**Sources:** [Zapier product](https://zapier.com), [Zapier pricing](https://zapier.com/pricing), and [Zapier task usage documentation](https://help.zapier.com/hc/en-us/articles/8496196837261-How-is-task-usage-measured-in-Zapier). + +### 5. Make: best for complex visual scenarios + +Make is the strongest fit for teams that want a visual representation of multi-branch app automation with detailed mapping and transformation controls. + +**Best for:** Operations and technical business teams building scenarios with routers, filters, iterators, aggregators, and field-level data mapping. + +**Pricing:** As of October 2026, [Make's pricing page](https://www.make.com/en/pricing) uses credits and states that each module action generally counts as one credit. + +**Strengths:** Make exposes workflow paths and transformations visually through [routers](https://help.make.com/router), [iterators](https://help.make.com/iterator), and [aggregators](https://help.make.com/aggregator). + +**Limitations:** Make is proprietary cloud software without a generally distributed self-hosted edition, and large scenarios require careful credit forecasting and error-handling design. + +**Sources:** [Make product](https://www.make.com), [Make pricing](https://www.make.com/en/pricing), and [Make credits documentation](https://help.make.com/credits). + +### 6. Microsoft Power Automate: best for Microsoft environments + +Microsoft Power Automate is the strongest fit for organizations that already govern work through Microsoft 365, Dynamics 365, Azure, and Power Platform. + +**Best for:** Microsoft-centric organizations combining cloud flows, desktop automation, approvals, and business data managed through Microsoft services. + +**Pricing:** As of October 2026, [Microsoft's pricing page](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) presents user, process, and hosted or unattended automation options. + +**Strengths:** Power Automate integrates closely with Microsoft applications and supports cloud workflows and robotic process automation for desktop tasks. + +**Limitations:** Licensing needs careful interpretation when a process spans premium connectors, multiple users, unattended bots, or hosted machines; the service is not distributed as a self-hosted workflow platform. + +**Sources:** [Microsoft Power Automate](https://www.microsoft.com/en-us/power-platform/products/power-automate), [Power Automate pricing](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing), and [Power Automate licensing guide](https://www.microsoft.com/licensing/docs/view/Power-Platform-Licensing-Guide). + +### 7. Workato: best for enterprise integration programs + +Workato is the strongest fit for larger organizations that need governed application integration, reusable automation recipes, and enterprise support. + +**Best for:** Enterprises coordinating business applications, data, permissions, and centrally governed automation programs. + +**Pricing:** As of October 2026, Workato describes a [usage-based model with platform editions and credits](https://docs.workato.com/en/pricing/overview), while contract terms can determine the applicable measurement model. + +**Strengths:** Workato focuses on application integration, process automation, recipe lifecycle management, environments, security, and enterprise orchestration in its [published plan structure](https://www.workato.com/pricing). + +**Limitations:** Workato is a proprietary cloud platform; its [on-premises agent connects private systems to Workato Cloud](https://docs.workato.com/on-prem/) rather than distributing the complete platform for self-hosting. + +**Sources:** [Workato product](https://www.workato.com), [Workato pricing](https://www.workato.com/pricing), [Workato billing documentation](https://docs.workato.com/pricing.html), and [Workato documentation](https://docs.workato.com). ## Which AI automation tool is best overall in 2026? @@ -271,11 +403,93 @@ Use this decision sequence: 6. **Review governance.** Check access controls, secrets management, audit requirements, data residency, retention, and vendor terms. 7. **Plan for failure.** Determine how the platform handles model errors, API rate limits, duplicate events, timeouts, and partial execution. -Run the same representative workflow in every finalist. Measure build time, successful-run rate, human intervention, billing-unit consumption, debugging effort, and the controls available before a sensitive action executes. The [AI Workflow Automation Platform Buyer's Checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) provides a longer requirements framework. +Run the same representative workflow in every finalist. Measure build time, successful-run rate, human intervention, billing-unit consumption, debugging effort, and the controls available before a sensitive action executes. The AI Workflow Automation Platform Buyer's Checklist below provides the requirements framework in one place. + +Teams comparing the building experience rather than the broader market should use [Best AI Workflow Builders for Technical and Semi-Technical Teams](https://www.sim.ai/library/best-ai-workflow-builders). Teams whose deployment decision starts with infrastructure control should use [Best Self-Hosted AI Workflow Automation Platforms in 2026](https://www.sim.ai/library/best-self-hosted-ai-workflow-automation-platforms-2026). + +## What should you look for in an AI workflow automation platform? + +Sim demonstrates the central buying distinction: evaluate how workflow automation software controls model-driven decisions, not merely whether its feature list includes AI. + +The checklist has six core questions: + +- **AI agent support:** Can agents reason, use tools, access context, and complete bounded tasks with explicit controls? +- **Multi-step orchestration:** Can AI workflows branch, loop, run parallel steps, delegate work, and pause for structured human input? +- **Model flexibility:** Can you change or bring your own language model without rebuilding the workflow? +- **Integration depth:** Does each connector support the actions you need, with API, webhook, or Model Context Protocol (MCP) fallbacks that let compatible AI clients call connected tools? +- **Security and governance:** Can you control access, audit activity, manage data retention, and meet compliance requirements? +- **Pricing model:** Can you predict costs as tasks, model usage, retries, and workflow volume increase? + +### Why the buying criteria have changed + +Workflow automation software now combines predictable trigger-action sequences with model reasoning, tool selection, context, and variable execution paths, so buyers must assess how a product controls decisions as well as how it connects applications. + +Established vendors package that shift differently. Zapier combines structured Zaps with [goal-driven Agents](https://zapier.com/agents). n8n documents [LangChain-based AI components](https://docs.n8n.io/build/integrate-ai/langchain-in-n8n) for models, memory, tools, and retrieval. Connector count alone does not show whether workflow automation software can build, govern, and recover production AI workflows. + +### AI agent support: can the platform run autonomous, tool-using agents? + +AI agent support should provide a bounded reasoning loop in which a model chooses approved tools, retains relevant run context, and records enough activity to explain the result. + +Sim provides an [Agent block](https://docs.sim.ai/workflows/blocks/agent) that reasons with a selected model and acts through connected tools, plus [MCP tools](https://docs.sim.ai/agents/mcp) for services without a built-in integration. Zapier offers [Agents](https://zapier.com/agents) that act across connected apps. n8n exposes configurable agent, model, memory, and tool components through its [LangChain integration](https://docs.n8n.io/build/integrate-ai/langchain-in-n8n), and its [Call n8n Workflow Tool](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow) lets an agent invoke another workflow. Gumloop documents [tool-using, context-aware Agents](https://docs.gumloop.com/core-concepts/agents), while Make describes [AI Agents](https://www.make.com/en/ai-agents) with visible reasoning and tool use. + +Ask every vendor to run the same realistic support request: consult a knowledge source, call a customer-record tool, handle a failed lookup, and pause before a consequential update. Inspect the tool-call record and whether the workflow preserves enough context to explain its decision. + +### Multi-step and multi-app orchestration: how workflows branch, loop, and hand off + +Orchestration depth should show how workflow automation software handles changing paths, repeated work, reusable components, and human decisions. + +Sim provides conditions and routing for branches, plus parallel, loop, and delegated workflow steps. Its [Human in the Loop block](https://docs.sim.ai/workflows/blocks/human-in-the-loop) pauses a run and resumes with submitted form fields; approval or rejection is a field that a downstream Condition must evaluate. Sim can also [deploy a workflow as an MCP server](https://docs.sim.ai/workflows/deployment/mcp) so compatible clients can call it as a tool. + +n8n documents [modular sub-workflows](https://docs.n8n.io/build/flow-logic/break-workflows-into-smaller-parts). Zapier provides [Paths for conditional branches](https://help.zapier.com/hc/en-us/articles/8496288555917-Add-branching-logic-to-Zap-workflows-with-Paths) plus [Looping and Sub-Zaps](https://help.zapier.com/hc/en-us/sections/16075022072077-Sub-Zap-Looping). Make uses [routers](https://help.make.com/router), [iterators](https://help.make.com/iterator), and [aggregators](https://help.make.com/aggregator). Gumloop offers [reusable subflows](https://docs.gumloop.com/core-concepts/subflows). + +During a demo, require one exception path, a loop over multiple records, delegated work, and an approval that can remain pending. Then inspect retries, preserved state, and execution traces rather than accepting a two-action demonstration. + +### Model flexibility: locked into one LLM vendor or free to choose? + +Model flexibility should let a team choose a model for each relevant step and replace the provider without rebuilding surrounding workflow logic. + +Sim separates hosted access from BYOK; [workspace BYOK](https://docs.sim.ai/platform/costs#bring-your-own-key-byok) works on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise. The [BYOK and multi-model guide](https://www.sim.ai/library/byok-multi-model-ai-agent-builder) explains the architecture tradeoffs. Self-hosted Sim can connect local models without Enterprise. + +n8n provides model-specific components including [Anthropic Chat Model](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatanthropic) and [Ollama Chat Model](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatollama). Gumloop documents [model selection and presets](https://docs.gumloop.com/core-concepts/ai_models). Make's [AI Agent credit documentation](https://help.make.com/credit-usage-for-ai-agents) distinguishes Make-provided model access from custom provider connections on eligible plans. During a demo, replace the model inside an existing agent and confirm which providers, keys, and local options the intended plan includes. + +### Integration depth: raw connector count versus usable depth per app + +Integration depth should be judged by required triggers, actions, authentication, pagination, rate-limit handling, and custom API fallbacks rather than a directory total alone. + +As of October 2026, [Sim's pricing page](https://www.sim.ai/pricing) describes more than 270 integrations, [Zapier's pricing page](https://zapier.com/pricing) describes access to thousands of apps, and [Make's pricing page](https://www.make.com/en/pricing) lists more than 3,500 apps. These totals use different definitions and change, so test the exact operations your representative workflow needs. For any missing action, confirm a workable webhook, generic API request, or MCP fallback. The [Zapier alternatives guide](https://www.sim.ai/library/best-zapier-alternatives) offers a connector-focused comparison. + +### Security and governance: what enterprise buyers actually need to check + +Security and governance should be proven at the intended plan and deployment level before rollout. + +- **Identity management:** Confirm that SSO centralizes sign-in and SCIM provisions or removes users through the identity provider. +- **Access control:** Check whether role-based access can restrict models, integrations, credentials, and administrative actions. +- **Auditability:** Require searchable records for configuration changes, user activity, and workflow runs, then confirm retention periods. +- **Data control:** Ask where prompts, outputs, credentials, and logs reside, and review deletion and export procedures. +- **Compliance:** Request current certification reports and confirm that their scope covers the services being purchased. +- **Deployment:** Determine whether self-hosting or on-premises connectivity covers the full product, and which controls require an enterprise license. + +[Sim Enterprise documentation](https://docs.sim.ai/platform/enterprise) describes permission groups, SSO, audit logs, usage tracking, data retention, and data drains; Sim separately documents [SCIM provisioning](https://docs.sim.ai/platform/enterprise/scim) and [self-hosted Enterprise configuration](https://docs.sim.ai/platform/enterprise/self-hosted). These enterprise features are governed by the [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), not the Apache 2.0 core license. + +n8n documents plan-dependent [SAML](https://docs.n8n.io/administer/manage-users-and-access/verify-user-identity/use-saml) and [role-based permissions](https://docs.n8n.io/administer/manage-users-and-access/set-permissions-and-roles-rbac). Zapier documents [SAML SSO](https://help.zapier.com/hc/en-us/articles/8496279747085-Set-up-single-sign-on-with-SAML) on eligible plans. Gumloop documents [Enterprise SSO, SAML, and SCIM](https://docs.gumloop.com/enterprise-features/sso_saml_scim) and [audit logging](https://docs.gumloop.com/enterprise-features/audit_logging). Ask each vendor to confirm current scope in writing. + +### Pricing model: task, operation, execution, or credit + +Pricing models should be compared using the cost of a complete production run, including loops, retries, AI usage, and operating labor. + +Zapier explains [task allowances](https://zapier.com/pricing), while Make explains [module and AI credit usage](https://www.make.com/en/pricing). Gumloop says [agent credits vary by model, tools, and run length](https://docs.gumloop.com/core-concepts/credits). n8n prices plans primarily by [workflow executions](https://n8n.io/pricing/), and Sim documents a [base run charge, hosted model usage, and hosted tool usage](https://docs.sim.ai/platform/costs). Workato documents [platform editions and usage credits](https://docs.workato.com/en/pricing/overview). Price several representative AI workflows instead of comparing headline allowances. + +### Matching the checklist to your buying scenario + +The buyer's operating role should determine how the six criteria are weighted and demonstrated. + +Technical architecture evaluations should prioritize agent controls, orchestration, model choice, and deployment. Process owners should begin with integration depth, exception handling, and cost at expected monthly volume. Enterprise approvers should confirm access controls, audit records, retention, and deployment terms before scoring convenience. Most migrations require rebuilding because products represent triggers, branches, credentials, and mappings differently, but standard APIs, webhooks, MCP tools, portable prompts, and external data stores can reduce that work. + +Use the six criteria as rows in a vendor scorecard, weight them for the buying scenario, and require every finalist to prove each score with the same production-like workflow. ## What are the limitations of these AI automation tools? -Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate all trade simplicity, control, ecosystem breadth, or operational responsibility against one another. +Sim, Gumloop, n8n, Zapier, Make, Microsoft Power Automate, and Workato all trade simplicity, control, ecosystem breadth, or operational responsibility against one another. - **Sim:** [Self-hosting](https://docs.sim.ai/platform/self-hosting) provides control but makes the buyer responsible for infrastructure, upgrades, security, and model-provider costs. - **n8n:** Technical flexibility can introduce a steeper learning curve, and its [Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) is not equivalent to an OSI-approved open-source license. @@ -283,6 +497,13 @@ Sim, n8n, Zapier, Make, Gumloop, and Microsoft Power Automate all trade simplici - **Make:** Complex visual scenarios can become difficult to maintain, and [credit consumption](https://www.make.com/en/pricing) depends on workflow design. - **Gumloop:** Managed no-code operation reduces infrastructure work but offers less deployment and licensing control than Sim's [Apache 2.0 core](https://github.com/simstudioai/sim/blob/main/LICENSE), whose enterprise exception is documented in the [`apps/sim/ee` license](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). - **Microsoft Power Automate:** Enterprise breadth and Microsoft integration can come with [licensing complexity](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing). +- **Workato:** Enterprise governance and integration breadth come with a proprietary cloud architecture and a [usage model](https://docs.workato.com/en/pricing/overview) that buyers should estimate against their contract and production recipes. + +## Which workflow automation tool should you choose? + +Sim should be the first choice for AI-first workflows, while Gumloop, n8n, Zapier, Make, Microsoft Power Automate, and Workato each fit a more specific operating model. + +Choose **Sim** when AI reasoning, tool use, model choice, branching, controlled human input, and deployment flexibility are central requirements. Choose **Gumloop** for approachable managed visual AI automation. Choose **n8n** when source-available self-hosting and technical customization matter most. Choose **Zapier** for straightforward SaaS triggers and actions. Choose **Make** for complex visual scenarios. Choose **Microsoft Power Automate** for a Microsoft-governed environment. Choose **Workato** for a governed enterprise integration program. ## Which related AI automation comparisons should I read? @@ -292,12 +513,13 @@ Sim routes agent-builder intent to its dedicated agent comparison so this broade - For a direct technical decision, compare Sim and n8n using the criteria in the head-to-head section above. - For open-source requirements, prioritize license terms, self-hosting, and infrastructure responsibility rather than treating “source available” and “open source” as synonyms. - For no-code requirements, compare hosted convenience, connector coverage, model support, and the billing unit using a representative production workflow. -- For procurement requirements, use the [AI Workflow Automation Platform Buyer's Checklist](https://www.sim.ai/library/ai-workflow-automation-platform-buyers-checklist) to compare operational controls and costs. +- For procurement requirements, use the AI Workflow Automation Platform Buyer's Checklist above to compare operational controls and costs. ## Where can buyers verify current licensing, deployment, and pricing details? -Sim, n8n, Zapier, Make, Gumloop, and Microsoft publish the authoritative current terms for their own products, so buyers should review commercial details on those first-party pages. +Sim, Gumloop, n8n, Zapier, Make, Microsoft, and Workato publish the authoritative current terms for their own products, so buyers should review commercial details on those first-party pages. +- [Sim product documentation](https://docs.sim.ai/introduction) - [Sim GitHub repository and Apache 2.0 core license](https://github.com/simstudioai/sim) - [Sim pricing](https://www.sim.ai/pricing) - [n8n Sustainable Use License](https://docs.n8n.io/privacy-and-security/sustainable-use-license) @@ -306,3 +528,5 @@ Sim, n8n, Zapier, Make, Gumloop, and Microsoft publish the authoritative current - [Make pricing](https://www.make.com/en/pricing) - [Gumloop pricing](https://www.gumloop.com/pricing) - [Microsoft Power Automate pricing](https://www.microsoft.com/en-us/power-platform/products/power-automate/pricing) +- [Workato pricing](https://www.workato.com/pricing) +- [Workato billing documentation](https://docs.workato.com/pricing.html) From 546a3788e04aadca124f7dc7846400f3af5fdceb Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 16:29:19 -0700 Subject: [PATCH 22/30] docs(library): update top-ai-assistants-2026 (#9356) * docs(library): update top-ai-assistants-2026 * Pi Babysit: address PR #9356 feedback --------- Co-authored-by: Sim Pi Agent --- .../library/top-ai-assistants-2026/index.mdx | 223 ++++++++++++++---- .../library/top-ai-assistants-2026/cover.jpg | Bin 17480 -> 29964 bytes 2 files changed, 182 insertions(+), 41 deletions(-) diff --git a/apps/sim/content/library/top-ai-assistants-2026/index.mdx b/apps/sim/content/library/top-ai-assistants-2026/index.mdx index 2eea9e165f4..f2daaf6f91a 100644 --- a/apps/sim/content/library/top-ai-assistants-2026/index.mdx +++ b/apps/sim/content/library/top-ai-assistants-2026/index.mdx @@ -1,13 +1,13 @@ --- slug: top-ai-assistants-2026 -title: 'Top AI Assistants in 2026' +title: 'Best AI Assistants in 2026: Top Options Compared' description: 'Compare the top AI assistants in 2026 for personal productivity, research, writing, Microsoft 365, and autonomous work, including capabilities, pricing, privacy, and tradeoffs.' seoDescription: 'ChatGPT, Claude, Gemini, Perplexity, Microsoft Copilot, Vellum, and Lindy compared for productivity, research, and writing, with pricing and privacy notes.' date: 2026-09-10 -updated: 2026-10-20 +updated: 2026-10-23 authors: - andrew -readingTime: 19 +readingTime: 29 tags: [AI Assistants, AI Agents, Productivity, Sim] ogImage: /library/top-ai-assistants-2026/cover.jpg draft: false @@ -64,18 +64,39 @@ faq: a: "AI assistant memory varies by product, plan, account type, region, and user settings, so buyers should test the current controls for viewing, disabling, and deleting retained information." - q: "Are free AI assistants enough for business use?" a: "Free AI assistants can be sufficient for low-risk individual experimentation, but organizations commonly need paid plans when they require administrative controls, contractual protections, collaboration, higher limits, or supported access to business data." + - q: "What is the best AI assistant?" + a: "ChatGPT is the best AI assistant for most people because it provides the strongest overall mix of conversation, writing, analysis, research, and multimodal work in a ready-made product." + - q: "What is the best free AI assistant?" + a: "ChatGPT, Claude, Gemini, Microsoft Copilot, and Perplexity each offer access paths that may suit users seeking a no-cost starting point, but current availability and limits depend on the vendor, region, and plan as of October 2026." + - q: "Is ChatGPT the best AI assistant?" + a: "ChatGPT is the best AI assistant overall for general use, but Claude is stronger for some writing and document tasks, Perplexity is stronger for source-led web research, and ecosystem-specific users may prefer Gemini or Microsoft Copilot." + - q: "What is the best AI assistant for business?" + a: "Microsoft Copilot is a strong AI assistant for Microsoft-centered businesses, Gemini is a strong choice for Google-centered businesses, and Sim is the better choice when the business needs a custom assistant that executes a controlled process across multiple systems." + - q: "What is the best AI assistant for research?" + a: "Perplexity is the best AI assistant for web research with visible sources, while ChatGPT and Claude are stronger generalists for combining research with broader drafting and analysis." + - q: "Should I build my own AI assistant with Sim?" + a: "Sim is worth using to build an AI assistant when the assistant must connect company tools, call APIs, follow repeatable logic, accept human input, or run through cloud or self-hosted infrastructure." + - q: "Is Sim free and open source?" + a: "Sim's core is open source under Apache 2.0, but code in apps/sim/ee is governed by the separate Sim Enterprise License and requires an active Enterprise subscription for production use." + - q: "Is Sim better than n8n for building an AI assistant?" + a: "Sim is better suited to teams that want an open-source AI workspace and visual builder centered on deploying and managing AI agents, while n8n is a strong source-available option for technical workflow automation teams." --- ## TL;DR -**[ChatGPT](https://chatgpt.com/pricing/) is the best general-purpose AI assistant for most people in 2026, while [Vellum](https://www.vellum.ai/) leads for autonomous personal work.** [Claude](https://www.anthropic.com/pricing) is strongest for long-form writing and document analysis, [Gemini](https://gemini.google/subscriptions/) for Google Workspace, [Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing) for Microsoft 365, and [Perplexity](https://www.perplexity.ai/help-center/en/articles/10352901-what-is-perplexity-pro) for citation-forward web research. Teams that need a repeatable system to take action across tools should use an agent builder such as Sim rather than a standalone assistant. +**[ChatGPT](https://chatgpt.com/pricing/) is the best AI assistant overall for most people in 2026** because it combines conversation, writing, analysis, research, and multimodal work in one accessible product. [Claude](https://www.anthropic.com/pricing) is strongest for long-form writing and document analysis, [Gemini](https://gemini.google/subscriptions/) for Google Workspace, [Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing) for Microsoft 365, and [Perplexity](https://www.perplexity.ai/help-center/en/articles/10352901-what-is-perplexity-pro) for citation-forward web research. [Vellum](https://www.vellum.ai/) leads for autonomous personal work. Teams that need a custom assistant to act across tools and repeatable processes should use an agent builder such as Sim rather than a standalone assistant. -- **[Vellum](https://www.vellum.ai/) | Best overall personal AI assistant.** An open-source personal AI assistant that can run on your computer, remember across conversations, and take actions across connected tools. -- **[ChatGPT](https://openai.com/chatgpt/overview/) | Best for general-purpose use.** Its multimodal tools and broad feature ecosystem support a wide range of everyday conversational tasks. +Pricing and plan availability are stated as of October 2026. Because vendors change plans frequently, the linked first-party pricing pages are the authoritative sources. + +- **[ChatGPT](https://openai.com/chatgpt/overview/) | Best overall AI assistant.** Its multimodal tools and broad feature ecosystem support a wide range of everyday conversational tasks. +- **[Vellum](https://www.vellum.ai/) | Best for autonomous personal work.** An open-source personal AI assistant that can run on your computer, remember across conversations, and take actions across connected tools. - **[Claude](https://www.anthropic.com/pricing) | Best for writing and long documents.** It handles extended context, complex analysis, and nuanced prose exceptionally well. - **[Perplexity](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity) | Best for research.** It combines web search with concise, cited answers for quick verification. - **[Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work) | Best for Microsoft 365 users.** It works inside familiar documents, spreadsheets, email, and meeting tools. - **[Lindy](https://docs.lindy.ai/fundamentals/lindy-101/create-agent) | Best for lightweight autonomous actions.** It handles routine administrative tasks like calendar coordination and inbox triage without requiring a full automation platform. +- **[Zapier Agents](https://zapier.com/agents) | Best for straightforward SaaS actions.** It gives business teams a hosted way to delegate work across Zapier's app ecosystem. +- **Sim | Best for building a custom assistant that automates real work.** It lets teams design agents around their own models, tools, APIs, and human checkpoints. +- **[n8n](https://n8n.io/pricing/) | Best for technical automation teams.** It offers cloud and self-hosted automation under a source-available license. Use the detailed entries to compare each assistant's capabilities, pricing, and tradeoffs. If you are deciding between conversational and action-taking software, start with our guide to [AI agents versus chatbots](https://www.sim.ai/library/ai-agent-vs-chatbot). @@ -87,6 +108,19 @@ This list covers assistants built primarily for individual users, operators, and We evaluated each assistant across model flexibility, integration depth, privacy controls, continuous memory, multimodal capabilities, and pricing transparency. Each ranking also accounts for concrete tradeoffs, such as closed ecosystems, cloud data retention, or machine connectivity requirements. Rankings reflect each product's fit for its target use case rather than a single aggregate score. +The evidence supports this reader-fit ranking: + +1. **ChatGPT — best overall AI assistant** +2. **Claude — best AI assistant for writing and long documents** +3. **Gemini — best AI assistant for Google Workspace users** +4. **Microsoft Copilot — best AI assistant for Microsoft 365 users** +5. **Perplexity — best AI assistant for source-backed web research** +6. **Zapier Agents — best AI assistant for straightforward SaaS actions** +7. **Sim — best for building a custom assistant that automates real work** +8. **n8n — best for technical teams adapting source-available automation** +9. **Vellum — best for autonomous personal work** +10. **Lindy — best for lightweight email, calendar, and meeting actions** + ## What to look for in a personal AI assistant A personal AI assistant should match the user's models, tools, privacy requirements, input formats, and budget. @@ -113,7 +147,7 @@ Vellum has [persistent memory that carries useful knowledge between conversation Vellum supports managed cloud and self-hosted deployments. Its documentation says that [the default managed model uses Anthropic Claude and local hosting still sends inference through a cloud AI model](https://www.vellum.ai/docs/trust-security/privacy-and-data), so local hosting should not be confused with fully offline inference. -**Pros** +**Strengths** - [Persistent memory and access across multiple supported surfaces](https://www.vellum.ai/) provide continuity between conversations. - [Background routines can handle scheduled checks and recurring work](https://www.vellum.ai/). @@ -121,7 +155,7 @@ Vellum supports managed cloud and self-hosted deployments. Its documentation say - Managed and self-hosted deployment options support different operating preferences. - [The open-source, self-hostable option allows inspection and control of the deployment](https://www.vellum.ai/pricing). -**Cons** +**Limitations** - [Local actions require the host computer to remain awake and available](https://www.vellum.ai/docs/hosting-options/local-hosting). - Cloud hosting is more convenient but does not offer the same data-locality profile as self-hosting. @@ -131,6 +165,8 @@ Vellum supports managed cloud and self-hosted deployments. Its documentation say As of October 2026, [Vellum's pricing page](https://www.vellum.ai/pricing) lists a free starting option and three packaged paid plans: Mighty at $30/month, Super at $100/month, and Ultra at $200/month. It also says self-hosting has no platform fee; model and infrastructure costs can still apply. +**Sources:** [Vellum overview](https://www.vellum.ai/), [Vellum pricing](https://www.vellum.ai/pricing) + ## ChatGPT ChatGPT is the best general-purpose AI assistant for most people in 2026. @@ -145,14 +181,14 @@ People who want one general-purpose assistant for everyday writing, research, co ChatGPT is centered on a hosted assistant experience, but it is no longer limited to purely reactive chat: [eligible accounts can run one-time or recurring scheduled tasks and monitor for supported changes](https://help.openai.com/en/articles/10291617-scheduled-tasks-in-chatgpt). Available models, tools, usage limits, and data controls vary by plan. -**Pros** +**Strengths** - Its [plan matrix covers a broad range of multimodal and productivity features](https://openai.com/chatgpt/pricing/). - [Voice, file, research, and image tools support varied interactive work](https://openai.com/chatgpt/overview/). - [Projects and custom GPTs provide reusable context and task-specific behavior](https://openai.com/chatgpt/pricing/). - The product spans everyday chat, creative work, research, and coding use cases. -**Cons** +**Limitations** - Scheduled tasks do not equate to unrestricted local operating-system execution. - The hosted product is tied to OpenAI's model and service ecosystem. @@ -162,6 +198,8 @@ ChatGPT is centered on a hosted assistant experience, but it is no longer limite As of October 2026, OpenAI offers free and paid plans. [ChatGPT Plus costs $20/month](https://help.openai.com/en/articles/6950777-what-is-chatgpt-plus), while other individual, business, and enterprise options provide different tools, limits, and administration features. +**Sources:** [ChatGPT overview](https://openai.com/chatgpt/overview/), [ChatGPT pricing](https://chatgpt.com/pricing/) + ## Claude Claude is the best AI assistant in this comparison for long-form writing and document analysis. @@ -176,14 +214,14 @@ Knowledge workers requiring long-document analysis, complex reasoning, and nuanc Claude often produces measured answers with thoughtful caveats, making it useful for research synthesis and sensitive editorial tasks. It is not limited to an isolated web app: [connectors can retrieve data and take actions in connected services while inheriting the user's permissions](https://support.anthropic.com/en/articles/11176164-pre-built-web-connectors-using-remote-mcp). These connectors are distinct from unrestricted local computer control. -**Pros** +**Strengths** - Strong comprehension across long documents and codebases. - Articulate prose for drafting and revision. - [Projects provide structured workspaces with persistent reference documents](https://www.anthropic.com/news/projects). - [Connectors extend Claude into supported apps and services](https://support.anthropic.com/en/articles/11176164-pre-built-web-connectors-using-remote-mcp). -**Cons** +**Limitations** - Connector availability and behavior depend on the service, client, and configuration. - Safety controls can occasionally block a legitimate research query. @@ -193,6 +231,8 @@ Claude often produces measured answers with thoughtful caveats, making it useful As of October 2026, [Anthropic offers a free Claude plan and prices Claude Pro at $20 when billed monthly or $200 when billed annually](https://www.anthropic.com/pricing). Team and enterprise options have separate current terms. +**Sources:** [Claude product page](https://www.anthropic.com/claude), [Claude pricing](https://claude.com/pricing) + ## Gemini Gemini is the best AI assistant for people and organizations whose daily work already lives in Google's supported applications and services. @@ -207,13 +247,13 @@ Google Workspace users who want assistance connected to supported Google applica [Gemini personalization based on past chats](https://support.google.com/gemini/answer/16598469) requires an eligible personal Google Account, Keep Activity, and a supported region and surface; it is not available for work, school, or supervised accounts. Account type, administrator policy, plan, and region can also affect integrations and data access. -**Pros** +**Strengths** - [Free and paid subscriptions provide different model and feature limits](https://gemini.google/subscriptions/). - Supported Google services make Gemini a natural fit for Google-centered work. - Personalization controls can use prior chats and preferences for eligible personal accounts. -**Cons** +**Limitations** - Integration and personalization availability varies by account type, plan, setting, and region. - Gemini is less compelling for teams that are not centered on Google services. @@ -223,6 +263,8 @@ Google Workspace users who want assistance connected to supported Google applica As of October 2026, [Google's Gemini subscriptions page](https://gemini.google/subscriptions/) lists a free option and paid consumer subscriptions; organizational availability and charges depend on the applicable Google Workspace offering. +**Sources:** [Gemini overview](https://gemini.google.com/), [Google AI plans](https://one.google.com/about/google-ai-plans/) + ## Perplexity Perplexity is the best AI assistant in this comparison for citation-forward web research. @@ -237,14 +279,14 @@ Researchers who want web information with visible source citations. Perplexity is useful for market scans, product comparisons, and early-stage research. [Its sessions retain sources and context across follow-up questions](https://www.perplexity.ai/help-center/en/articles/10354769-what-is-a-thread). Its core strength is information retrieval rather than local operational control: it is not primarily designed to organize a desktop inbox or execute arbitrary system tasks. -**Pros** +**Strengths** - [Searches web content and provides links to supporting sources](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity). - [Conversational queries let users refine a topic while retaining context](https://www.perplexity.ai/help-center/en/articles/10354775-technical-capabilities-of-threads). - [Pro subscribers can switch among supported advanced models](https://www.perplexity.ai/help-center/en/articles/10354919-what-advanced-ai-models-are-included-in-my-subscription). - [Research and file tools support deeper source exploration](https://www.perplexity.ai/help-center/en/articles/10352901-what-is-perplexity-pro). -**Cons** +**Limitations** - Research is its main focus rather than local action execution. - Citations can support only part of a generated claim, so important details still require manual verification. @@ -254,6 +296,8 @@ Perplexity is useful for market scans, product comparisons, and early-stage rese As of October 2026, Perplexity offers free and paid plans with different access to searches, citations, uploads, research, and models. Review [Perplexity's current subscription comparison](https://www.perplexity.ai/help-center/en/articles/11187416-which-perplexity-subscription-plan-is-right-for-you) before choosing a plan, because limits and included features can change. +**Sources:** [Perplexity](https://www.perplexity.ai/), [Perplexity plans](https://www.perplexity.ai/pro) + ## Microsoft Copilot Microsoft Copilot is the best AI assistant in this comparison for organizations standardized on Microsoft 365. @@ -268,13 +312,13 @@ Microsoft 365 users who want AI assistance embedded inside documents, email, spr Copilot combines language models with Microsoft Graph and Microsoft 365 services, using work content that the user has permission to access. Its deepest value therefore comes from the Microsoft ecosystem, while custom local scripts and independent model choice are not its main focus. -**Pros** +**Strengths** - [Works directly inside familiar Microsoft 365 applications](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work). - [Microsoft Graph integration grounds responses in work content users have permission to access](https://learn.microsoft.com/en-us/microsoft-365/copilot/microsoft-365-copilot-overview). - [Business offerings include organizational security and administrative controls](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing). -**Cons** +**Limitations** - It delivers less value when a workflow sits outside Microsoft 365. - Feature availability depends on the underlying subscription and whether an administrator assigns a Copilot add-on license. @@ -284,6 +328,8 @@ Copilot combines language models with Microsoft Graph and Microsoft 365 services As of October 2026, Microsoft offers several individual and organizational Copilot packages rather than one universal price. Review the [current Microsoft 365 Copilot plans and pricing](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing) alongside the licensing prerequisites for the capabilities you need. +**Sources:** [Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-copilot), [Microsoft Copilot pricing](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing/individuals) + ## Lindy Lindy is the best AI assistant in this comparison for lightweight email, calendar, and meeting actions. @@ -298,13 +344,13 @@ Users who want an AI personal assistant to handle recurring email, calendar, and Lindy suits operators who want more action-taking than a standard chat window provides without building a complex workflow from scratch. It is a closed, hosted product organized around connected applications rather than a self-hosted local assistant. -**Pros** +**Strengths** - [Its meeting tools can schedule meetings, take notes, and handle follow-up tasks](https://www.lindy.ai/solutions/meetings). - [Templates provide pre-built workflows for common administrative tasks](https://docs.lindy.ai/fundamentals/lindy-101/templates). - [Human-in-the-loop controls can pause side-effecting actions for confirmation](https://docs.lindy.ai/testing/human-in-the-loop). -**Cons** +**Limitations** - As a hosted service, it has a different data-control profile from a local deployment. - Usage allowances vary by plan, so users should compare expected volume with current terms. @@ -312,21 +358,115 @@ Lindy suits operators who want more action-taking than a standard chat window pr **Pricing** -As of October 2026, [Lindy's current pricing page](https://www.lindy.ai/pricing) lists a seven-day trial rather than a permanent free tier. Paid plans start from $29.99 a month per active user for 3,000 credits, though some visitors see $49.99 for 5,000 credits, and scale to higher-capacity plans. +As of October 2026, [Lindy's current pricing page](https://www.lindy.ai/pricing) lists a free plan with $50 in starting credits. Active users on the Team plan are billed from $49.99 a month for 5,000 credits each, with pooled team credits and paid top-ups. + +**Sources:** [Lindy integrations](https://docs.lindy.ai/integrations/overview), [Lindy pricing](https://www.lindy.ai/pricing) + +## Zapier Agents + +Zapier Agents is the best assistant in this comparison for straightforward actions across common SaaS applications. + +**Best for** + +Business teams that want to create a hosted assistant that can use company knowledge and take actions in familiar cloud applications. + +**What it is** + +[Zapier Agents](https://zapier.com/agents) lets users create agents that work with business data and perform tasks across Zapier's app ecosystem. + +**Strengths** + +- Zapier's established integration catalog makes common SaaS connections accessible. +- The hosted experience prioritizes quick setup for app-based tasks. + +**Limitations** + +- Complex state, extensive branching, custom infrastructure, and deeper execution control may require a more configurable builder. +- Zapier Agents does not provide a self-hosted software edition. + +**Pricing** + +As of October 2026, Zapier publishes current product plans and usage terms on its [official pricing page](https://zapier.com/pricing). + +**Sources:** [Zapier Agents](https://zapier.com/agents), [Zapier pricing](https://zapier.com/pricing) + +## Sim + +Sim is the choice in this comparison for teams building a custom assistant around their own models, APIs, tools, data flows, and human checkpoints—not a ready-made personal assistant. + +**Best for** + +Technical and semi-technical teams building assistants that must act across tools and repeatable processes. + +**What it is** + +Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. Its visual builder connects models, tools, APIs, logic, and human input in repeatable workflows. + +**Strengths** + +- Teams can design assistants around their own systems instead of accepting a fixed chat product. +- Cloud and self-hosted deployment support different operational requirements. +- Any self-hosted Sim deployment can connect Ollama, vLLM, LM Studio, or LiteLLM without requiring Enterprise. + +**Limitations** + +- A team must define, test, deploy, and maintain the assistant. +- Sim is not intended to replace a ready-made general-purpose personal assistant for immediate drafting and questions. + +**Pricing** + +As of October 2026, Sim lists its hosted plans on the [official pricing page](https://www.sim.ai/pricing). [Workspace BYOK](https://docs.sim.ai/platform/costs#bring-your-own-key-byok) works on every Sim Cloud plan, while organization-level keys require Pro for Teams, Max for Teams, or Enterprise. + +**Sources:** [Sim documentation](https://docs.sim.ai/), [self-hosting documentation](https://docs.sim.ai/platform/self-hosting), [Sim core license](https://github.com/simstudioai/sim/blob/main/LICENSE), [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) + +Sim's core is Apache 2.0 and can be self-hosted, while code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), which permits development, testing, and internal non-production use; production use requires an active Sim Enterprise subscription. + +Teams ready to implement an assistant can follow [How to Create an AI Agent: A Beginner’s Step-by-Step Guide](https://www.sim.ai/library/how-to-create-an-ai-agent). + +## n8n + +n8n is the best fit in this comparison for technical automation teams that want source access and self-hosting. + +**Best for** + +Developers and automation specialists who value self-hosting and broad workflow connectivity. + +**What it is** + +n8n is a workflow automation product with AI capabilities, cloud hosting, and self-hosted deployment. + +**Strengths** + +- n8n supports self-hosting, code-oriented customization, and broad application automation. +- Its execution-based plans include unlimited users, workflows, and integrations according to the [official pricing page](https://n8n.io/pricing/). + +**Limitations** + +- n8n's [Sustainable Use License](https://docs.n8n.io/sustainable-use-license/) is source-available rather than OSI-approved open source and restricts some commercial uses. +- Teams must design the surrounding assistant experience and account for infrastructure and model-provider costs when self-hosting. + +**Pricing** + +As of October 2026, n8n lists its current cloud and self-hosted plans and billing details on the [official pricing page](https://n8n.io/pricing/). + +**Sources:** [n8n pricing](https://n8n.io/pricing/), [n8n Sustainable Use License](https://docs.n8n.io/sustainable-use-license/) ## How the top AI assistants compare -ChatGPT, Claude, Gemini, Microsoft Copilot, Perplexity, Vellum, and Lindy differ most in model access, native integrations, memory, automation depth, and pricing structure. The linked first-party pages are authoritative for current charges and limits, which can also vary by plan, region, administrator policy, and account type. +ChatGPT, Claude, Gemini, Microsoft Copilot, Perplexity, Zapier Agents, Sim, n8n, Vellum, and Lindy differ most in model access, native integrations, memory, automation depth, deployment, licensing, and pricing structure. The linked first-party pages are authoritative for current charges and limits, which can also vary by plan, region, administrator policy, and account type. | Rank | AI assistant | Model access | Integrations and ecosystem | Memory and personalization | Privacy controls | Multimodal capabilities | Automation ability | Pricing as of October 2026 | Best for | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 1 | [ChatGPT](https://openai.com/chatgpt/overview/) | OpenAI's GPT model family, with access varying by plan | OpenAI tools, files, web features, custom GPTs, and supported connected services | Account-level memory and personalization where enabled | [Data Controls manage training, exports, shared links, and account deletion](https://help.openai.com/en/articles/7730893-data-controls-in-chatgpt) | [Text, file uploads, images, and voice, with availability varying by plan](https://openai.com/chatgpt/pricing/) | Tools, configured actions, and scheduled tasks, but primarily a hosted conversational assistant | [Free and paid tiers; Plus is $20/month](https://help.openai.com/en/articles/6950777-what-is-chatgpt-plus) | General-purpose assistance, analysis, drafting, and broad tool use | -| 2 | [Vellum](https://www.vellum.ai/) | [Managed Claude by default, including for local hosting](https://www.vellum.ai/docs/trust-security/privacy-and-data) | Web, macOS, iOS, Slack, Telegram, terminal, and connected tools | Persistent cross-conversation memory | [Encrypted private cloud storage or workspace data kept on the user's machine with local hosting](https://www.vellum.ai/docs/trust-security/privacy-and-data) | [Text and voice across web, desktop, phone, and chat channels](https://www.vellum.ai/docs/hosting-options/cloud-hosting) | Background routines and actions on an available host | [Free starting option; Mighty $30/month, Super $100/month, and Ultra $200/month](https://www.vellum.ai/pricing) | Autonomous personal work and operations | -| 3 | [Claude](https://www.anthropic.com/pricing) | Anthropic's Claude model family | Web and desktop experiences, files, Projects, artifacts, and supported connectors | Project context and account features vary by product and plan | [Consumer data encrypted in transit and at rest, with employee access limited by default](https://privacy.anthropic.com/en/articles/10458704) | [Text, documents, images, and code](https://docs.anthropic.com/en/docs/build-with-claude/vision) | Interactive knowledge work and connector-supported tasks; repeatable automation generally needs orchestration | [Free, individual, team, and enterprise offerings](https://www.anthropic.com/pricing) | Long-form writing, document analysis, and careful synthesis | -| 4 | [Gemini](https://gemini.google/subscriptions/) | Google's Gemini model family | Supported Google services and Workspace applications | [Past-chat personalization for eligible personal accounts](https://support.google.com/gemini/answer/16598469) | [Activity review, deletion, and retention controls vary between personal and managed accounts](https://support.google.com/gemini/answer/13278892) | [Documents, spreadsheets, notebooks, photos, and videos](https://support.google.com/gemini/answer/14903178) | Assistance in supported Google products, but not a general visual workflow builder | [Free and paid consumer subscriptions, plus organizational offerings](https://gemini.google/subscriptions/) | People and teams centered on Google Workspace | -| 5 | [Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work) | Models delivered through Microsoft's Copilot products | Microsoft 365 applications, Microsoft Graph context, and supported Microsoft services | [Copilot Memory uses permitted chat and work context when enabled](https://support.microsoft.com/en-us/microsoft-365-copilot/manage-copilot-memory-in-microsoft-365-copilot) | [Microsoft 365 access controls, sensitivity labels, encryption, auditing, and compliance protections](https://learn.microsoft.com/en-us/microsoft-365/copilot/microsoft-365-copilot-architecture-data-protection-auditing) | [Documents, email, spreadsheets, presentations, and meetings in supported Microsoft 365 apps](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work) | Strong inside Microsoft applications; broader automation may require other Microsoft services | [Current individual, business, enterprise, and Copilot Studio options](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing) | Organizations standardized on Microsoft 365 | -| 6 | [Perplexity](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity) | Perplexity-selected, plan-dependent models | Web search, cited sources, files, and research tools | [Memory can use saved details and search history](https://www.perplexity.ai/help-center/en/articles/10968016-memory) | [Uploaded files and images stay private and can be deleted with their session](https://www.perplexity.ai/help-center/en/articles/10354810-security-and-privacy-with-file-uploads) | [Text, documents, and image uploads](https://www.perplexity.ai/help-center/en/articles/10354939-what-s-the-difference-between-typing-a-question-and-uploading-a-file-or-image) | Research-focused rather than durable multi-application process automation | [Free Standard plus paid Pro, Max, Education Pro, and enterprise options](https://www.perplexity.ai/help-center/en/articles/11187416-which-perplexity-subscription-plan-is-right-for-you) | Citation-backed web research | -| 7 | [Lindy](https://docs.lindy.ai/fundamentals/lindy-101/create-agent) | Plan-dependent model options in a hosted assistant | Email, calendar, meetings, and supported work-app integrations | Hosted assistant context and configuration | [Personal and team connection scopes with integration guardrails](https://docs.lindy.ai/integrations/overview) | [Email, calendar, meetings, and connected-app data](https://docs.lindy.ai/integrations/overview) | Trigger-based email, calendar, and meeting actions with approvals | [Seven-day trial; per-user plans from $29.99/month, though some visitors see $49.99](https://www.lindy.ai/pricing) | Lightweight administrative tasks | +| 2 | [Claude](https://www.anthropic.com/pricing) | Anthropic's Claude model family | Web and desktop experiences, files, Projects, artifacts, and supported connectors | Project context and account features vary by product and plan | [Consumer data encrypted in transit and at rest, with employee access limited by default](https://privacy.anthropic.com/en/articles/10458704) | [Text, documents, images, and code](https://docs.anthropic.com/en/docs/build-with-claude/vision) | Interactive knowledge work and connector-supported tasks; repeatable automation generally needs orchestration | [Free, individual, team, and enterprise offerings](https://www.anthropic.com/pricing) | Long-form writing, document analysis, and careful synthesis | +| 3 | [Gemini](https://gemini.google/subscriptions/) | Google's Gemini model family | Supported Google services and Workspace applications | [Past-chat personalization for eligible personal accounts](https://support.google.com/gemini/answer/16598469) | [Activity review, deletion, and retention controls vary between personal and managed accounts](https://support.google.com/gemini/answer/13278892) | [Documents, spreadsheets, notebooks, photos, and videos](https://support.google.com/gemini/answer/14903178) | Assistance in supported Google products, but not a general visual workflow builder | [Free and paid consumer subscriptions, plus organizational offerings](https://gemini.google/subscriptions/) | People and teams centered on Google Workspace | +| 4 | [Microsoft Copilot](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work) | Models delivered through Microsoft's Copilot products | Microsoft 365 applications, Microsoft Graph context, and supported Microsoft services | [Copilot Memory uses permitted chat and work context when enabled](https://support.microsoft.com/en-us/microsoft-365-copilot/manage-copilot-memory-in-microsoft-365-copilot) | [Microsoft 365 access controls, sensitivity labels, encryption, auditing, and compliance protections](https://learn.microsoft.com/en-us/microsoft-365/copilot/microsoft-365-copilot-architecture-data-protection-auditing) | [Documents, email, spreadsheets, presentations, and meetings in supported Microsoft 365 apps](https://www.microsoft.com/en-us/microsoft-365-copilot/in-apps-for-work) | Strong inside Microsoft applications; broader automation may require other Microsoft services | [Current individual, business, enterprise, and Copilot Studio options](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing) | Organizations standardized on Microsoft 365 | +| 5 | [Perplexity](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity) | Perplexity-selected, plan-dependent models | Web search, cited sources, files, and research tools | [Memory can use saved details and search history](https://www.perplexity.ai/help-center/en/articles/10968016-memory) | [Uploaded files and images stay private and can be deleted with their session](https://www.perplexity.ai/help-center/en/articles/10354810-security-and-privacy-with-file-uploads) | [Text, documents, and image uploads](https://www.perplexity.ai/help-center/en/articles/10354939-what-s-the-difference-between-typing-a-question-and-uploading-a-file-or-image) | Research-focused rather than durable multi-application process automation | [Free Standard plus paid Pro, Max, Education Pro, and enterprise options](https://www.perplexity.ai/help-center/en/articles/11187416-which-perplexity-subscription-plan-is-right-for-you) | Citation-backed web research | +| 6 | [Zapier Agents](https://zapier.com/agents) | Hosted, plan-dependent AI models | Zapier's supported app ecosystem and connected business data | Agent configuration and supplied knowledge | Hosted Zapier controls | Text and connected-app data | Straightforward actions across common SaaS apps | [Current plans and usage terms](https://zapier.com/pricing) | Quick SaaS actions | +| 7 | Sim | Choice of supported model providers, BYOK, and local models when self-hosted | Models, tools, APIs, integrations, structured data, and human input | Workflow-defined state and data | Cloud or self-hosted deployment | Model- and tool-dependent | Repeatable, triggered multi-step processes with branching and human checkpoints | [Free, Pro, Max, and Enterprise plans](https://www.sim.ai/pricing) | Building custom assistants that automate real work | +| 8 | [n8n](https://n8n.io/pricing/) | Model-provider connections and AI nodes | Broad workflow integrations, code, and self-hosting | Workflow-defined state | Cloud or self-hosted under a source-available license | Model- and node-dependent | Technical application automation and AI workflows | [Cloud and self-hosted plans](https://n8n.io/pricing/) | Technical automation teams | +| 9 | [Vellum](https://www.vellum.ai/) | [Managed Claude by default, including for local hosting](https://www.vellum.ai/docs/trust-security/privacy-and-data) | Web, macOS, iOS, Slack, Telegram, terminal, and connected tools | Persistent cross-conversation memory | [Encrypted private cloud storage or workspace data kept on the user's machine with local hosting](https://www.vellum.ai/docs/trust-security/privacy-and-data) | [Text and voice across web, desktop, phone, and chat channels](https://www.vellum.ai/docs/hosting-options/cloud-hosting) | Background routines and actions on an available host | [Free starting option; Mighty $30/month, Super $100/month, and Ultra $200/month](https://www.vellum.ai/pricing) | Autonomous personal work and operations | +| 10 | [Lindy](https://docs.lindy.ai/fundamentals/lindy-101/create-agent) | Plan-dependent model options in a hosted assistant | Email, calendar, meetings, and supported work-app integrations | Hosted assistant context and configuration | [Personal and team connection scopes with integration guardrails](https://docs.lindy.ai/integrations/overview) | [Email, calendar, meetings, and connected-app data](https://docs.lindy.ai/integrations/overview) | Trigger-based email, calendar, and meeting actions with approvals | [Free plan; Team from $49.99/month per active user for 5,000 credits](https://www.lindy.ai/pricing) | Lightweight administrative tasks | ## Which AI assistant is best for most people? @@ -365,23 +505,21 @@ Calendar-heavy users can also compare purpose-built options in our guide to the ## Why Vellum leads this list -[Vellum leads for autonomous personal work](https://www.vellum.ai/) because it bridges the gap between conversational chat and operational execution. While traditional assistants center on a hosted prompt-and-response experience, Vellum can function as a persistent personal assistant, remember useful context across sessions, and execute background routines. +Although ChatGPT ranks first overall, [Vellum leads for autonomous personal work](https://www.vellum.ai/) because it bridges the gap between conversational chat and operational execution. While traditional assistants center on a hosted prompt-and-response experience, Vellum can function as a persistent personal assistant, remember useful context across sessions, and execute background routines. Its open-source foundation, hosting choices, and security controls distinguish it from assistants that bind every task to one hosted interface. Self-hosting provides the strongest local control, while cloud hosting trades some data locality for convenience. That combination gives users a practical choice between managed operation and a more private, hands-on deployment. ## What is the difference between an AI assistant and an AI agent builder? -An AI assistant helps a person think or create through conversation, while an AI agent builder lets a team design, deploy, and manage systems that take actions across models, tools, and business applications. +An AI assistant helps a person think or create through conversation, while an AI agent builder lets a team design, deploy, and manage systems that take actions across models, tools, and business applications. Our guide to [AI Personal Assistant vs. AI Agent Builder: What's the Difference?](https://www.sim.ai/library/ai-personal-assistant-vs-ai-agent-builder) explores that buying decision in depth. The consolidated comparison table above captures the product differences; in practical terms: -| Requirement | AI assistant | AI agent builder | -| --- | --- | --- | -| Ask questions, draft text, or summarize a file | Best fit | Possible, but often unnecessary | -| Work interactively with a person | Best fit | Supported through deployed interfaces and triggers | -| Run the same multi-step process repeatedly | Limited or product-specific | Best fit | -| Connect several business systems in one process | Limited or dependent on configured tools | Core use case | -| Add branching, retries, approvals, and structured outputs | Usually limited | Core use case | -| Inspect and improve execution behavior | Product-dependent | Core platform requirement | -| Self-host the orchestration layer | Rare for consumer assistants | Available in platforms that support self-hosting | +- **Ask questions, draft text, or summarize a file:** an AI assistant is the best fit; an AI agent builder is possible, but often unnecessary. +- **Work interactively with a person:** an AI assistant is the best fit; a builder can support this through deployed interfaces and triggers. +- **Run the same multi-step process repeatedly:** assistants are limited or product-specific; this is a best fit for a builder. +- **Connect several business systems in one process:** assistants are limited or dependent on configured tools; this is a core builder use case. +- **Add branching, retries, approvals, and structured outputs:** assistants are usually limited; this is a core builder use case. +- **Inspect and improve execution behavior:** assistant support is product-dependent; this is a core platform requirement for a builder. +- **Self-host the orchestration layer:** this is rare for consumer assistants and available in builders that support self-hosting. The distinction is practical: if a person must prompt the system and manually move each result into another tool, it is functioning as an assistant. If the system can receive a trigger, invoke tools, apply logic, request input, and continue, it is functioning as an agentic workflow. @@ -391,6 +529,8 @@ Sim is the better choice when an AI system must automate real work across multip Use Sim when you need to trigger work from an event, route a task through multiple models or tools, add conditions and structured outputs, pause for human input, or deploy a repeatable process for a team. Human in the Loop resumes with submitted form fields; a downstream Condition must evaluate fields such as approval or rejection before routing the run. Guardrails likewise reports passed or failed, and a downstream Condition must route on that result. +Choose an off-the-shelf assistant when a person starts each interaction and the main job is drafting, summarizing, answering questions, or research. Choose Sim when an event or schedule should start a repeatable process, several tools or APIs must run in sequence, the process needs branching or structured outputs, or a person must supply information before execution continues. Teams can combine both approaches: a ready-made assistant can support exploration and drafting while a Sim agent runs the operational process that follows. + [Self-hosted Sim deployments](https://docs.sim.ai/platform/self-hosting) can connect local models through Ollama, vLLM, LM Studio, or LiteLLM without Sim Enterprise. [Workspace BYOK](https://docs.sim.ai/platform/costs#bring-your-own-key-byok) works on every Sim Cloud plan, while organization-level BYOK requires Pro for Teams, Max for Teams, or Enterprise. Sim's core is licensed under Apache 2.0, while code in `apps/sim/ee` is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE); production use of those enterprise features requires an active Sim Enterprise subscription. Local-model support is a self-hosting capability and does not require Sim Enterprise. @@ -399,7 +539,7 @@ Sim's core is licensed under Apache 2.0, while code in `apps/sim/ee` is governed Sim is designed as an open-source AI workspace for building and managing AI agents, while n8n is a workflow automation product with AI capabilities and an integration-oriented ecosystem. Sim is the stronger fit when work is primarily model-driven and the team wants a visual workspace centered on agent behavior; n8n is a strong fit when the process is primarily application automation. -The licensing distinction matters for self-hosting. Sim's core uses the OSI-approved Apache 2.0 license, with enterprise code under the separate Sim Enterprise License. n8n's official [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source-available rather than OSI-approved open source and restricts some commercial uses. +The licensing distinction matters for self-hosting. Sim's core uses the OSI-approved Apache 2.0 license, with enterprise code under the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). n8n's official [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) is source-available rather than OSI-approved open source and restricts some commercial uses. The [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) guide covers the agent-platform category; this article remains focused on end-user AI assistants. @@ -426,9 +566,10 @@ ChatGPT, Claude, Gemini, Microsoft Copilot, Perplexity, Vellum, Lindy, Sim, and - **Gemini:** [Google's assistant](https://gemini.google/subscriptions/) is differentiated by its relationship with supported Google products and account context. - **Microsoft Copilot:** [Microsoft's assistant](https://www.microsoft.com/en-us/microsoft-365-copilot/pricing) is differentiated by its placement in supported Microsoft products and permitted organizational context. - **Perplexity:** [Perplexity](https://www.perplexity.ai/help-center/en/articles/10352155-what-is-perplexity) is a research-focused assistant that foregrounds web sources and citations. +- **Zapier Agents:** [Zapier Agents](https://zapier.com/agents) is a hosted product for assistants that work with business data and take actions across supported SaaS applications. - **Vellum:** [Vellum](https://www.vellum.ai/) combines persistent memory, background routines, and managed or self-hosted deployment options. - **Lindy:** [Lindy](https://docs.lindy.ai/pricing) focuses on hosted email, calendar, meeting, and follow-up assistance. -- **Sim:** Sim is the open-source AI workspace for building, deploying, and managing agents; Sim's core is Apache 2.0, and enterprise code has a separate license. +- **Sim:** Sim is the open-source AI workspace for building, deploying, and managing agents; Sim's core is Apache 2.0, while enterprise code is governed by the separate [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE). - **n8n:** n8n is a source-available workflow automation product under the Sustainable Use License rather than an OSI-approved open-source license. ## Which related AI assistant and agent guides should you read? diff --git a/apps/sim/public/library/top-ai-assistants-2026/cover.jpg b/apps/sim/public/library/top-ai-assistants-2026/cover.jpg index 86c1c564fbc21575639d1a4b78ceff1ce1eb1978..f0f0ade895feb3c2fed5a99ad9c96dec738597c4 100644 GIT binary patch literal 29964 zcmeFYby!?Y)-T#1`1ZbSb2@u>}8+Vrwf`tHw%sVsR zeD}3Uodc(oS@kT~eR7MA=D+q`mgGboE4o<9b*)VoWZHfj1~J4 zM?B|wEi61r9y;FU3IetM;gZ*e66-bK+MX*E+-!_MmZ?3-5V zypl7XuHu!gVxCt{hJFk;1cEu?aAe{jr1u|jiKWmw-olxBX%ieAwch5y7a$Qe1c9e; z{YAodIPEI^1N+T?L=RnVRgQM^KWg%SZ~ngs{Qm<182uAZe+!3rWd7}zULg*Syqcob zziB=&T#9PSll4mi0KnGQrf_OX$(x>u^X`MC#9mtHe{6{z4h1-P&%1xTg(qjY#YzkP zTPys$a7(O1bpU`u49maeNUe&0_iyEZ{@*2asK5VQOYl-)?G!)#e*CNAuTK<0QU;@fg|WoMu1tdLpFsZCRGthGy>PqS<1>ilxJA z9}56X#lZ=Eqkw_oayr9}y!gh!Aq(^Ja8*o?3ULwu=5o?|joF1yr&9f~O%PI`yh(J8 z?Q-uiI6Tw!_1-o~TFcp;N#TAv!)UgP!_!~NGFX%7yL?)i7B*Z_9k@ksIzwb$ge`54+RwtF3nRtjW0MMn z7nkGC)nfMxqYG=?O_)!YI=!m#k8+AU*>u*uky7S4EDsXKHh(PLUk4sL#I~4K21$h~ zH`prkwA?vC68du*0^ys{RMT10K05Hc(v?kK$Ya54s(UK#C*-N)j>@qN(hQfHs`Qum zGz55caB5WcgSRQcLi;8nKc9nH=$&C`WrZVs69+P{jWU39B$MHuoh^N?cWi$}wRN21 zn~wI^V-&-Twqc;gld{K66o$o#W)=+b#bBbxmYJ$Y|3s3gU`&px(-F{}4 z6BB`#%iFk6olOac0Om$EwuSd*ItJ|o!mRWtjHxyiw$0@a}eby>ngspMoubFhtM~O)m6=OlX{R$dVLNN> zEM=KuP|;1$JM%e$9@U&k94zz2Fq}*iW}50x&bVe0h!M>v?t&N!7^`;ITiF#k#5{>* zwyn3x;fmIS$({k=BT+#qr&EXDb^EK(X7jzz1kD3|wJQ_;TPDxRMfp2o~CrV=M z<`<$>rN40Ql)w-dU7(0Gm3|i7l6|2>c*i%_arMK);2H1<-zyp`K8JPYJoI0%A$?cW zj6B7N8G`xl?q7iUm{r8VN5p=}%F259*k^HdtI#t<-aFYTFRR5RuIcVMP|{Ds_Nhs#mZ6&XnKmIxjx4d^24c&kx0z?9<7+YF&j4FF z;4kqa;ruNK^`D``u^h2g5zC^M^9;a9EHmGDbs;Okn%G|9D)gp#clEN9KE+iEDBH@k zKUqIf@$M1%W7W7W)Ufye0gv3#f%b5Stw&C#<>8)JSjeeg&1oHr=V`5v1cE3^W>0)- z%n0=!jfV-$>ws!F5VOCKHox z=!#9+cL<$Twl=|4Mz@g#M(Ye0~ zf=(t;zij+pw&}k~*Y|qfXZ`z}|4+=iaYHB`;0V)>^&3cw;$99><*HV&)aOkFM%-|P zGd0)Sv&Mr6uN{bB1^Zm-MCgiEDzvJ|2M8H-b-jppyE^n|Y%K9S>jS`6Hv~>Nd-@(A zv;sd?2@3XngFH=2{EaEx*xXE)|IQ@!VrKT<){NIZV%z<>jEU>yi*T@!x4pLdL-nVVH5vOJaxH!$`Nho>}!t7pP&*&)V$c(-NzwvwR$D&EI#wbZ#E zuSAIaw1Cag_e<6x%&WQ|p8<+bsNB~?h#20{Lh8Qx!|yd956nX}pNMWl=@ zwKJ<=&Mw8TdA+NU(>)PO&B07im4IIgyzeG?aJCMH!#sQ+bP#!YNXA+G(3${7!oP$r z|Dpfizp+=DeIJ+Z*9L;LowfVDLevEcs9_76?cY;Pi9AlrnGq2s*WHG;#udAGU@+Cx zWNc-Wg8<2&07Q=-Hun2X&E#C>^A=GQY8{c!el(!31a(HB3@o` zB@aK5Mke9f{EpwEqf1yL?{-R2^8`@Q%30fH&gfo$m=kye7(NUcO%*Vx8;aCD1H|RU ze>-_aO3e!JYJG75S$;br3l8|4CMV(AL2@3e4WFMS^~g{bY(SfQ?iP$~WEkGXoAMP6B==2JD9U+XP} zglQ|O$gzQ+*FpPFaiZV;6$MxU+HS!$SCo%`PNb4SlEegm%cAQ+Q8s0b#^lM8(#gfe zt#Kt^EtWs0jxj2F28q9TA?Jk8Xniec(Z_NOEd)I;Y=TsnV>SKHuzv8G!u~k-@!x;~Uc&h?MTm7;Qzu(5O2~N2e(VJF5vEF8t@b(j4Tl~h) zNn-gNM}L8LAS^EK@6s)I!d3%otYS=)>A`Nd1-GD|$W7xA`mZ|w+iUD)_WM%B$1voq zzpBycFVxiu0x5bJ^8fAq|7V4mnfbZ>;AcS3$f{;hagg8BDSznyOcADZRd-*eCZ|S9 z=6|mIZzZpyO?31H>5u1@ex=uDy#W79@y@`sQ>+^$j4&~5oe&q;j38w6w*7lUGR9O} zrmvzcqsaT8vU@O>%7hw?-E1G>GEEpO%ZT=+%3}$SBhpee4-Q{XZ6C{;S&xOP_0X9R zz7JbVBh_w1%wA=R6AW3rtV%F3Nj5%I_%~wzt7I?ZD>8XJ(!{_z0szpxTKD0i%@wHA zkrtwn(;%D_U81X)=)j-&LO;9x#Kd@@1Pl*ZA-QE?TvS-tbDs)4@y2m+K4Nz!t%*4; z-_F4VGm;L(rxCKzcuAr(*|KKf1=EANN;(a#idv)>+9&6DMh<9N*V= zJCBINdwiJSzhucj75aLcuC$LpNqpqs7V*C&I}iPU;K)EtllAZWUjUYkLfy$Vn9033 zJ8kcLaSY!Td!Lexze#^{R(#&Awu*O;d?e;{Bz=s}_Yv=UR>_#QG|OKpOL47PedQY- zL*79_u-y+vZfnxpTzs{?E4jqc`0SREwzfLQqI~bRv*_rF9P$b2V^c9Ex@E=(3e9>J zMx%8M&$KUM67N+v>D2szJ_XeO3huwV*4DmNn57?>J;0PMw$R34I;rn#M?Vn%iKkUw zlj}1gL``LEhZ)07?w3J!GHrD1T0NqAiubZsea=nZ?@Sge(9{E}8S^&Iy!C4?Zf%(8 z6nr>5yz4OhB4+FMur#HagDgq#b1+1t%BZEff-%rHEht$-nXzxC2wa&lT2`g4tMD!JaK^5Leo$(|w@s;~CS^OU z{PJK<5D&E^r3K7OI$f%}u)0U5q6LN-*`Hk+4bk>A1)503~73->}E9tIW;0f30b_X-;am*6!XJ`X=3w}crLH76IZ zIt{H`sO0BwFOf{7m-r?O65tuIT7ka2AhoN0$d+j0 zY9M;hGquK09?=7nxrJ6e4VMO#^F5S4QUaE~-k(e*?253Z6%qMJ;W6XUA&}e!KBHUH z8NPM=@F-*pMMPYC1JbXwpWL-H6Fvt&wiQ#H6Pf2t;b+uoYRoiJ#qh>=5O%qL&8*am zB*~0AffoNLKE1Ju)1kZnhz=^$T&iDau_O&v?`6(n{p$k{TbTdv6{n4J!m4<;p-uC7z3cjUpu+c&OVASq6ANQqsqYU zwJS#b0bD8KaU?2DtgtFC+XysWmK zFlko5rzBKLM#$Pohrl&8u)P{I(~z)Bp(`nZD{#9%THmjR)BWl(DSF(|AU>BjH|QBq z5}EmjhqDITw|JrtKUY4l?tysN!A7yp-nZtc%d>s#aFJ07H)8GfnUgE?tMWg0b{fPl zfH}Frj=|)QX}9P9jVLIWHaD=X|Wn5@T}>xOfljVfpsAu+kB>HK#~ad?;lsry+ieh zXid6WCQVmuYtFf{9>E+}kzJr$8fCO;Rw}3Mr}vuhVe7~m10CAf;sp(+qw+`_m?sah zU)EIkhk&s+&W11zE(nO%j|1vUD)t&wNe|VhiC=0HXBTgY#-A1kCb1;wr~b}L z;)0-`jOgMiyiY=i&*nZZnCmp5DnMMCszB!2DprKBwbJ&QUliPvJ1k52LSm35eURf9 zd0I7e*S%A>{>_uDR~~iS@W~{;h>W#ttJYp^6e2bCV0ctlO@~)Q_tRI(=@nKnvBOUo zd=r+L`*YbV%v-3c0n)#(MeLseF1i7?CzxbCx{x^b-W{eD)Gq1z>lKl*aat3|@7&ScZWCX;sCx{*p1zfA-G){284!NCv^Y|>bpKB+EWp&v6{jbbCI>z0 z#X*#w0}(>EYRF-qJElAuAG@uSMI9wjEKsrxk0l9JNEvzOQB*C%4U|DeJtCq93*C$7- znp%P}y%AD&7ugbg+uT{p#zv2?>nz@m{)2qP^0lvga4G}(X6lcy;{(<^E+(UCgvApT zsT(dId>$~j$vt?CEA4ncVAAEQR@bu119KZw!;;I&%0SuPk@v^Gvrntlw=O!A2r9HH z<0xIj@E`J87k}gTB{m6C;>9MUeBu5EqhyPK3$PrKBovMdy9l%poOoA}6S}Ie(~4&t z;_`m_6z^+uGeNp6b%09h!u*8%rD6Tbf?%gHK_A_!L+p62UsjM+Tyd_Ckv8>K1O{Lx zQFdU%+Tym>OZy$T8pg|3Zx`tG*brdLYvFTG8;`O9x0k4TK|y=fh>fe5sk>Gv&QBie zuaFRJ?rv>&?XI^d2#$AV-}J@drlHEDri!=o@XgdQrsgW1h<6RT*#&+I#2*3gX=`aa z6n<%L#uAy1w#?GH;(zL|^2pxP6~s3vE5D?*0o``yS_jt4qq(LkbxifF-#O;qj_d`d zlV@CC$RkU)$yqM3eS-L9>KN4#U(&iaw~j52dj!+QmM@b7fg-G&SFCjUN+a#W zU^e*{bR8dl(P_RV54bI-Tw>3LW#k;bep>SH)=is=@-54O6{LAWay!Tq)5`AS$r z=|~K!1~8+g+=xpYP?&J5s(kDV%`k)7B8t`2GOia`@|^`feYYk#*CDHW^B}F$WbYO? z=sh!i;?iThmIF(Un?F>a$lzrD8Nl6)fiV8m)ytTQHm=dTkJro6a)x;|cUk(_YJ9#{ zDaO8L=ENoDR~W%5V@4Ir8c-E7pv8JNj@UaOsC}^$BKpL6vK$32i`Fk%=!GNmBhf}1 z*NVT@ijCN<>BTxT$Mg7B#}EQ>A%ZkO59uKata5R%lyj(QX){ zT5P~Jk>jC-dJWkXVPEdKd}jKFq*>m`brdJH^QPq+T4FJ8>m;*9X;BgV_cU2De{vqZ zsK*Z~Z?|&I^b1IiDn_uOFIH_k%~2X+;r`G$)RNw>iH3HM?4AtmmybU3xx-|rs__zCJqrx&~u)gPx z!)f_=r=*J4gBsPZlnNV(WTIvLB7%`i#ga&kRL!)0L=2)maE}UgMFS7E3Dlj|YyeVX z?R(a=vetI7;prr4ar^Py28IeJraC{`lHAHvY@NQiTVJbeqB^gGv=uyyhLQT#HKR8U z3|x|DP}DDNZM#fn<4&qd@w??ska`BQT23?L6?J(LOj8K5|FQlYG%%WO=!@e$e49oG z493)IWUTFix#gG{3(@eePH7xZ(EXWJ6IqOAK-qZcjsi9I8-<-yb~e zZcz23jGwB8#>02LZQ1M@V1gNZWqzp>i{EP6reuWGBYPLF6HJBwabgBU?G2K7j|;;U zCt{5WgKcXUl3N-lel~L9@?gG)#ECW{n(@<83C%Q0)^;0P-^=kTNsP@yzxF4ZIeJ}6SWcE$LmI2oPKJqdU<`4f?y1gowQEw%>4&wt zF{6J>KaO&$cFbX~IJOkc`4Tt${+xF&&CjNxL)~cq)d$+x_&OK9KXNaR#~`($Bir>@ zDpb{hg3yaa$d3PseeCbE1Gk5FgK=vwW0MQvFjiHV zp)ETBZEC%K$c<}`gP0FxbNr6P5#3eLLY0T!$$VY~)c-73;~JD_UeAp+ny5VW@Z`nr zx>Io?lf$FS^#%BTcUyletjcp$@XW$Dj~1xhZ&iHUK<85tbNbtdy37_<*4+b17Fi8r zyG_UDdSUj}ukQh|^qe1GXCUQD*=G8QF0)1UEew%f{nKM;wt9iE+k>H9s`iJwrO@ak*#puE^! zZH#ZQEU3v+_8?#HeATgkAPdxI!B)?C_qp>rxeBpOMw{UHGgyig?KEH~fZ|)J#s{p} zfA|=k0s+WyU@L5!=F%RwX)@o26qs8-0`l=1;rPkmjy%#uLuK0oSK-K&R1rIJ&I$gm z7S{U6D(wK0eNlV^ZFE6Ug`fesR>!gEd6O|^>g|4;=2%H}&hLx=CCb|4L^ocuN-xGm z)Ud`XE53t-`AdIO=Q16Z(G<)20I}JFI@2~O>a9cUj=H3{;Q}9=FT(06GxiiRgQ>ag80`_=ecFlBG+1;^R(Gu2 zqwQg^aQYIFhspF7r1%XPot?i>qnMjmR|TSvm)1b7DfPj_*6~QisVXtfxswBo{tHXT z1H3#kA~Z{hD7W?e6}xNf2=$D5vXcD-4J+)TSh$Fd5b1gr6o{Tdoj%ek+L& z4?xd6tiecZBfRSLD!X3|UKfsoD^44~HE0~StkDUsd0C5sk5yz&80t0TDX`5zNIZ7) zGxK0-BXt(UUiLR`J@Vl#bQA&O3d@6?z`9R?j_ZS>7bdse@O>jg zX7i1cNqyVM1YxzW1XAk8gKvIg@AtUsez% zH6@{MBNH#1K2bH2kd>76@n5~;jAq1{XCb2waA84N*CCmVgAm>r%$RauUfoeWZ6dMG zjhKMoCs`dZvzWfs`Q@KZbjYk6=GEcH?MCJtg=2V+wWrY6 z7dvk$F6_4|wp#aM+9KRLv{Vl2Cn$tPey2hq4jAl;7}c4?@(I%_k9D(ef1U?Bl2rDO zW0yV%Of*soe_9!I&-YBc?>y0It@JvcoKLC*W{4E49X|SqQdS=nU9YOh8@t{R6jsv7 zqo4BEq7u-cX33N!XzLS1D)-C!@@ZdJ=|m2VgHQ0z*_{az zWwkANNg`?jLi5Y3wA@y+B!On&ZQ7~w^^Vg>nUXl53A%NVqjsU24n)BoCsBr^5ouOd zM1)k+(DKP>nT|DPeZWrZ%;YxgCA3ktBTqCwBaDGSYF!ITZ~r{Vli)Xe*v;;6QTB%9T}(O}U)~^$o1-UvZ(E>_H^)+S-<$B;h8va8L$K?o|@M}jYa`EpWKzVhw&7dIaH^$oloNOdVzC8PCYlCy1`NV8Ak8Sp5$;;ikZ z>ZzL70Jpk#=k2<1*foLpo2=RqJv0g>I!SW8X;@!veK_h2u*m$I$7dznbRRC5K%OrW zLm8oGIC6jDysaRr1b-QA1pJ=<;a$vn!OeTnuC(K6cSFoLZW1~klChxN#rL|ADdW!7 zmjFD>g9INq4;NUsU=%_t_84h(kM@ zocM73knLJ-4B3SXsG>6H+0JCzv8sLXR-O)a&HGe?sjcz5Cc-qH0UOQ_ydMPT^EbB% z3@YTP>-%)>hDKOs>=otfk+(mB;B?sq^9H=Fr_j(;y9z@L+^b5zh8fbj zN&RA&cr@%tc1xqT7JPPgNxOr?^1GfU;ZihF9=!2;(^gizy@8`f{}E%dYf}8DkB_tl z>VcBAgLA_Pmp;-*?BC|e7O&5*>T|yz+b4Y2(T{PWK_UI6do{6AA){j9mlI?VzY}t} z<6ihv_8H*sr+_7!Tt78iR*RbD0zutO?IW8y!%rI5r*~p#8(4)VW9`+lx>JG6 zG4gG`@jJKuL`3H5-H&iAtk0Gm?}R!zQ=%deSSp0uF)_lGgq4lGfnkmG-7}gWRShf@ z*Qjc7{`{r)JPS_$&BGu(`a4O_6545C$Xg?u$}iFIR1=CkIBf+!VQTH3M;F2-tR{UNtcsl5wl zJUS=WU(*4p*!7} zb#D#QzTVr!Mn7dj;QII?DOc8JyqH367yyDpP6C%9y(`3z2jzj0y0SEb1fM@hA}T zEAA6~zR$l#-`8}mMyYL8t%bUXhfL`!wGzzo%% ztSH=yPAR4Nq<%MC>c^`e6{$(pV@S8x=cOq4I<8QN_620&13QdTKu0saY-!@tfH@<5;p%F_U^`BzO*itQhnVTV_~zW zHgjOqwz)pwtBL122Z7ER!F7$RmrB_pOl`sszbz7ChRh9LcArjW=;WI;meSZnYAsK% zhoC8T2eBjz#4}Jr$d_7dO!zw8?{~i!?34=cn#Kq2?FKOMo%{;nw~qJd9`i@Q#-2tF z)3m(!RX46&v_7D77CQY3$32Y(>Jr*$s=~SW()#dbcZdOZHQ2|!0RBkrb^qG_kFsJ{ zfRo#_V~>Pj6XMcHeOjDH`mTqa54T*vt9nZ)3(<|SXMhWnf4^GS-#hS)N9I85`I1XX zo8ZOJ>-6%X_kebBk0Qr-9>`BQmJf^?u(^^$g)N!s`>IR3R?~y50j4WAM$|9sz0TnR z!T?k$53)#uZ&q8)T^Wg?x2w#0sfHk$#8wf>_Vp%=yXCw2jvhH;LA67GfcuPt!0@0g zgt^-m(J2UHHnW4i?{I~S`zGKws1`a;sww{2NMjg2VH6*|!iB}0fwduVLZm0tEgrr{ z*2|8g{*4>-F3?4NK@n(7l;~=V{boi8&lem8s(_e?Z6452MQG5)6G=|d20Huo-vn;J zmf0XGz${GRK)e8X418S}m8m4%tvl+;I*_eSqv;=)kgxn9B3m6* zG{kPg0dK~C16Q9hvf|&UyiT-l(lPPg7E^?W7ririp?MH*YGN)>2Y*LD=5;ZMaZ8eD z=NVAG)Kta{?2VPgP2)|P)YLHUW&+IPQ_n_C4^B)Ko>Mou@F^xbpj_X$T+uey3xMvv zhQdZ>ReU=#yBxQ7vTkJ&q(K+-7zF&90={?Lv^=!}Qz$wLW4{h6#fJQif1?-T#p`=mCdU@9|zpD!2x`56o!fd6ZMHo zbM+mSN%HKFh6j2ZKB+Ha2+CqP{lHSO1~%=|!p^9_QpEHOG>$6O%H{EZKr_~CUqM_m zl1#HU3!<&tDjY)O|6wvVwsaSexouLVZ3@Do2Uex!^98zu#XcI~hu=3oc3BVF&Nj-_ ze#n9~Afih=oif*5PU3)g!YF?UtQ@CUD@>saTp#aTYf3dS608bsoZISo{8X^)4m~|P zhlKf3d!xh84&Ab>A5_Po4EuiipCDY{(r429IZhNYs*vE(fHhrKY19fmWsZg9c>fc_ z_P=A8NE87XrOly_55A7Ao-B1>jy5;2)<+Svz-5&Arp%=PEd=SjJj=@Um5Wd;U1}Px zoZpU2#!umCMQVt@c~8Lo=22Qcx1gxoxk4pvgeZ!J896_72)RP#wPL7{|I8Mve@=Y; z0zC`eYhR+8EQjSnZU)`O{YZ@}r-gU>gDu6uW?n8Y0dEhm1kuq$4C1JM75RslV-k^T zbPD+Z71r2zk!9x8>IExj+2Z+EGDg~|n}$+}sA&&xQaFe4I0E(pc7vl0j^a|gZ{S5F z(CKo!8b==2haf$~PiMD9(toz1+nex^Dpuu0fxg>8`ij8h{*I(xUKzPz^F)O$%i<*G zF6&mbP@Bd{4})dqi`8sIZyayov3+tQjEPz_aQnL2Q3$fVDb1L zGqRmK?Fpmmab+Q^G^$*{{JNTDC{xfPqwKDI z+Qa`adLUt{X!Lc41&oxKuQi3ed79qWdnRyww+Ljm_6K zUr|Q#>h#5o$4`o-)Cl{m@(Tjz!Iu|SP97U|E~;4rbS98ynV}-Y@lRH9j?gSYi z)I#gIqXDZDptq;%$orzXGUykFp$WgSSDHF)0uJxC@*UEF$wG8XO8qDrup7^G0bvfd=I!KfLz(Uyk?e~{L{$o$8*RY~?pFyIF@=e=mm0AzC z>v5PQuatRmAi93>L7^CqnR||ryxqc2L?7ZfT9IY6wPfb5?Oz9a^ArD>C%rJt61#k{ z0Xy#m6O)w>quCuZcW-f<^z{bh2DoxZg}R+T`u44!49+T4Fx1EB@~@qyO7xLoZxcB! z{UR^LhSl2stfdn0vcA)@mS=6H@%@5jk5-Og+yuR`7+-FabT%@u_5b1>^aOL?;VHAD za0t9?a>S^;5MijhY#sC6C!hw$N*)kIh3}VxhoCfdwc~%Ah6gi^2QCj0gUmvWCQ5i7 za(5+5UKZfuz@5#7R|rqMw>o`|`uq+gtM!xk33*4A5)orey;)Ou_lGEfKM}>` z)N(^hM{*20vui+S!j%tYsMFz*8J=KNeRt^ ze5R|v2$9N{<3%CaB8HO3nkZV^0fzQ~NsvLGX8=}%Ls<5wTtU>jGM0paLd!n2TpOpZs(35$cks4~3NegZ52=+-!S=mg8K{;3}P{jGq zJjmy(uBmmbZ-ghdoH9?BxECz0gtZI5EqltQ|mmdMW(kL&rb{ zs0&9BOWwjZ9zC}H=>+oJLRS8=CLwLb8qd#96vADw)6ARi-lUs@kf3-XIGp=ZhYAeR zyF_iUu{pkuU@&2&VnpyYn%Oz&8U8* zmUe5sYVy!x7WRvedA|-M>nhjt|;QUNn5!e27ir zc0Ij4lgsQF4>?<_IeTCi7JCM~qjx&9Q@C2#F%Hcf|ZWhXEE;Sr9*74w>Mg=Ich=*e6qERu)OKw%QqjM0Avn%t_x3# z>+Gu&#|2l$^9QY_VrGZO5*GZ(QW)M9)*UmEsRENa`>oYF?S{=>vdL;b4<=I~-{9gj z&wgzz&yIC(X8NJhOQU3GI5=+DTQEAmM~aAGq&jd|7^8+lbU)-{JspsVFp6M(TWwqI ztqIsft7^JS(H_`XYYdn|-(IriLn&`V+{gD_C2U#m19H`WaH?k?VVr1QFAZ43H@rb@ zZtu%?(WUC>boSaLkZ4&ja@Am2!`I5f-vxuWyxXUZK$%Yyd^4~N9xTO6u5>21()H`# z%BsrN=O?ZBV*_X{O~zI(SRLpP3VyxNLxo0ER=~b{yLNu?u2RTrEvJB9&c(#JK&y31 z#$>}^GX=l!TnV>2O*)kM$f0$XA8Tf90^wsoln1O)~fmg)N|@r zg+ZYVgnVkvCn1i`kV?E)U|ZW>nvq(%pog4?KHE{*msvUkjpm^}y_>VB^v3_9qgqRV zT)dcsO``>sc7mttk9-E?y5i5JdUqUCWtMCLg><(SY4mP*)klP^!nJ2PrgKFykt0&R zk8$B5i_l7&7-Ax6Ksjd8GTTs7fZ`$ow)y9*2z`aGN3N~*NjbM6bEV&=oJ%4eu23+}u>KK~4?g|}B+7Xx9xPxnt~ zHf^i)264aS9aQO5IXd-ae0q`Gp@P+69Go}I4juy~Sun#WMs;sdlfs!-qrKm+6^WtV zV+k+@WZbC+pZuZl!gFBg7yWW~GRs=iSaaP~~C2^@^*d4Z-VaYC|bwW`e=8Ro+y_je}%Mn?7at#wF(iqZAx6!1q7pn?O{kqO}5E`Q&NM+!Pvosj6 zhMw0qaHcFUXE{6KX%x}`Nmf??vCA6Stp(|jd1c{t2|hz4DgoZ(sP3xR=F2uMS))KJ z$;`j>MbdGA3xGgou<7fGgBD%&j?Tz3pr>|_Wu;R{994a7bX&}P7JA65mwtD#&vT2M zcvvXd%1g+0%^?uu&HaF(^s{6~!h)^9^~TAaRyQ^7En%gP_S+2CT3t;(tPeiziu3AH zE>$rXS9jC?%a_Z9iF<;O}$b7X`lls{&Ff#4a+9p(v7>Xz{waV3buvFhhQlYcScW!Sg|MoIydcPbf>}t# z6rm3SQdZ3L@-i+!l<;|p%Ki*)e~CtVO#eF9)mYFL(naX(t89L+3ZGMg!h5#UfDB{_ zi~P%wJ@oSwzzO11QUD^0+BWTT58IHovI2uA`kB4!`U8lnO>GbZ$IcH1m?Ij1d zU20oFqXR)tla{aEAFof|lpXN}o;FTYwJ?i->r%_^P-9VHoFdGr_tvK7dc^c$SI&EV zuGEd$HtSW}H&I0c5qWsq?{pC7Q<5f;rp4@U9BXnYcRu~qnUnA`y|g?|Lalt5fm2rR zMp3^4-`Yb2df8!NbZ9x%u+xvPd8G| z1Cx`jjd2qCUe|{6(n$WXOMk}azw6%ZH`W<+dV`D!w2H61hy8DS@h<{Dd8{MRE+rB5 zYR>nn!&A$4?bnQ${AfWgp-HAb&(c0f^wDx_O3P^Y}-682LO&wul-&tqJl;czVGvA-1IKhRY z;rB?;zC4Y`8bxbv2-iqGT>I=qD9uHT?Jb{MerclIsg^fTe)H&h_VFe4%8-q_c+d0m zIP@__J22u<7o}^A()N2JaNvUxzLSYU_VU!is=(8*aUM>*Hg+3&Pt7HqB!K|m0R}Iu z)X=NVIJn7NzxpiId)aXMs~~XT_XYgCNZ;tysBBZs->393d69mKx@cDH+3e z3C_H3N&zLv%jiAAt%cq}LalfsBcaSxdg0s45p8=fsX5+(27fWl~za&jxn2}MUye@iRJ*qg{kxSLJoheF@Rzca* zZIwEG;%_B$Ge;IXk5YKL8S=65SJM_`UbMj7d+@oMB` z0QENe#cc9o)T%wSphgih8y!Aw0Bh9-)Q* z%xgs!fANpbwW9G*`lt^PSrHfLKeL8`6c7!R+MfaD`qCo(s)#i0e= zkt1k=s#7PY41uQUTsF6CYOhbY8V!n)(SEAdE%Oi>Z0i|EPY1fI7JTG#LS{cW;u@Qa zx%h&TZ=v`G#)!#g5G>(fl^{6O9r@MRa`#IgOYWE17t-1l zol)J6;N!++x<>vESY3G16&Rm_q|qSagTYiD8N6$dJylz^#{FW_e6oqEfUm5oQH(O?hyp`R(^Vw(?X~{5 zG4muX3p?V5SgTFR^)D@4qh3RR5)PKlgns^8^b8nimVDB6 zc@kE(%~Oz5RVvCNna%XeYs`oP+%%I+Sa5oC964ocW-rqe{1_>26aacX5L8CR${}1s zDDB$<)cuSg2-a}>4HFuC%&1rfRs552;^Cmh@fXg7P=_69rVpir?M4{iUB=nrR*LSY7|EEIlYyhhcZ#wjLqX zcREV957A6;qagQ9_o&MIor{W~yk8Z{#0q^_?ys&{j2L_QY}I8A&1@I|@E}F;l8A}n zein=4zU=^_zt~rSG0!=a?NNnmwbi+3b0knUD@=c8wlFU~(pTVJqF<0Q-mmsmzgDCe zG;q?8!6+?zDwt0Cx>#)T447)->b_5YGJ5jCA;OX*MXKD5B{()Ok4$KDy=;FneIOlm zR(JyyRxRnMOB`F(zxFVl>)@(R!;B4%oKak5KOFv*CMp8kru8S8{3rI>7-h>#b;he$ z6J{zRKVCZ7xt1LBkAu$uy!zLdL7lSoND~JMx}8;SLq0SS=Rc}ysa9v0=c|b^cHP$2 z<>gebOcDZK{&wZ35YAA&oElHN@J3%-f$Xg6oRGg!#4~7o*Cp$>%QGPDY2ZgXg1RDu z8UBMlGTgh}SGAgU@oZ~Z-f+4T$q^dTaJ3*OnuZ#JAg9HVhJMQGt$^bV8_QN}%{@ai z>DFTH3M(A)q2sl6*+c@%3`S%=%TO$Lc)9!!y9O37Prf^@T_LTj_2oZAX>)qcV{;{| zu^>#V)IV55^WImw8=_J5x#L3-0e98dh z;V6{Ibs$(i#(4_jmgucK>(ptGY!bwv#RS{`Oz1fF{;=r7l%?e!y9S;b#vWlD*1oyu zoUx9w#rsGNwnhZb1N)G8e3x8AfCOh1Dl1Zg%*R#M_C4bwc>}muEnx>Rv0~ z$j7}`_DSY7&>;!oMb4nYc%+)}+#&}txudRui~EpMSEky;8Q+5A{Y4IDpJ_E9lUw}E zo9Pp)xjt@AYvHKMl2|U%_~}!DA)Ggh1eU4`j$+?RU%AZ6Bzn-FSVak@Z$~z&F!$OE zC4b^p9Us_<7Alg=YE-q1+QYK~Klp4o*wYaAbFkUDdzAHWGwiqb&%S|`EaTb-WeoAR z)FFI&WBgiS%0fk0zw!Ye17jTmq|F{~TRJW}#4V0ovezNi>HbMMUuxaIJ9LebkcGaR z^~DWQf~(v(66;qq{U&YWn|_*A!Ev2Z^E#OBR~9kR@K&D=dm8@7OW5UwE@)hP)+`#25pHPx{}?m--v#$iHrk)&mA-U_m+ zaypWPPbNFNBgf+O%F(1MY7BiY78qQP6fNbucX4oRU?pk%**-9P6j5e_NY!nw@-0CA#3`%jEFx`^_R(niFT*Xa6YF_qiG7!F&6<`j5Q%jb0(2Zmq2bVD^{R7oof%ZH;#48OIp0hWH9JMH!dREY4 z&zU4Sg%w1+jr4}kvHY*Z9F10^F!4UjPaeo1CO`xMh_rKCTu(c>PSr404Oz08~hi4x}(B zz(jmXlmJ+$Sp6W!C?-iw!^13PD9aolEg!^Zuv=*6=a#-tJ5%n|J^$zA&ccJRM_%PP zOaG6)zA`Mz_S<*pkj|mILk1Zsr34&M8b(qYX@&*`Dd{1GZh;|&lu}8hOF&|f&Y?r3 z6hZKezVH9+y|1&c^I^Ws^<4K__p_c=zqRglGa|YLmyK6NM&rQRi+3k6eA!I{C|~es z?OlxRxtaU|!35^@8=LVjj#8&ow*Y;5+G|K%4U%nMc#!dw=NB6o4Nln8GCETcNoiq= zl7!e9)jUFAj&0xEw*;cN7)lZA*g>)()KmZYuLmFbxSJQ`54hsrG=T+$=P`(&8M@D; z;?2p<&-N9eN058mt>#a;yuIjUW?1H8aic_g0dk{+1vVgeJwu`@^`aEoF%6Pq%)^An z`c6$ki^oC*EK=?yY0eHjHbzC5^>`{nyhN~5V89nEpHQIVl6(5&dXaQeV&HVG21SH; zydcv|E}ebMN|Z`2woM}S2Sw3i8Y#dK7ci2jJtt-I@Z)3B?2TCPVA8hWGI2Ae7eab2 zn(1SABn?Qe=qe3rBRnmn2L?9VJ}J(op?|dYd;P z={KO*Y2>?gb`Oz`vF>=J&Nd&9{`FS=ta{i3*}g`t^*sl({|=N9(GQ?9<$)O|wNlKe0v;e1F@6eT0^ z5h)P(2GVh3X3hH>ko~m(^QPB+^0D4!RrMmsD@X5gzuJ5;Ud84^tL1;Z;Lxz(CqG1T zS>IATXxVt($@7sU_5h5$vl;dThj34TT*5QDNYCZF9FApOw;VP06p$e~->nMIk<4;^ zD#se3WIIyHGsK;}8>LNVi?hs1$F1r1vQ*As$hcJ{5EeacZi%xRr1K?vaK7tPROG%%p7vSFWW@(7rfM>M=DH-JET~ z$8tqH`hd{GYjPBnA@Eqm@r|JMS?!kEvir`^vb!$6BhJ3OPSYZ|M_A^5`GdnYd(;KP z*XIEQKB%jsQy79$5A7b-qaG%M-oz!G6nSOcyHV{!*-}@?WNhUZPOehl-y;0;KE3?t z)%tOK8sikT(Bd6wGPS_^d0w>xDCd(B1=`D%GVm1VaeOByT7>Rc$j=Zcg8B@-$|?{c z;odXIs*eA(c<%!|A8q6YX}0o0IFB4le_{I0_I`eD{N>n#vZi({5^|ey{ykO~z`Kke zKe_6UWN}!W=Qhi6*Yg82<;JGWSs43WB5W>3Re5M2p9?9teBMrM_HnmZKy>fF}+$m)_i~F#(P@l zQOH>@=>@jrKYXXP8`17C(H!TO=(-iRbigHW{WZxn zq~4S=t7jzjtMqdX`N$&i7{(^81t5(dQo@yOb$o*p)YU#YblemgtW!GNNyUzTH>u8(>I~ zDcb|>eXxTwklw1()tIaOPAZ)mike1mRpZcGsmf#c6jW-yM%xVIQ3iCUk?_c{f+L!! zRg*qfN}IM!NdGIj;ZvK0yek6Mrw^amei`5pYw>$Fy{Ay`_n(-AuRg&nK51G1r@XEs$MZF4MbZ5Lxhav7P^|e&q=Rs-6OT|o^{L}|@5HOA!Vcii`4iVdJ995O|OO=pjd78`l2Rtp5gFd^|8~)%zCTvb#z@i99+1O7tc? zWV6+y?;20>wrV9mG8cAQyF=2#skG1DBMC~7ky5UBEG@P8MQQ zL;frM%*IF2K%_)qca0~6qI0om{tmrM9H;CE&UaFL6C2)I**5P4(~`bL;od-q#-7w= zGNA8a-a=@xZDU0r7tz+js4Q7)Mo`!Jo*jbXF&r3F=@UyhVBIM1z$Fug${DD!sF}+Djz)e!&B;@*qp}qfDZv51A+8r6>31 zZ0!^#9uOtW0evgFbBVWqVU_i+rTl;CE%_^WyZOt;y%t~fcKzq0)jvgExh~qhtlEDg zCq#b*o70vlgF{&M5Q$Qz@^>Hqy!RSbIBDX!fv7R2*s&C2-IF1+)=w%B4gq68_ zN-SnfGu%SQdvk262nwWJ33=-PR)Sg8!-3Rzr*2zW2~@Z`uLlfxW4!FFdNvJ89Pd1{ zD*kv(VE7;-*`f(FnNMj{7(HWkalAQ(qBbon zeG@0L%8m50ZA0nSRU4!Gl@ZUDzLxS~XHd**y&R^~d1Y(VL;!e!QuN}qOOzX# zRP%evDEV(?!rAwJg7!2G#L^cSv?~2;Oc6iIWMmEvQ~`JROHEAdX&}R%Mx9gyJ%rRw z)Xgno-+!|GYxP^r0vK-61%jVj*4@0{O-f53$dxp<`YJ70Lc;1yZ|I7<%78|iZt&q9 zqH<*ER)>3VZztr>u2v#x9Pym(yPklk6y4O>Wf|4dCZ)cc_XzeS2U~kL+V)C;u6*~e{GdKBu&SnVRwk;LevzvHtvFY15j5q?Ynk{Y{q#zR)|71 zmT)*xN;6+6NTJnP3dWrzG){CrV*ve+o#A?`(r$bCIsH>a`c&$d;Fp^Fk_h)$mey_2}!+MO8%fI}OSgM&bC@srbxToPqxnYoQ(Fi#+icp+nHQNVtL(`dzkM zb|(j?@F3#I3S>J!V2QAXw~WptFP~Q&YM_}QKWpxnJuQO~{RSipe_~bIQu^{j<4aIH zDUocGU3t_iEK-9aZ4-|N`Pn=~bge9<9vj8*Rwc$ewOPmX@CyuwLQwOXQgN#<4CDT? zR7uNf|A~Im;Y+ZM^JxEZz>e1(Z1NoP59`SBSkQ#|BkS1SeFe3ccI>|34HR3o6kkfH z;VJCjPmtKOh)^Fguw>45J*P--0(adnUVQKR(3aLWQHW9o&aw=iGUeWTLexucDyB7E zf;_0AA>z=g_xthwzjjrlfo6mJj7ymkFp$C9ATZ&H55!}*U$QSf+qCH&FAc<5^#lr>W{P zXyIDkRU$VNnIS2aU8mMPHcd5{{|~!gPX@|q*Gihx2k>IPh(vsB6Ie&dG&xN2XyiV% zgycqXPQT(|tEYL*RrzWgHKdz{k(lC(w?$_7o7kqgPMSjwqy+wwhvu5>Ob9D?w?QK_IT64a?6b9SUmjjP)2kcq z`|v1I+3ten*eC+$?Yc*O<#+=*lK<(%hIW1b-S@C6TE&^Pog^4aH1v7GMP=8mB$Osc zK{`a=tzMz*yT>&_d?Lmu=|+o8Dmbc{^I=! zO*$5lT=@;qTn#zGf@j771>zDH$2kGNqW4luJmGVR^!9-_ZTSRM;Pi);y%&|A>^;#; zI)SLCBLlmEyZRzXGNZJ0$~^{mc};8f#DXZRJTLjGe-?hW9wIY@EG^G`iudCzN7DM| z?i299PJH#`u)2+u*b?-Xs>@iw?5$WwVB%e|R~~FWYe>Wc5f8#Gj&!;8(H-=4Sym&6 zH^q}TCt)d3voQ6filCG+Z$dIDt2rYw24f>&K>6XdrYs}9wAGgiW$K7C^)Fzadx@S7 zwz2%h{@y7l(ctc8Npw|v?~JD2%%VD`UAL=!jy4Aa@2&QoyKIR}ZGRatPRY0zRKa4X2VX>& zwxGbms;;u&K104GNMGrDXBH7G__UM5F6lSkw$4=a1#LD=@=dok)xZ%h9%_jme z$qEhTD_M=S%;)bXrE>HkU55J6c}ZiB)f>^$w~8pDm&*g>$yp7(kT1Ph6HH93h=}|* zM0lP>{RW7GU8O^viEpTNAAXCArMrP<0~F#dnCpL)Y~fsT7bdOI>J%r27s@k*zQw^?p#VDq?gm5{6ubQ$2)GGv)Wg8D=y8ZZ z+N&&o>Bh5QzWqrKk`Dywc!Z=2ojuH}gCBfJ2iOsA)a^IdTLN&4ZKiY`E5-xjhwfQz zV!#*ki7i{e^GcUyugz5DV8eD?;?B5J+q9j_a?^>P_;lmTHutf{NAeB7B5$1V**|^U z{0*q7l3BC)@NQTp^-+xuEeF02cQDsnEU5LJ0dfiYs%8Iko&D*A98ldWh0Myiql|EywM9&7|x?Z=D71&PYnE(Vyp?RK~Zj7f^&%89ZPgT<0Td+H!s}@DfoK z)XK9XJ7wvuALFJJ_*(7q%U9}rY5`a!i!R;f(vawYq!wg|JI^R>RSi~(#8l*HkR{#% z_3nY?dw)GA=aXS2{!5`OkXN#K(l)kL7g1~XgOnq+uRexQy~=!&?dxXclrY=g=8KBo z0O5GYU~PHkl3u%EUrDxJ^G!A`o9a7+&H59^)0HM0lgecOvZ%&BSj@*z8sxw3JUONI zPDsQ0K)@J@-tksnbWi4WY;I%?E$Z#d1S*NI_?q!2Uq9ooq2XGtRx?!;Omfz${$(-& z8bdNhHXgDJeD#iIq2-j1Wlk_#{Q)-RLGX=p3d+ST&$h$apO%_O_TV{Y(x21nzCqHy z?{!~b@<@me9M`P?4VE4Ogx zpxKKnNr|KO{K^EWb8;nalrkrT7?kA~RT6_nBi`BgQgkJ^#MKQ(gwbsoYZr;T=ePAq z*jr%@PB^)b>@UKv*y2TCu-H8@e@z8yPaH!UjadR3PI!8x-1~w?y~e?9Y>ohSpRmnQ zZC*PBd+_yZ2=6IO=+Pl4$jV|tpj(pOnMZV82(%=kiOp&TbkAlj7A!fJ9>t}(`?2W> zN%&R4J)(A6J-VR&@ty#wP2PkE5%O11KwZ}i);w<5WqADtuuy+D2RE5p-*}g9B)~?J zj^sGp=0y6j5EswE8>N}ScZSw6WUV@1Yw7xa+%u*`pqezkRD-L?SEtf_tcH6e)U0?< z`S5IO%&d(vps-SH9%k4mUF0pYM7lL*C?{yp3RFuKvF4m^kv`XyDXv$WlHY%%#Wxnh zACn&Gf8^n&{+Z`*At~qG4v6Cd16-T$Z&unpFnOZ)Juaps#y4VFFr@{O%O=!>MBr5{ zlRqbHw$TC}tZp>WSS|_<+;arEi{^7muey7T&MT{P7`qsFQ-!HE_@-8<;Tk6cSTw54 z^Q?hs@c_noBsC%sSjLXMF~wxb zAt_S?PK7BF+Dfem=*9g}=qzc^JufOe8x;u!%SSr5EdB)0FsC_dwuI_mk;Atdfyh0* z><(B81M&8A=|y*$w*~4+b6K@E0e!>7jRfOF1q;EMWp_-_pO_B5^vsLP)tNl$UVf;m zLu)a<_Ki}&=OxHrQvCF3Sgr1mgKj7Li@^7_lKo|n#PLr*76T zKcMh;4U(g_`_AbF4`mwfI;7St{zOUVu(@wbyKNnnvc`T(b{{oL{tXbJ-gr3-9KZOm z@bMYG(>E03yR>RWX{vi=SCNYPoTs|(^tAU9VMg>3^NU`uZ77_Vu4FOp=U48!Ap%Au z`yUoMIpM^z)K6_b2<_EP(s}0_%n1mbS^TSke(KK@*8Ik|m$9XP6`h{ig9w%2OQR7T zV4E8%$t%9UOrO%aZPz3(sOv;Bz(fHr>8H7Ge;NOtU{Kz6Hsfr2RYRbj0#4G;CxtHW zh9eg5!86qHW?go93xDx0O+M}5`j&gC>Ce(3mHrN`py__8C8Ws2cxFP`-MoZ=BsJ%p zk^3v$es_I*LEzIIWOG1J#_9t?v$CSmw^<{=)?uwr)R(Ibe^pKoluhTRpN^&VIpWb$ z%dAURTu!b3>YVH9>P~kS0AgH9qz4Pj%bI`lZ=yjZY}Jq-f_WLAB{DFX(nTCVZN{y% zV8KKUum0Qre8pEHDxd?-nz^1kZ~BJY_X94lCslWr5RT(7K>Qte7N6q}KL)-0`f;w^3 zkXUp@>$dxvKXDhU)n3G7!Voi9EpPcrly{k5ECLMxQ zV$YcswEzhGR5xXH{MwpDc@Hc%AUzp5NJa_0ERcHYEunW>&W{b$GRz}_jau%l+D8A% z*(PXsp)}~rN24Kwt$5ZPn-p2z&kwal7uS3@R6ajXZO>VIq&Q$M^z35)RpB!~(n<~! z{*7*?*c7o@)`7b50dnnJCT*K?{Iy~Dp6?+eq+x!LOV@?izXRmpAlkd7QM#d2s(a*j z6$vl}@*A?ZTti+LozBLT z6~`WRs$twt552uigf=P;R${i~m8 zFq(ctwgKxB;p2L!L0w$$+!l^Kp2JoJ6f$eWq|G@MX#~u`)|Kwwbu^<{{Baf8Lt@6n zx95QMm)`4F9xEK7PaZ}03x!vPKcWo<7^Ei&yyjlg(0AMktsnF1@tz{o2jR0SGs?9o z!FlbM2ZZsl1xq*nMhN01b@W%aiHm{EiP!BG;qyq=C|QZEoLD4+g1|VCbYhO~hXRK#caG; zc7i1kHRYF_=j?c2kS|G60#7bY15CfB1#zGCyDG|4jb6^wVqZA1Yy`BWctAmH_=i!0 zQ^-%&_rat|YW>YNuISU8uUIED!4Szi?#H2uh#9ENPmdahft}L(x4f2qi0~m;GJaua zwl@_SDUlK?QSyNdu;_nHyFO!R!Q)|Bf%l##Mnw&W$q8=gbRf1E1!x-wPcXzW=y5UC zW~iS3MVvr%Fhu_L#TyGwLp9P$Nq~8ITvO-+R|-OW%iV#ff25Sj`Kyp} z?BOn8UT#;jA&-~`m$=HXo;Ew-59@*I+lZ{7uQ4broHd83&pG;^qQT>-fEpkO5v2_d zKo`f$oPE0q^1)3tSb9;~R=YJ|iOD!QDw0LoEn!yCyRq5}CsqZ|ne&kFpN1h*x7W-s znVS|Aq<(?__6Ipnxn4|tn=YF>E?=vtA-g1y@Y9#LA9uS^G|O&I@;kLTm#D3GVMvd; zsKj3Mb;Z+3%p|)`w~&&q1i%?!`Ts0YrPYYh>4D}x>wg{fFGVr?VuXdVU1z$?sYhc; z#Z1qt9zzBfjX_cyaTyNg#8P@%O*H}yE z)g8&AiDvi6IH?p3>w<@R0ul<3{-LL@Ieg-j%7u>!WdWRsUOd`0NICCLM0)qKY!TPe z*+}ym2MvLrO{Lk3=9PVSWjw`NaX^F&J_j`WV8oBV=|T%u*Yg{I-QGqMqP|7wD_z_l zMu32OZ^^K@B`{oAps{6vx-*Es`N`}=@Tj>UWU=0QS<7T(OwuVa^V4mfaq!|!TTau# znD_d5dACbs6vr>cLxV?-JUc(*Pf*j;>ZFwtxEK161+aBYMS1jIINM9#ZhZtg)G(zP zahBD>bOk6!7(7-0B~$P;{UaW!6G=j`_+)LdS%UA@{m%_II$*h|#p9@-;UbYFEE%E- zb~zF7G<)81NaAStcdL{ls7ThoLe{uLqM3mqR&YN6e0ZVX{>p|rO}vnB8tnKsBTmhi zAQjgTGx}k)Pj+-ojzhF6l1+U(M%t6UyY;=OPNBw%y|`YkbCW9-AD!R)1=;SH=VTq`}x`)XR(OML0ts&fRki=6T1_Vb}vg54xR0-Z2w zmIz_@j3Kh`8@duBsb$6h8i{d|ZlAhCJfX`VQ%@=}<8@0FqbGCYrn3N@qB*s}QL3eH zy0YUBH#KnQzT_VL4 z288c-moa30FUh_F8WvF#Z9hmRdsdrF!13NQR-qE>GnLLQAND}ElHE)KttMk?G(jLt zMbvqlM9Flq;%<#8k|ChFhD#2|H%bq}m1GCUlf5E<)s)X}P^O#7OwTujc3ydxQq2dx z+6ysU5loZ{BrM4!F=s6V(nj?Pg6_95{ZxW?R!;-rLC%)r?W)81q!%7+0Wa#oU2UYir7l4%e%A zrD&NxMO6ifE@W%Qg(4m9MzM5NkPb%As$O=7zf1?z~ z`bZ@VRoQZDkk}n@TbZd~R*e$lB3TXb!liI*KZ9GJX51?zo3l5Rr3+c3O@STETv669 zOC?!=d};^LW*O@`Ebm*qqI1G4j~zH^LqB{Jq@`T0iy zmJ8%Ng#81bVY>$Jt0W1V-15UqE`meIQqC(ULkj@_G=Nc1vhemTHwuNT+*EGQ_}1^MQ*IiRtuPZly7w>dXrjvLna7sf+ke1OhH% zYpvU;US%XF#I%lb7jxnmk^yj6rDFK+ZpjQzCJ#e+Jm~@%V&*8T+ly3J_Y7BIGR*Hr zsTWseu7r&@y^3t?XYX$NR^mhTt7}(nu!tWBa76!Z7_&vboTz>5<lWE!{Vg* z30X&Wd&IQ!2hw6Bgz1Li)IJuQYD#ox5+w{vEE=x5j1Z~o=)GmO z8t$Z0uTwr@mgITt05w;37&J;>yLYx(!^W4>zjY@J81-0ft{vMRg!3EV!6PJq7&rxi zQ%Hcn@GW*Xj-NLB*S3AN2E%oExX$dWca$dPa6x)7yYC|1hW2gH42C!ylT_zc6rxXn#B5svv9GYOy z2NW&$8f5s}V4D%O@QM=CYFY?=3I%~Ujif-~7NIW*HI6n^tNUXHsJOIy?jU@BmJ-5g zIeLIYoVTsl9#3piN9`X$Xa6@rV?hTK`TZ>SFB1wf9!%7S^qf{fo0kwQUaI8gB}9!H z^}KZ0+)!pRQnc7FDO13BGCSQlj;LP5Ji_JCszrP9uDc*R{(Qjxs?nXd&3O>;`pY*D z*zks*dgb+LV$Y?Od$N#^kd;u=nOS_;Ya$uX)6|se7?5j_lA|mtNU7f|?VVeUI%nU- zZKdr64TW7wLn@fbOtMx9Y&c^aRVhb1-v9NJ7)qAEL1cPq18v2r5c-Ga&H_5wdFD>- zmLK=dqqY~J%i3M4_dbVZXl=vx7y)8>ctv$`r&{WRJ1~3eFv1#rRc&i+~XvMt8$F*B( zDo7!2ols!KV2Jh<5tp+jTel~}5l}ALyKaoVH00CZ_AKFPGqeHR=AF0Y7-|tH;7x9Y zw&zvvN}DcPw=G~|ANOT`TX`Nc!<@hAeU_Kvd{jlNz2qO_Fb9{=&+V1NDhJXBTTf&i(Q` z8xLNES~ZlF(|g25bqS|`!x+o~&KJH-*OXnxNHNYk(xuaUxt#94 z=N@WZ)aQsPk@yT*@mTk2PHamR9xdW(C4T0F*}`4fHV&n`v6aaxqxVYQe869QI(COs9zD=e(9Sv#Z@$0OOcW+-e~Uf& zh`xto7CVe86TTkC{9I0EUE`DG@oR~t@s<9MS3;v;+N%T}oS!TgGrs{%9DgnXrT%Xh eg#I1hU-x%>KK96xB0%Hy|K|zp-J*Y=nEO8}2t5}7 literal 17480 zcmeIaby!@@vM)Tiy95Sz7zpm}Zi9PpcMTRSXmI!7I=Dly;O;IVxDy~D5CYuEe)m3S z-*fMCpZh-d`M$r-TC-+mO;=Y{|5kT(RrQ+nvih8dJcZJ*KGXKA|lc{U>#T2^gm2s{-2qA2H+sW7Q@%W z!B7KWabVzZU|t3RqyQKg02~Z-1N>`4LO?`@hlTr7ivMpv|FigI9e@r81AxVb!-oD+ z9sI9afBzHtiD>F?*MifY{UFHBa+1akfGH!M)S9qoG}-oV&t|=)s>qt@@t!7#jb$75 za|Oot>0Udf36JrQOt-9I(Cjl~w4(1gvW0kGQIL)ko>Y*Ea<#8c9dI+jbx1ioRO z(Z5E{@uX0!vo$7_UzG@yoS6PCDHzIf={WIUTnC3|sxHIdq!|Bb-^BLeoa>lrbjCLolz6?Bipt-*h> zgTpFLgWrt@@a&l>{iG2F0EBPd3o_CCxgEV;cF`d zO6Lc%wNH?B+rqMKwZ+9EfBb$92Eg2Y2LRx1==@$)FPdMuD&lCtNq+I9RxwFjiQ56d z6w9|N3+&ZQaLLR900B<`K#*AEEY1f};iLTYfkOb&*muN?PCHBQHM~|5b%5wQsL*Gu zW1J0q&qYDGoK9%~+|e-rzP55#VEy+vMZsSb|EVvxFNU9_Zd)A1TXHN*%5nRkQp&6g zL( ztPCmr;Est+pf@<+u|28tq~QMEaLYC^hNYVwm&u@21J|du?_D`o?*B=s;h@FVa4jzR zUf3q(GA~+qsmHY}HLC`sV^31DNBNE-4NjX^u$N~|#X+`C1)W!kLSBUXwatG2^lzcH zA}hOWyXLFS3qO$^RonSn1S<`gC-6oQvgyKy4b&78*8S`1XR;(nWjp z*>>CaFsC4^OO4l)>gwN+p%8vBjLz!X6mnp!$2imA$U~X(ZgR+;Z;%ty=0*q4k@q-G zzM^oB?oM*Do*`l0;NyQH!+9KJDw`o|kW*nR?IF z>yx!gUqme3e&?xM=;wcZPZafhX<%}wSL&spFVZm}Ncq#@5t039TzNfKi>8(Fq{1_S z&ogR{uNP-}F;5QJlT|9aF?{BWU{NsL4-{X4W6^+PuAAU51@zY@*&2hmKe0GOFs7fd z{|z00?G5nz`#Kk(=r`L&(eR_)saEONNLLU?oMP?j@vD(|09SO9cF7cOEtBKzR_(L&cmcH5%X)Wnxw15NmO%2q%L#p#xE!{;juZu# zbDI}JagGx!@vSSE{BScrwTHk_2ei)eoR@!UrL*Dv_CKRT$C|-i8Ws4N-0T-k-Y?33 zM`vzd1SM2>W0;SYTjZO^o2@e)x-SGOxyy_&b&|F9#T$E-2T{FKa;&!L2}iTAQYK4L ze7Cjep==?R(Eh%aw<@L_M)Wvxm8 z0s2~j{<{}IiG%8iBM#ORlNfwsTIZe8kP{GFQd||V8JC^_yWMR zH*~Y^uD9W($qC9ssY($Jlwwh>c>%mJMG2Hb7w^PEYR+86l5;@B%!r&s4jeF|F~+HK zGW~Zr{(N_st=^J{tcc{Ouh`s9a!QHQb_+=H_+~l@-_Lg3&M+?)(I?qapZjXn5=AwM&)y zd2woE>5djCGfb+-`_N;X7I+V`UP*hzV`@|MSnJjlI4hKwrTMmrs_OEK;U+)jaNr$8 z>Rh^7ufHJ~X6I+rJ&BsV-flW`BJ0)|0g}bT|r8~78G06l@N5nbR-(nt8 zsm~V7rs0@T9__~;T|_;Jk$yYkz!e1Meobbszlx1+Txodt8)5-O=TZ3;l759Kr|)@G zAVfy9nGX(d(p$ai@INH+mXOu7sEV${hb;#e2K4bR_U8W%>;J!L#h@`cFE#)M9tIW; z5gq{=rT@|3&lv(D02>LH2L}(I;1wky7q2N5Ew_w1H4VGCYcNM*A2e!5hQ{(R$bc6> zhYoQ!B)nU)pv5qIHOA*RO1dGjD)s`(b@yctU-$gs&-GzWf2j*f^cmlk7M`jaCN2X6 zAMgVwmVeOv_Sg#c0TuHk#2oe zS&5$)ODwNkW#FGbE;~?{m6h5{5!T0dQ}l9t%7*Ag0<)vePD(FJzMhW(Axek%I;_$a z4OTU-!+cbX70ws>grKTsf=*ajNmmWg`Q-(Ao;e9#K3{kT+O%a^ZS1w7?#VoM>JcMR zv9}&V_pyNW2VBl;^klDm!GU*AWA1vDyLyzr$M#Q;$edoYCr`ciT7CL05q)fnTZ`e0 zJ&O(ht97WlR?$vcYY9sg6eFs%;`k%xNzAZSNSi?0^xU~A=2)uneU$5XJA~yU%df85 zi23GBT5=mqX*|BG$qc%fe7aDq$$ZrYh&||SvhRJ!0_-x*ip9?jZ8TS!2+H4_`+NfC zu@hC1G$e+3tCDxk*J#0CcWDDMYS+#Pe!Xk6eWz^bhIbd}di67*_r@q4^fnz?jMG%)T zrGB#*{p<1joza9U|8Es^_dh5hxwdMb58Nsf1o<~h4Xex=9m!cNIyFVZACUrYa_0TI=U6Urxqvt|_9~Dd7kLs?tQQv^0-({?-8q z;y@Q;0Yxvipsk@P8R^G&x@L#3q^lhU0z-S+5nXe1}17{Ji4d+jW;GPw54UV z9kTZ?oc9-(i9zFPi5mU}cU^560ary_j%+8_fwdk6d9^>Qim0oKijxTVrhjelU`$L3sVwtn6e zi~9sxM^W`;jkt$_j8%d60C$L2@)QfcH}1fZF0gEZ!q~_nroN}8(XTTQ&ey?cAAEgY z;GcR)_tS=uoUpQ}O^+uiyxLcF)ZusIX2XVw#o07|s|*c&jDW>P6dCvHddzsW&hzh( zYW5Os=+@`(I6o%xuodKHffBn!5Sg`vsCa%v12#JQw5#pEQ$@R{?B1TLEh8gdcL{K3 z!Pk#kRx=&8Z5)C3RiNMCt>>7DWh&2&WInX`LXtFDH(nd8C0#rQ+hyO-IeG4g(C%pQ zurxg5Z{d*t2-&si{-%v>7v5o{2r4Qn(8uV*l!=IB1I|qmK_-(C${oXIEKD_p8hJ4C z66Z?PE$&)VjCJK$Yiiz?6Ufugyl$)apG)xHckj4Xhi{9F~RmFM2%n9kbTh!>Xv5zbSGU)ouvf(0VP+F*(hnTzN05nCn(&iGyXId z+mDY~&Z+_9j3q#6srXk|UoY3oi3jcp`50xQ#Ay{d@Y8P>$cWKn^p0W#ipP6CHm=op zY%aGp!81nXFfDxhg8CU3z(O~ zH{GG)rLP>^;ikw3fyDFi&A>DI){9s%Z+i9NL5BcRS5MiV88O|Lllbc%c<@ zMI@Rfv{YWfxl{7q!CuZ_sSobTO^g-=bv*;~*6%)c$rOMhNnnZ_$stM%Qn%g-+0cIsAs(1di>hlc5=@un&NE|Z z?+g!B+EkmKl5Ft@??)7LTKH}cpDlD-v|lB@owB;asw>OrS{8|<%ba8%+3}oqv2d=x zwcm5Y4l&WrVj(i$NWf-=`&Hb7h_7;z{@mg<`H(ypKEq<7ju)r|NjgjmoUH78bf78Voyl4+u zklX+q?>`zHHq?65#CxShTh)rZIn?>u*Qq5VBl8Jv;pR}3`=+W_RL%i^un1S2WVx?TF&5IHajLcP=b~*8)K5s1LtbJX=0fWUMhz*U+ zYBtzCUM7p(G>D&XRzkox%Py(mzeB_byr*Lap;^F}VH7 zo_>u6{9Pxi^Xp!kzT=eD8?`m3V7|tRw#&no3lC!mjyCId{^s*QL*WXSmVn-3Fy+`O zzTgj3GDBjF@DPO0Bb|H==MbKBo?XH%H@LF!R+zc5yF)^9#B6Imy@PXn4Ls&2$w-qz zDM-vE+E`>>_5eiKiyPdfOQ609N7=ZxprCNa}7)M^8Sn=|IW->iMKP274=m~dM5q*uP8x2_{6C?ob{Ss zdEaH7!k2r^v&+1N2*x?y5OSSg6q?JB4+&z-SI=9HDT>lFzISvgPTvu*p>}y#SSA6+4XT+tv#~WZ`HeV`k9+o|meO;!wNFq$baQuL z$e>{Arx_tR?#4iu4No$U!ES>A^`rKX>%EP4MF!Es_n09Xy^{pdRFwV2S#f0t(gU+* z+Pf-(fvevY-rA*0xW)Ly8t=8>he$SPi0+O8o$^N-l6fY5-zdnhtpG3Y94L(s#>8BQ=Th0lI@geZZI>qQxD;5YI&^?EH||5PH?kxSmwMQD{8O@VAfYHp;q^%1T+pzoG2VD zqv(?CvY-6M_WLGr){j&UKwCfT9A#L^*NZQ#znM@UGyacm4b(D?P|#50ey_qZZ`M)~ z*r>n|ie-W<@|N<)PT2ArqqLv6)l_NUl?FIN%b0P-?SwUA-aG*^gN_O+x9i--3cL3+FB)gUHlUqimCU7(;d zyRU&HW6$b2A{a-&6P5?x!bt5vHHJ`0uD7?0e#d#+u)d3R#(W1kX?ZmE^ml6azpde& z0akk9p493H)QZ=LixR59S%+WgaM4T@)m_2EeqaPaI`ZXt#n+^hqao{~@>OL>j+8zU zvMXKx(`>LQ^t2Z+a4JNm;{|#F{Kpg!Q1{9!v~s6AA#U1YH``V>o5?!cOC}9Ek;geR zeGum3?R)x%=^^vgMBr$!oAFp_Ss!+*tSK|YYpGb2Z3{RZ7+jMk0WP^08)~5HEJslL zbDcg*BGg+qIAC#<5nS#fsh~N+6{nofI0_|3B4mHA{!J#SdS&hMX7@BP-2t}#N{SX~ z|30=ZUGvOng=5+Qss(vM_}y|Nk0CuYaPqe+ri>iIPZE5!&!5H9DC))o6CP7l4E4H{ zd>NLhV#miUnuPSf7+OEDQgJFVEyX)zA2<}|n|+x;iYPxzoqj>T31yE#gG0?lsHz``&uwn~iQB?yTaQZWn+OX#EnaT9b znny(DxfbRgJuwLsX|f-H%PkelkH`ZjG5K}}Bv_?{Z*6Q^huaM$jF{d)Sy!ckyeH>g zgQVIc_?7OdKWKYNYGGavZcmWZv{E56@5H>8A)$)lFTPX3_ZVW~48W18+O%)1_=wAz zPzGswGP*~wSkfW<_T5wK5DBvcWy(JZry?qcEKXAo)MNKH04?K@c^%ZAwR)4s_N9?fg7J=3WL>vU_IxnRl3JQuj0il^0SNlQzsKx{!Zx|!uW1d81d zJfte9&Z~XW_0eW5v8YvY%!LV&KSnRtm@7_CdvR22QO8-2M@*aJee88s5_D+IvFs#_ z-DovlL1h$1CraV7%wfx_-zG5h;~P{J)s=Zp<0(c}RFr*Pv=(_&r*_7p7P~S({V+VY zqv+4Dp1&05+I~ zeszBA%esG`R~6P2B@MlpdICcVsE{<_;RMt1#4xoAjswWeB*A%zZ;Dfc0P&L_jf8HRq zwr1)(D(TvD`qsnFwV1D>_Y<5py6>bJy5|LE{D~e&<17%Z)>ISI@h3@@pWiayQlA-&Icyx&Fvg7G2wM9rw{817OVH0HE;J-Stj$*oquGQ zgL3p zyszlnq?aY&vAi;4Ye_AkD`O1CGZ0#ZCWPMUevpD^Yp7dBa=cutI#^aQeIyC}QYf1_ zZwdt31MCVcESB|S6DS*35g_nKvTjtYO+sFRL(=icbo90b5g%Tm7V#?3Lr!5@L(Du~42N@NJZ(fqLetc2ZCy~+&RoOKs3RvD5@rgLACXToZ>p(h0qD%C9{EQi zs~8@Znbk-qBk&_MMq@N-pXM^a25#)hng+`)z%Si3aJQ&?@GE^8enU)-cIvC+8$yki zlp`ivrV^dNT}2=4(hE>4S%Y3O5cTkpD@sFXrPuz%|JOJAIX#~0XsTI@_MvW>5;~<; zDa-NBqB3Jsvra>S7^8LP(+KMe-2hgxc+|LtuBK-H)I4zo*lt?#NZyoujv6p{H0EQV zu?dPI=NMC2lBw1ZjwW&PV) zdb;w@dw$Hx(B6#TYZ0KA)kX!{K*Pvh0xcD-oxwB`YiN@Nn;P0OE1ReEHz2@++aWs3 z7eK&1F=KNB#EE;}5jip0;cUE#E(}dZmWQ%?(Qg;uI9z9{^);u8gu#`y8%V+L(8>6f zn+d$o`bMOx;IzurT86Yw$_QzS{OH~=N5m2j8k_#25*<*;k-U)$E3?BpnOB8mFL@t} zrHNg7ILdXUrB1DfQ}{FcV?T3AvVzG2ZCMfTsk6L&6>!0i~!kQ_s4|ujQtmCR# zq>qq#pn}>Jpj#=wK=nkfeifv=k{9OT?t3Dn~t;FWZ6 z9X?Ix**M0XkZh1W)I^iCbD2w@%&@9PKAQ1jzf6?V0A*LN zZ*6GlQk;$(>mpm9Azi<;-yv5fWy)uJEpnk%^fz8juVXps?c2npl6ZN`0C%48B{^P6 z`Ls`s_7TB3jd=BDUvZHm!@ejjgSce}8<=FG9tZ!neN6PX|Tza(yT=f->F{$$S6 z*0Abe0xWqIFCkzj23|OrOsF%UJby&0Qwv)u?>}4E`El~i@B6)o#%|brW~p3DDS}m} zcd{H-iHtV!C$R?Y=K^-Yd(s3>BJ)sQ8O-hfwIWIwGL?Dq7Nm;#qF9zt7ps>5j2C$a zHHLbCq$>V?DSRSBGrN>}C_b0Cd6R2!5v@n!d~x6IKhxqUV$ihsr%GH# zf+(3$qWbm0dH8@KS~wl6wHJVdBBF02=|p@iN_phhg@JFU^6!W$m!*^5z$IzzR_CPL z6CN)OHxo7a6P71pl<`(;xrfv-&5O|JbuQudovZ3|AP7bP_Eg`%Ds{|d#W!h`H)|rO zbI|4jY8IO;mMD2R6ra_|a!)UlRX7Kj3Bw##a2+egjX`QP z7E{1736W;zezx!e`w9g;SO-PlP}Q2s!z*^sLdTBUS$W`1!D8vscgb@%(B^y)1@_$t z5V3(s)I-O&JnM;N()O@Li62w8gjivm`O@-hJdL;(d5e9TMbhCH?AGAbVD?V^xj{40 z$qINZc^=x@@QFPt_|QOr?ib)8U({x>5;am*hZ05UD87Mu6U>aUbt<>FCsyhyit(d;p5PYvJLknv~02OZ6`+*55m@f+5uk*-LWMd=mhLhQ^1(`S%N8?M>3- z3xFc=zcvf0m4DtVs{dB!#8N!j27!Go^0k4DH}XW-*NA+eS`WREVgXGo2V`U8aDct< zT7dxAnP=aJZ3jzxtT)WVWWZmgiu%Kv;OB5h+%BI7;nmDj+5<)J^);5GTY&>k*b$FS zw(rOJ$>Cssi#P_oSPGt?i15$?g+&K|y6Ke}`C{`&@tg?HD z8eN>dn&lWKv_&c-KJR@eu#{#nJh2vS^1Lg!X|+ZjN1C$BOQw{}plnTv&jnPgG<{Kf zZm9sUrdw@$9Bhx0n*gtotdzK1*0@~WQ_zsScmg3go<_a@nwkAq0en1PlJ>^{21en|c3w9oUw2bOHY%QfFe$(x z$Q?=LbyCDS8{nq}H?b9y@BIEEbVoo9a(DgWL@J<&tusB|y@ zXPEvG|A~o{xJY**p5I)oIB>$}1ps%c+xx`qW~w~1;lDu)d*$xEAO~0Syx^l81p93R zsacGqJ|mwe@7iPl>heEA7aPLoHQTplG6sX@JT0$yW=viHyH687TXV&i&nXLdeJh?L z-`L=H7(>N^2Y4qvUjXnsNyS3sr9>-fEU`z`cvZbAHoj^m#!4rAywSP=s`F<%o*&np zPZD-ah==*#4T&|qnQob&H1fvOJjLg~mLClS+11Bue(Ei}cfA7?>M7SE&@i{BX* zTr=Jjz#)%=4X8o{!1SBN&m8A*2xI(d>=$}1AONTzKZJicfkmmt^2?yq#|fe!CYUtW zT1mN(qa|ETwU;2Jx!akg8ll+`KL5$NqFoRd!ao2e=vqzOs%EkP!8EBQ$My<#4jC1VW;#dw5^&b*3fp|@5w@|vU&87m8Rf+L5E=K(=g!1 zJ)xB`gw#SuhG8bCD&5=*z?g)JTVFQxD?92_ysI{WllzguKrq~L-%_Xep2j2`+?hIv zGnuMCq)6E^1mrHLX)UKd?j2U5%Ml=XKo<>wDM_}U`(#ZNwd5gI+a@#MZX+%?UOc|O z7zY}#t<&kLOklwq+PxGaHuS>hFW7Q`>g3R{Q$jFc>vKN-obStbm z>5`gEu8Gy382(2mJtg^`H{sg9<+CQFrWBhP_aMSlci>^`)-!Sf1m02Auu6I3>2>3w&MUPPFOyv!bkBBaiiVHp?b6u2 z?ALN)5U*CQV*=`M637^oN525J!fGfPSND@Vt2LXkKksC3|N$1)qh5sAn(vtJt0 zG+BS7=v|Ps$@xv|S#CG^GGO?IJLdJ>ldF5+k9b;k%O z)Xb)R%%BdZ5VoJS)B_L5Wi~lSvtB@ zW#m8KrO`^Gh&VAyw0yU^V|Y_^BC?9b zOK0G+*xq&(_U{nPm0Y&tD)hlPMU?0jhO_d z$TX=WpOShiPZ{96ZB1ykrturujRD8ij+}U-wJ3KQ5jyakdD6i5)E3bPv=_8nmAi2n za}fqNyGh1@AU6UU{rv-d!~{*$<{`%Qc#_UyEE0bS(t<_%q=J5JSpZrmr%%rBM8P{3`?s8ttfThnK=EpVJ~Y|c8V zL%}VIywH8uB6hOeEJ0#f8T^ihaEE1s|&Sj~;)l#HFD=`Jmx2RS3KnNr&S!RzOzMB3$p-XRLb&9?jE6Lq3=ok{gvS7%K*L=9viA9kBA38~&;e0q*Z+&D{Z;gIjeR88U;sEw9BUdQNBoJB>75*Z*xTOT9 zwp#r(#F39O6wmWy`-zFgX>#o&71qaGtgz*?{|@SfGe7gxro4&@l01ptap<(TC%igX z2Z%->?hS`;^WGhj{mkbswRof7r8$isWcp{&ZEK7+`emB=4){lB`ial7E|KNd)vUT= z!p4K9CB0use%nwayAvIoUHO+=g=U^^Iq`H++HM2iT&8wq%m1AHX)p$5eh%e!me$Tcg;gb0|O;J>U zRw^c!amRM|3VYJK@tFQ!n2(^yqY$r7Nhhn)X6N`dF&_+~>qo8WP%&c(YP>n9Dv0M< zjlmD7IOY-y{6E0I1CT8O^#dDji-*N949jBAT}j`iCCe+B^-wLU&N`bLys}jgdL{or zj`V+V1rDiDz1io>603^|tOWZBqMKBL6S4R#P_t;IDpU6~X>B{#;$a&I_1wl1@_h|b zo463vd6FH*rH3A!N~<$_s9Yz7v+(jG^{N#cXp}8?&Q1tGvSEfo2;>W@$}l0xDiWiM zNa`J~$&39jfO=aKI5XNW<{!7}4m=gVM#ZrAks0VMB~!R+|Hna!OnHsq5F=S|VIl?f z4k-f~y-6gx{^g_#XBOLtymbc;?R$F*DUqsB+Cq+vLx@60Bx4$-E>Zve5U0*XQO6L^ zLuaagyD69#u1eja1Z*qFGtcEZ_5Bfk4)%4t5inn0BRaEhrmbvI3!G=3>P2$J6o3t@ zJwh(xOAs?_DNxUQsTCCjDFRAL++A>6F@Sa%oo`bH-Sq8Fr1aV4VNO7cU-KOjLmWxikYnv_w7nmob{G-M6{CvW7JS(6NWv)IKwD|9fT+k^6iMuo-7 z|9vj)x)BR}0i?YEfGY|g2E__J2fy)bar{Q}!@33pmIS>;VmvDajk|(m;elzh7doJK z+RPp6lZHL1=?nnGWEFb6AP~)=SDW$X&^21oYkakINuF97{nL)<@43#B>D>G&Ki~US zivth=7~-xZ_)YZr8z2^P68OyiT& zv@BG;PVZJt2=}zisK#(Pu4>#Z7LPp-M`IeJY-qVGe%$NCleyC3Q->N&X|=5m)zh|h zI^zUp_J^EerVM2q_Js{TXgJ6>lBbuR3o(`>q#0FtQ`D#?^jjk*{Bx)WpNm{G z-@4vNfQ+oH&Jk1hx216w+W7q)W<&OLz!)7$&FDa zR^Vp`#OH`-o&vtxGWiyyGcqp{n##UvSmbg`v&z&R8Rt?+?Eu%@V787DsBK;87wdvN z0ghxU}BYv29cXIc|1Z^DP!k((6tm5L)BNunR$0MtTUim74H%)5y~AtZ4o^fET#%bZ5J;_>I3{CWkrr5knKZ+3vDln z(vFxNfcvAYA_bG?hh1boy}6B#l}}%Fx}*c;kO(^6ah8Y((5Cm&Eu!ww5dcs{*cw0` z%h%T=2mfqKCl~u_1tFy409cZG5}238hb1f4sk*?{_VkiBLh z1w5c!UyWoS1_7qeW&9{fISEwoy34EMhVZ93{G} z);8ilbVTNma%?#;DVm~exsKpW0R}w;S6*PcUsMXXxf*qm-Kd20m>P}m1I(j~y{UX4 zhmVOWCbZ^<0V~+iq`Kj)J9yB*)k->e7|X71@{NY*1P}VkdkX%N`0c)9pl5#d(>}

odlR=x;;m_x zNjKWfSP>euXn$tDb(yB8i4|3HJ}6}>NwDw!p!CPeQvxmp8V81U^}(flZR1k;R|+Aw z8B=Zz;r34GpQ;A*_(ZiN&Y(eoV*}G8zr=3c;+APMXFeDYM_xOkUKX*`c3&!{F&7y^ zxIhT2O+im4cio$Uue|Rq%e^{AlVP!Jn-+_H)4mT@XR$|2Mod3rNL{o)9)N|wAkAa2 z68ai4Gf<1oZn`;eXUUm0$MDn28+XITv(Z=_lLq@Sy5*iQXoNtrLzMG9AspV^G2>nl zL}6e{lrq~4b<%^5@1~ZKKVMHO${sQf4=Ea1rhTqB;{kqJbvvYqRnzJBq!utIE|n%? zu45XoZO=`S%k#JMcnZ%vxKJ3kLmQ((&?YUJXIU-ilxufAI| zI_?E*lAp!ZuwKl<(qFs(lG)4m$}DsI!znl@8z-`aze`82*NcodIy8U$I&NY(>1WA# zZHiwg1ex-ba=D_^v^&o{9#%lB&GOhKZ3I`9zNG}0^OxuAJpwC`9JuQ?XwQBHUwHr$ zQUIpQOjbCc&mcQH-@D*V=QE^)&y$rw%!9^SQPm(NT8A_bJ0gd99^hXV>V7lWDmqPJ zkt8{P>gr=DQH(-T1`V#ntG0F3{!K@jUpWyZzYEw5Mob)4pReJ*ccq~{o~yw<)XTmA z+P-(qG!1UwgsItV$Hr>DqC8-bqvJ+ofz%R=@w0aaC#8;HuV1(dP)5ZDZGI1$O6Pt! zd}0ODRe7gNkWX`{&zx6lrU@2nrXf`r3nz&Q5LG^a5fu6icyKm~kZG{x_L{PFko=+? zJbs}D%*(rp72xya8h79zHrlzybE6hl=*p1O_(1<19x}Tyyy7YUi6(?me$kGh-H99Z zb7oZWVM1jI>m@Y)Yg93{au%KxFnSLC6E2;rD4XgKMJ<{?ccb+>XKxb%DEPrij=<|cT7yC*qA3xW**H;^!~Mb1(c;aS z-V=#IUe;>z+b_=B!U?zp_;rCOi%V)?I*(x3KX?E3vT$}zRj z^(LJzS~qB@ZDp{9#y9lWTgw2AS-h6($bl4o8Y*AG{LXcU=1TpqGlOHReW(jw8RE!c zJJ!E4&lrr@Xm5J(T?KTCHeThd0wDVOH08m(&LiBpELK@=(>fR4kKK^y-H zW)ZVVXM*vlBMnUent=(=*{%7(7P3$8x%B_&x-e88Z!GF|G# z^Dk zq+_8I*-UOrvaV$xRg9ob1oQ5HsqXxK`?zK{XN$l&Etaz+m;k6q>JrRBt`vvf6J}Uk zoMff8WWQnk0vNAJV0*;2B!HD6y44E0&iV5Y4V?dcw#Hw77^?rrx+8QAQw*T+@&7;p LeJ;m8DAxW5`@tFM From 7f3a07576c436943dc1a08cf68405aa0218f4107 Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 16:29:33 -0700 Subject: [PATCH 23/30] docs(library): update langgraph-alternatives (#9357) * docs(library): update langgraph-alternatives * Pi Babysit: address PR #9357 feedback --------- Co-authored-by: Sim Pi Agent --- .../library/langgraph-alternatives/index.mdx | 351 ++++++++++++------ 1 file changed, 239 insertions(+), 112 deletions(-) diff --git a/apps/sim/content/library/langgraph-alternatives/index.mdx b/apps/sim/content/library/langgraph-alternatives/index.mdx index d305e6c7028..6203e0c64c4 100644 --- a/apps/sim/content/library/langgraph-alternatives/index.mdx +++ b/apps/sim/content/library/langgraph-alternatives/index.mdx @@ -1,14 +1,14 @@ --- slug: langgraph-alternatives -title: 'Best LangChain and LangGraph Alternatives for Production AI Agent Workflows' -description: 'Compare LangChain and LangGraph alternatives for teams leaving because of Python complexity, prototype-to-production friction, or operational requirements.' +title: 'Best LangGraph Alternatives for Production AI Agent Workflows' +description: 'Compare the best LangGraph alternatives for production AI agents, including visual workspaces, code-first frameworks, and integration-heavy automation tools.' seoTitle: 'LangGraph Alternatives 2026: 12 Options for Production' -seoDescription: 'The best LangGraph and LangChain alternatives in 2026: CrewAI, PydanticAI, OpenAI Agents SDK, Mastra, Dify, n8n and Sim, picked by use case.' +seoDescription: 'Compare 12 LangGraph alternatives for production AI agents, including Sim, CrewAI, Microsoft Agent Framework, Pydantic AI, Dify, and n8n.' date: 2026-07-13 -updated: 2026-10-21 +updated: 2026-10-23 authors: - emir -readingTime: 36 +readingTime: 40 tags: [LangGraph Alternatives, AI Agents, Agent Frameworks, Sim] ogImage: /library/langgraph-alternatives/cover.jpg draft: false @@ -16,11 +16,11 @@ faq: - q: "What is the main difference between LangGraph and its alternatives?" a: "LangGraph uses an explicit graph paradigm where you define nodes, edges, and state reducers to control every transition in an agent workflow. Most code-first alternatives replace this with different models: CrewAI uses role-based task delegation, AutoGen uses multi-turn conversation, and Google ADK uses hierarchical agent trees. Workspace platforms like Sim and Dify take a fundamentally different approach by handling deployment, integrations, and collaboration as built-in features rather than asking you to build them yourself. The split is really two decisions: which architectural model fits your agent logic, and whether you want to own the production infrastructure or have it handled for you." - q: "Can I migrate an existing LangGraph workflow to one of these alternatives?" - a: "The agent logic (what each step does and the business rules governing flow) can transfer to any alternative. The graph definitions themselves don't port directly because each framework uses a different abstraction model. For code-first frameworks, expect to rewrite the orchestration layer while preserving your prompt engineering and tool implementations. For workspace platforms like Sim or Dify, you'll rebuild the workflow in a visual or conversational interface, which is often faster than the original code-based build but requires rethinking how you express branching and state management." + a: "A LangGraph migration can preserve agent logic, including what each step does and the business rules governing flow, but graph definitions do not port directly because each framework uses a different abstraction model. For code-first frameworks, expect to rewrite the orchestration layer while preserving prompt engineering and tool implementations. For workspace platforms like Sim or Dify, rebuild the workflow in a visual or conversational interface and rethink how branching and state management are expressed." - q: "Which LangGraph alternative is best for non-technical team members?" a: "Workspace platforms with visual builders are the clear answer. Sim's drag-and-drop visual builder and Chat let non-engineers describe what they want in natural language and see it built visually. Dify's visual workflow builder offers a similar low-code experience for RAG-heavy applications. Code-first frameworks like CrewAI, AutoGen, or Google ADK are not the right fit for non-engineers since they require Python or TypeScript proficiency and comfort with command-line tooling." - q: "Is LangGraph still worth learning in 2026?" - a: "Yes, if you genuinely require stateful graph control with human-in-the-loop approvals and your industry demands auditable, deterministic execution paths. Financial services, healthcare, and legal tech teams building compliance-sensitive agent workflows will find real value in LangGraph's explicit state management model. It is not worth learning if your goal is fast production deployment for business workflow automation, since the engineering overhead of building deployment, collaboration, and integration infrastructure on top of LangGraph is significant, and workspace platforms now handle that layer natively." + a: "LangGraph is still worth learning if you genuinely require stateful graph control with human-in-the-loop approvals and your industry demands auditable, deterministic execution paths. Financial services, healthcare, and legal tech teams building compliance-sensitive agent workflows will find real value in LangGraph's explicit state management model. It is not worth learning if your goal is fast production deployment for business workflow automation, since the engineering overhead of building deployment, collaboration, and integration infrastructure on top of LangGraph is significant, and workspace platforms now handle that layer natively." - q: "How does Sim compare to LangGraph for enterprise use?" a: "Sim and LangGraph serve enterprise teams from opposite directions. Sim ships deployment infrastructure (cloud-hosted with automatic scaling or self-hosted via Docker/Kubernetes), SOC2 compliance, real-time team collaboration with permission controls, 270+ pre-built integrations, and per-model cost tracking as built-in features. LangGraph offers deep audit trails and deterministic control over individual state transitions, which matter for regulated industry workflows requiring explicit approval chains. If your compliance team needs to trace every state transition, LangGraph wins. If your team needs agents running in production next month with 20 integrations and five people collaborating, Sim closes that gap faster." - q: "What is the best LangGraph alternative?" @@ -32,19 +32,19 @@ faq: - q: "Is LangGraph open source?" a: "LangGraph is distributed under the MIT License as of October 2026. Optional hosted LangChain services have separate commercial terms from the LangGraph library." - q: "What is the best open-source LangGraph alternative?" - a: "Sim fits teams that want an open-source visual workspace: Sim's core is Apache 2.0, while its enterprise directory has a separate production-use license. Haystack is a strong Apache 2.0 code-first choice for retrieval pipelines, and several other frameworks use permissive licenses published in their repositories." + a: "Sim fits teams that want an open-source visual workspace: Sim's core is Apache 2.0, while its enterprise directory has a separate production-use license. Enterprise license: https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE. Haystack is a strong Apache 2.0 code-first choice for retrieval pipelines, and several other frameworks use permissive licenses published in their repositories." - q: "What is the best no-code LangGraph alternative?" a: "Sim is a strong low-code fit for teams that want to assemble agent workflows visually while retaining extensibility and self-hosting. Complex production systems may still require code for custom logic, secure services, data persistence, and integrations." - q: "What is the best Python alternative to LangGraph?" a: "PydanticAI is the best Python alternative to LangGraph when typed dependencies, validated outputs, and conventional application code matter more than graph primitives. CrewAI is better for role-based agent teams, while Haystack and LlamaIndex are better for retrieval-centered applications." - q: "Is CrewAI better than LangGraph?" - a: "CrewAI is better than LangGraph for role-based multi-agent tasks that map naturally to agents, crews, and delegated work. LangGraph is better when developers need explicit state transitions, checkpointing, interrupts, and detailed graph control." + a: "CrewAI is better than LangGraph for teams that prefer role-based multi-agent abstractions, while LangGraph is better when explicit graph state and conditional state transitions are central to the application." - q: "Is AutoGen better than LangGraph?" - a: "AutoGen is better than LangGraph for experimenting with conversational teams of agents. LangGraph is better for applications that require explicit state graphs, durable execution patterns, and controlled transitions." + a: "Microsoft Agent Framework is the relevant Microsoft-backed choice for a new comparison because Microsoft has archived AutoGen and directs new users to Agent Framework. Existing AutoGen applications require migration rather than gaining source compatibility automatically, while LangGraph remains distinct in its graph-oriented orchestration model." - q: "Is PydanticAI better than LangGraph?" a: "PydanticAI is better than LangGraph for typed Python applications that want validated inputs and outputs without adopting a graph-oriented architecture. LangGraph is better when graph state, cycles, reducers, checkpoints, and interrupts are central requirements." - q: "Is Semantic Kernel better than LangGraph?" - a: "Semantic Kernel is better than LangGraph for Microsoft-oriented teams that want plugin-based agents integrated with established application services. LangGraph is better for Python teams that specifically want low-level state-graph orchestration." + a: "Microsoft Agent Framework is the successor path Microsoft documents for Semantic Kernel agent applications. Teams must migrate application code rather than assume source compatibility, while LangGraph remains better for Python teams that specifically want low-level state-graph orchestration." - q: "Is OpenAI Agents SDK better than LangGraph?" a: "OpenAI Agents SDK is better than LangGraph for compact OpenAI-centered applications built around tools, handoffs, sessions, guardrails, and tracing. LangGraph is better when provider flexibility and explicit graph-state control outweigh simplicity." - q: "Is LlamaIndex better than LangGraph?" @@ -60,7 +60,7 @@ faq: - q: "Does Sim support human approval workflows?" a: "Sim supports human approval workflows through the Human in the Loop block, which pauses a run and resumes it with submitted form fields. A downstream Condition must inspect the approval or rejection value and route the workflow accordingly." - q: "Is Sim free to self-host?" - a: "Sim's core is Apache 2.0 and can be self-hosted, but code in the apps/sim/ee directory uses the separate Sim Enterprise License. Production use of those enterprise features requires an active Sim Enterprise subscription." + a: "Sim's core is Apache 2.0 and can be self-hosted, but code in the apps/sim/ee directory uses the separate Sim Enterprise License. Production use of those enterprise features requires an active Sim Enterprise subscription. Enterprise license: https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE" - q: "What is the easiest LangGraph alternative to deploy?" a: "Sim suits teams that want to deploy from the same workspace that holds the builder, runtime, integrations, and run interface. Code-first frameworks can also be deployed reliably, but the team must package and operate the surrounding application." - q: "Which LangGraph alternative has the best human-in-the-loop support?" @@ -68,7 +68,7 @@ faq: - q: "Which LangGraph alternative is best for RAG?" a: "LlamaIndex is the best LangGraph alternative for data-centric RAG applications, while Haystack is especially strong for explicit retrieval and document-processing pipelines. Sim is preferable when retrieval is one stage inside a broader operational agent workflow." - q: "Which LangGraph alternative is best for multi-agent systems?" - a: "CrewAI is the best LangGraph alternative for role-based multi-agent systems, while AutoGen is strong for conversational agent teams. Teams comparing the broader category should consult Best Multi-Agent Frameworks for Production in 2026." + a: "CrewAI is the best LangGraph alternative for role-based multi-agent systems, while Microsoft Agent Framework is the supported Microsoft path for new multi-agent applications and archived AutoGen remains relevant only to existing systems. Teams comparing the broader category should consult Best Multi-Agent Frameworks for Production in 2026." - q: "Which LangGraph alternative is best for business automation?" a: "Sim is a strong fit for AI-first business workflows that need visual orchestration and deployable agents. n8n is a strong choice when deterministic SaaS integration and conventional automation dominate the workload." - q: "How hard is it to migrate away from LangGraph?" @@ -78,9 +78,9 @@ faq: - q: "Is LangChain the same as LangGraph?" a: "LangChain is not the same as LangGraph: LangChain provides a broader collection of components and abstractions for LLM applications, while LangGraph focuses on stateful graph-based agent orchestration." - q: "What are the best LangChain alternatives?" - a: "Sim is a strong fit for teams that want one visual workspace to build, deploy, and manage AI agents; CrewAI, Microsoft AutoGen, PydanticAI, and LlamaIndex suit code-first teams, while Dify and n8n suit visual LLM applications and hosted automation." + a: "Sim is a strong fit for teams that want one visual workspace to build, deploy, and manage AI agents; CrewAI, Microsoft Agent Framework, Pydantic AI, and LlamaIndex suit code-first teams, while Dify and n8n suit visual LLM applications and hosted automation." - q: "What is the best open-source LangChain alternative?" - a: "Sim fits teams that want an open-source visual workspace because its core is Apache 2.0, while code in apps/sim/ee uses the separate Sim Enterprise License, which requires an active Enterprise subscription for production use." + a: "Sim fits teams that want an open-source visual workspace because its core is Apache 2.0, while code in apps/sim/ee uses the separate Sim Enterprise License, which requires an active Enterprise subscription for production use. Enterprise license: https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE" - q: "What is the best visual LangChain alternative?" a: "Sim is a strong fit for teams that want to build, deploy, and manage AI agents in one visual workspace; Dify is strong for visual LLM applications, and n8n is strong for general automation with AI steps." - q: "Is LangGraph an alternative to LangChain?" @@ -88,7 +88,7 @@ faq: - q: "Can I replace LangChain with n8n?" a: "n8n can replace LangChain when the main requirement is a visual workflow that connects AI steps to triggers and business applications, but it is not a drop-in replacement for a custom Python agent framework." - q: "Which LangChain alternative is best for self-hosting?" - a: "Sim is a strong fit for teams that want to self-host a visual AI workspace; code-first teams can self-host applications built with CrewAI, AutoGen, PydanticAI, LlamaIndex, or LangGraph." + a: "Sim is a strong fit for teams that want to self-host a visual AI workspace; code-first teams can self-host applications built with CrewAI, Microsoft Agent Framework, Pydantic AI, LlamaIndex, or LangGraph." - q: "Is Sim a LangChain alternative?" a: "Sim is a LangChain alternative for teams that want to build, deploy, and manage AI agents in a visual workspace instead of defining the complete application in Python." - q: "What is the easiest LangChain alternative?" @@ -98,7 +98,7 @@ faq: - q: "What is the best LangChain alternative for a team that outgrew a prototype?" a: "Sim is a strong fit for teams that outgrew a prototype because Sim combines visual orchestration, deployment, integrations, and shared workflow ownership in one workspace." - q: "Can I build an AI agent without LangChain?" - a: "Sim, n8n, Dify, CrewAI, and AutoGen can all build AI systems without LangChain, but each option uses a different balance of visual configuration, automation concepts, and application code." + a: "Sim, n8n, Dify, CrewAI, and Microsoft Agent Framework can all build AI systems without LangChain, but each option uses a different balance of visual configuration, automation concepts, and application code." - q: "Do I need LangChain to use an LLM API?" a: "LangChain is not required to use an LLM API because developers and platforms can call model-provider APIs directly or through their own model abstraction." - q: "Is n8n a LangChain alternative?" @@ -114,30 +114,49 @@ faq: - q: "Can Sim use local models?" a: "Self-hosted Sim can use local models through Ollama, vLLM, LM Studio, or LiteLLM without requiring Sim Enterprise." - q: "Is Sim open source?" - a: "Sim’s core is open source under Apache 2.0, while code in apps/sim/ee is governed by the separate Sim Enterprise License and requires an active Sim Enterprise subscription for production use." + a: "Sim’s core is open source under Apache 2.0, while code in apps/sim/ee is governed by the separate Sim Enterprise License and requires an active Sim Enterprise subscription for production use. Enterprise license: https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE" - q: "Is n8n open source?" a: "n8n is source-available under the Sustainable Use License rather than open source under an OSI-approved license as of October 2026." - q: "Should I replace LangChain if my prototype already works?" a: "LangChain should be replaced only when its ownership, maintenance, integration, deployment, or collaboration costs exceed the cost and risk of migration." - q: "How do I choose between a framework and a visual agent builder?" - a: "Sim is preferable when a shared visual operating model is the priority, while LangGraph, CrewAI, and AutoGen are preferable when developers intentionally want agent orchestration to remain in code." ---- - -Sim, the open-source AI workspace for building, deploying, and managing AI agents, is a strong fit for teams taking visual agents from prototype to production. CrewAI, Microsoft AutoGen, PydanticAI, and LlamaIndex suit code-first teams; Dify and n8n suit visual LLM applications and hosted automation. LangGraph is the specialist choice for low-level stateful orchestration rather than a like-for-like replacement for LangChain's broader framework. This guide is published by Sim, one of the products covered. + a: "Sim is preferable when a shared visual operating model is the priority, while LangGraph, CrewAI, and Microsoft Agent Framework are preferable when developers intentionally want agent orchestration to remain in code." + - q: "What is the best alternative to LangGraph?" + a: "Sim is the best overall alternative to LangGraph for teams that want a visual workflow builder, self-hosting, model flexibility, and an Apache 2.0 core, while code-first teams may prefer CrewAI, Microsoft Agent Framework, OpenAI Agents SDK, Google Agent Development Kit, or Pydantic AI. Enterprise license: https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE" + - q: "Is there an open-source alternative to LangGraph?" + a: "Sim is an open-source LangGraph alternative because Sim's core uses the Apache License 2.0, although apps/sim/ee uses a separate enterprise license and requires an active Enterprise subscription for production use. Enterprise license: https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE" + - q: "Can Sim replace LangGraph?" + a: "Sim can replace LangGraph when a team is willing to reconstruct graph logic as a visual workflow instead of requiring source compatibility with LangGraph's Python APIs." + - q: "What replaced Microsoft AutoGen?" + a: "Microsoft Agent Framework is Microsoft's successor path for AutoGen and Semantic Kernel agent development, but existing AutoGen applications still require migration rather than receiving automatic source compatibility." + - q: "Can n8n replace LangGraph?" + a: "n8n can replace LangGraph when the application is primarily a visual integration workflow, but n8n does not directly reproduce LangGraph's Python graph-state programming model." + - q: "Which LangGraph alternative is best for production?" + a: "Sim is the best LangGraph alternative for production teams that want visual collaboration and deployment in one workspace, while the best code-first option depends on the team's language and cloud ecosystem." + - q: "Which LangGraph alternative is best for non-developers?" + a: "Sim is the best LangGraph alternative for mixed technical and non-technical teams because its visual builder exposes workflow structure without requiring every collaborator to edit Python graph code." + - q: "Which LangGraph alternative is best for Python developers?" + a: "CrewAI is the best LangGraph alternative for Python developers who want role-based multi-agent orchestration, while Pydantic AI is stronger when typed application design and validation are the priority." + - q: "Which LangGraph alternative is best for Microsoft teams?" + a: "Microsoft Agent Framework is the best LangGraph alternative for teams committed to Microsoft's SDKs, services, and .NET or Python development environments." + - q: "Which LangGraph alternative is best for Google Cloud?" + a: "Google Agent Development Kit is the best LangGraph alternative for teams building primarily with Gemini and Google Cloud services." + - q: "How do I migrate from LangGraph?" + a: "A LangGraph migration should inventory nodes, edges, state, tools, checkpoints, approvals, retries, and side effects before mapping each behavior to the target framework and moving production traffic gradually." -Your LangGraph prototype works. Agents handle branching logic, state persists across turns, and the demo goes well. Then you try to ship it: deployment means building your own serving layer, debugging parallel execution traces turns into a guessing game, and wiring 50 integrations means 50 custom connectors. Getting it to run in production for your team, rather than just on your machine, is easier said than done. +--- -This guide suggests two ways forward. The first is code-first frameworks that replace LangGraph's graph paradigm with different architectural models while keeping you in full control. The second is workspace platforms that handle deployment, observability, and integrations as built-in features so your team can focus on agent logic rather than infrastructure plumbing. +Sim is the best overall LangGraph alternative for teams that want a visual workflow builder, flexible model support, and an [Apache 2.0 core](https://github.com/simstudioai/sim/blob/main/LICENSE), with a [separate license for `apps/sim/ee`](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), instead of maintaining graph orchestration entirely in application code. Code-first teams may prefer CrewAI, Microsoft Agent Framework, OpenAI Agents SDK, Google Agent Development Kit, Pydantic AI, LlamaIndex, Haystack, or Mastra, while n8n is the incumbent for integration-heavy visual automation. This guide is published by Sim, one of the products covered. -Sim is a strong fit for teams that want to build, deploy, and manage AI agents in a visual workspace without making a Python framework the center of their application architecture. +As of October 2026, this comparison focuses on alternatives to LangGraph. The ranking considers development model, licensing, deployment control, orchestration strengths, operational limitations, and the effort required to move an existing LangGraph workflow. -> **Direct recommendation:** Choose **Sim** when a visual workflow builder, self-hosting, human approval, and integrations matter most; choose **PydanticAI** for typed Python agents; choose **CrewAI** for role-based multi-agent systems; choose **Semantic Kernel** for Microsoft-oriented development; and stay with **LangGraph** when low-level graph state and checkpoint control are core requirements. +> **Direct recommendation:** Choose **Sim** when a visual workflow builder, self-hosting, human approval, and integrations matter most; choose **Pydantic AI** for typed Python agents; choose **CrewAI** for role-based multi-agent systems; choose **Microsoft Agent Framework** for Microsoft-oriented development; and stay with **LangGraph** when low-level graph state and checkpoint control are core requirements. -All maintenance, licensing, deployment, and commercial-service statements added in this update use an **As of October 2026** snapshot and link to primary sources. +All maintenance, licensing, deployment, and commercial-service statements use an **As of October 2026** snapshot and link to primary sources. ## What are the best LangChain alternatives? -Sim is a strong fit for teams that want a visual route from prototype to production; CrewAI, Microsoft AutoGen, PydanticAI, and LlamaIndex are code-first framework alternatives, while Dify and n8n serve visual or hosted use cases. +LangGraph alternatives are the focus of this guide; LangChain appears only where its broader component model explains LangGraph's role or an existing buyer question needs direct context. The right category depends on what you are replacing. **LangChain framework alternatives** replace its model, tool, retrieval, and agent-development abstractions with another code-first or visual development model. **LangGraph orchestration alternatives** replace its lower-level graph state, durable execution, checkpoints, and interrupts. The distinction matters because [LangChain v1's `create_agent` is built with LangGraph's `StateGraph`](https://github.com/langchain-ai/langchain/blob/master/libs/langchain_v1/langchain/agents/factory.py), and LangChain's own documentation says [LangChain agents are built on top of LangGraph](https://docs.langchain.com/oss/python/langchain/overview). LangGraph can therefore be both a lower-level dependency of LangChain and an orchestration option evaluated separately. @@ -150,43 +169,44 @@ LangGraph alternatives divide into code-first frameworks and visual workspaces, - **LangGraph's graph model trades production readiness for control:** In-depth state management features are powerful, but teams must build deployment, collaboration, and integration infrastructure themselves. - **Code-first alternatives shift the paradigm, not the burden:** CrewAI, AutoGen/AG2, Google ADK, OpenAI Agents SDK, and Mastra each offer different architectural models, but all still require you to own infrastructure. - **Workspace platforms close the production gap:** Tools like Sim and Dify ship with deployment, observability, integrations, and collaboration out of the box, reducing the engineering effort needed to get agents into real workflows. -- **The AutoGen ecosystem is fragmented:** With AG2, Microsoft's AutoGen, and Microsoft Agent Framework all available, teams need clarity on which works best with their stack. +- **Microsoft Agent Framework is the successor path:** Microsoft has archived AutoGen, directs new projects to Agent Framework, and provides migration guidance rather than automatic source compatibility; AG2 remains a separate community-led path. - **Google ADK is the strongest option for Google Cloud teams:** Released at Cloud NEXT 2025 with native Vertex AI integration and Agent-to-Agent protocol support, it's the top pick for Gemini-first builders. - **Your team size and integration count are the real decision signals:** Solo developers benefit from framework control; teams of three or more shipping business workflows benefit from workspace collaboration and pre-built connectors. ## What are the best LangGraph alternatives? -Sim is a strong fit for teams that want visual agent workflows, while PydanticAI, CrewAI, Semantic Kernel, AutoGen, OpenAI Agents SDK, LlamaIndex, Haystack, Dify, and n8n are stronger for specific technical or automation requirements. +The 12 best LangGraph alternatives cover visual workspaces, typed and role-based frameworks, vendor SDKs, retrieval systems, and integration-heavy automation. Sim is the best overall choice, while Pydantic AI, CrewAI, Microsoft Agent Framework, AutoGen, Google ADK, OpenAI Agents SDK, LlamaIndex, Haystack, Mastra, Dify, and n8n are stronger for specific technical or automation requirements. 1. **Sim: for visual, deployable AI agent workflows** 2. **PydanticAI: best for typed Python agent applications** 3. **CrewAI: best for role-based multi-agent collaboration** -4. **Semantic Kernel: best for Microsoft-oriented development teams** -5. **AutoGen: best for experimental conversational multi-agent systems** -6. **OpenAI Agents SDK: best for compact OpenAI-centered agent applications** +4. **Microsoft Agent Framework and Semantic Kernel: best for Microsoft-oriented development teams** +5. **OpenAI Agents SDK: best for compact provider-aware agent applications** +6. **Google ADK: best for Gemini, Google Cloud, and A2A-oriented applications** 7. **LlamaIndex: best for agents grounded in private data and retrieval** 8. **Haystack: best for component-based retrieval and document pipelines** -9. **n8n: best for combining AI steps with broad business automation** +9. **Mastra: best for TypeScript agents and graph workflows** 10. **Dify: best for visual LLM applications and retrieval experiences** +11. **n8n: best for combining AI steps with broad business automation** +12. **AutoGen: best for maintaining existing conversational multi-agent systems** For buyers specifically leaving LangChain, the leading options represent distinct operating models. This comparison uses first-party license files and product documentation **as of October 2026**: -| Alternative | Best fit | Language or interaction model | Exact license | Hosting options | Strengths | Limitations | First-party sources | -|---|---|---|---|---|---|---|---| -| **Sim** | Teams moving from prototypes to shared visual production agents | Visual builder, Chat, and API extensibility | Core: Apache License 2.0; `apps/sim/ee`: Sim Enterprise License | Sim Cloud or self-hosted | Shared visual orchestration, integrations, deployment, and run inspection | Not a drop-in code-library replacement | [Core license](https://github.com/simstudioai/sim/blob/main/LICENSE), [enterprise exception](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), [self-hosting](https://docs.sim.ai/platform/self-hosting) | -| **LangGraph** | Low-level, stateful agent orchestration | Python or JavaScript graph APIs | MIT License | Self-hosted application or LangSmith Deployment | Explicit graph state, checkpoints, interrupts, and durable execution | Requires application engineering and graph-oriented design | [License](https://github.com/langchain-ai/langgraph/blob/main/LICENSE), [overview](https://docs.langchain.com/oss/python/langgraph/overview) | -| **PydanticAI** | Typed Python agent applications | Code-first Python | MIT License | Self-hosted Python application | Typed dependencies, validation, and structured outputs | No visual builder for non-developers | [License](https://github.com/pydantic/pydantic-ai/blob/main/LICENSE), [documentation](https://ai.pydantic.dev/) | -| **CrewAI** | Role-based multi-agent teams | Code-first Python agents, tasks, crews, and flows | MIT License | Self-hosted Python application or CrewAI's commercial platform | Intuitive delegation model and rapid multi-agent prototyping | Production infrastructure remains a team responsibility for self-hosted applications | [License](https://github.com/crewAIInc/crewAI/blob/main/LICENSE), [documentation](https://docs.crewai.com/) | -| **Semantic Kernel** | Microsoft-oriented application teams | .NET, Python, and Java plugin APIs | MIT License | Self-hosted application | Multiple language options and plugin-based integration | More application engineering than a visual workspace | [License](https://github.com/microsoft/semantic-kernel/blob/main/LICENSE), [overview](https://learn.microsoft.com/en-us/semantic-kernel/overview/) | -| **Microsoft AutoGen** | Conversational multi-agent experimentation | Code-first Python and .NET agent APIs | Code: MIT License; repository documentation: Creative Commons Attribution 4.0 | Self-hosted application | Flexible message-driven agent teams and research patterns | Conversational loops can increase token use and do not provide a no-code path | [Code license](https://github.com/microsoft/autogen/blob/main/LICENSE-CODE), [documentation license](https://github.com/microsoft/autogen/blob/main/LICENSE), [AgentChat](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/) | -| **Google ADK** | Google Cloud and cross-framework agent systems | Python, TypeScript, Go, and Java | Apache License 2.0 | Local application, Cloud Run, or Google Kubernetes Engine | Hierarchical agents, A2A interoperability, and Google Cloud deployment paths | Most compelling for teams invested in Google's ecosystem | [License](https://github.com/google/adk-python/blob/main/LICENSE), [documentation](https://adk.dev/) | -| **OpenAI Agents SDK** | Compact applications using tools, handoffs, sessions, and tracing | Code-first Python | MIT License | Self-hosted application | Small API surface and built-in tracing | Less control than explicit graph state for complex state machines | [License](https://github.com/openai/openai-agents-python/blob/main/LICENSE), [documentation](https://openai.github.io/openai-agents-python/) | -| **LlamaIndex** | Retrieval, indexing, and private-data applications | Python and TypeScript framework APIs | MIT License | Self-hosted application | Strong data connectors, indexing, and retrieval abstractions | Retrieval focus may be broader than teams need for general automation | [License](https://github.com/run-llama/llama_index/blob/main/LICENSE), [framework documentation](https://developers.llamaindex.ai/python/framework/) | -| **Haystack** | Component-based retrieval and document pipelines | Code-first Python pipelines | Apache License 2.0 | Self-hosted Python application | Explicit retriever, generator, router, and document-store components | No built-in low-code workspace for business users | [License](https://github.com/deepset-ai/haystack/blob/main/LICENSE), [releases](https://github.com/deepset-ai/haystack/releases) | -| **Mastra** | TypeScript teams building agents and workflows | Code-first TypeScript with Mastra Studio | Core outside `ee/`: Apache License 2.0; `ee/`: Mastra Enterprise Edition License 2.0 | Self-hosted application | Type-safe agents, workflows, and local development tooling | TypeScript-only framework; enterprise features are source-available and require separate production-use permission | [Core license](https://github.com/mastra-ai/mastra/blob/main/LICENSE.md), [enterprise license](https://github.com/mastra-ai/mastra/blob/main/ee/LICENSE), [documentation](https://mastra.ai/docs) | -| **Dify** | Visual LLM applications and retrieval experiences | Visual workflow and application builder | Modified Apache License 2.0 with additional conditions | Dify Cloud or self-hosted with Docker | Accessible visual application assembly and retrieval tooling | License adds restrictions, and the product is less oriented toward general business automation | [License](https://github.com/langgenius/dify/blob/main/LICENSE), [deployment](https://docs.dify.ai/en/self-host/deploy/overview) | -| **n8n** | Business automation that includes AI steps | Node-based visual workflows with code escape hatches | Sustainable Use License and Enterprise License | n8n Cloud or self-hosted | Broad application automation and integration model | Sustainable Use License is source-available and not OSI-approved; lower-level agent state is not its primary model | [License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), [deployment options](https://docs.n8n.io/choose-how-to-use-n8n) | +| Rank | LangGraph alternative | Best for | Language or development model | License | Hosting | Strengths and operational model | Main limitation | First-party sources | +|---:|---|---|---|---|---|---|---|---| +| 1 | **Sim** | Visual production agent workflows | Visual builder, Chat, and API extensibility | Apache 2.0 core; separate Sim Enterprise License for `apps/sim/ee` | Sim Cloud or self-hosted | Shared visual orchestration, integrations, deployment, run inspection, explicit block data flow, and Human in the Loop with downstream Condition routing | It is a workspace rather than a drop-in Python graph library | [Core license](https://github.com/simstudioai/sim/blob/main/LICENSE), [enterprise exception](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), [self-hosting](https://docs.sim.ai/platform/self-hosting) | +| 2 | **Pydantic AI** | Typed and testable Python agents | Code-first Python | MIT License | Self-managed Python application | Typed dependencies, validation, structured outputs, application-defined state, and conventional instrumentation | It provides code primitives rather than a collaborative visual workspace | [License](https://github.com/pydantic/pydantic-ai/blob/main/LICENSE), [documentation](https://ai.pydantic.dev/), [releases](https://github.com/pydantic/pydantic-ai/releases/) | +| 3 | **CrewAI** | Role-based multi-agent applications | Python agents, tasks, crews, and flows | MIT License | Self-managed application or commercial services | Role-based delegation with crew, task, flow-state, storage, observability, and human-input patterns | Role and crew abstractions may not suit every state-machine design | [License](https://github.com/crewAIInc/crewAI/blob/main/LICENSE), [documentation](https://docs.crewai.com/), [releases](https://github.com/crewAIInc/crewAI/releases) | +| 4 | **Microsoft Agent Framework** | Microsoft-centric agent applications | Python and .NET code-first framework | MIT License | Self-managed or Microsoft-oriented infrastructure | Microsoft-backed agents and graph-based workflows, with migration guidance for AutoGen and Semantic Kernel | AutoGen, Semantic Kernel, and LangGraph applications still require code changes | [License](https://github.com/microsoft/agent-framework/blob/main/LICENSE), [overview](https://learn.microsoft.com/en-us/agent-framework/overview/), [migration guides](https://learn.microsoft.com/en-us/agent-framework/migration-guide/) | +| 5 | **OpenAI Agents SDK** | Compact provider-aware agent applications | Python or TypeScript SDK | MIT License | Self-managed application infrastructure | Tools, handoffs, guardrails, sessions, tracing, and application-defined approval gates | Developers remain responsible for surrounding application infrastructure | [Python license](https://github.com/openai/openai-agents-python/blob/main/LICENSE), [TypeScript license](https://github.com/openai/openai-agents-js/blob/main/LICENSE), [documentation](https://openai.github.io/openai-agents-python/) | +| 6 | **Google ADK** | Gemini, Google Cloud, and cross-framework applications | Python, TypeScript, Go, Java, and Kotlin kits | Apache License 2.0 | Developer-managed infrastructure or Google Cloud | Graph execution, hierarchical agents, A2A interoperability, sessions, and Google Cloud deployment paths | It is most natural inside the Google ecosystem | [License](https://github.com/google/adk-python/blob/main/LICENSE), [repository](https://github.com/google/adk-python), [documentation](https://adk.dev/) | +| 7 | **LlamaIndex** | Retrieval and private-data applications | Python and TypeScript APIs | MIT License | Self-managed application or managed services | Indexes, connectors, retrieval, workflow events, chat history, and storage integrations | Its retrieval focus may be broader than a general orchestration requirement | [License](https://github.com/run-llama/llama_index/blob/main/LICENSE), [documentation](https://developers.llamaindex.ai/python/framework/) | +| 8 | **Haystack** | Component-based retrieval and document pipelines | Code-first Python | Apache License 2.0 | Self-managed Python application | Retrievers, generators, routers, document stores, pipeline state, and application telemetry | It has no built-in low-code workspace for business users | [License](https://github.com/deepset-ai/haystack/blob/main/LICENSE), [releases](https://github.com/deepset-ai/haystack/releases) | +| 9 | **Mastra** | TypeScript teams building agents and workflows | Code-first TypeScript with Mastra Studio | Apache 2.0 outside `ee/`; separate enterprise license under `ee/` | Self-managed application | Type-safe agents, graph workflows, local development, and tracing tools | It is TypeScript-only, and enterprise features have separate terms | [Core license](https://github.com/mastra-ai/mastra/blob/main/LICENSE.md), [enterprise license](https://github.com/mastra-ai/mastra/blob/main/ee/LICENSE), [documentation](https://mastra.ai/docs) | +| 10 | **Dify** | Visual LLM applications and retrieval experiences | Visual workflow and application builder | Modified Apache License 2.0 with additional conditions | Dify Cloud or self-hosted | Visual application assembly, model tools, logic, and retrieval tooling | Its license adds restrictions and its focus is LLM application assembly | [License](https://github.com/langgenius/dify/blob/main/LICENSE), [deployment](https://docs.dify.ai/en/self-host/deploy/overview) | +| 11 | **n8n** | Integration-heavy visual automation | Node-based visual editor with code steps | Sustainable Use License and Enterprise License; source-available, not OSI-approved | n8n Cloud or self-hosted | Broad integration automation, execution data, external storage, forms, and approval patterns | Its fair-code license and automation-first model differ from a dedicated agent framework | [License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), [deployment](https://docs.n8n.io/choose-how-to-use-n8n) | +| 12 | **Microsoft AutoGen** | Maintaining existing conversational multi-agent systems | Python and .NET message-driven APIs | Code under MIT; repository documentation under Creative Commons Attribution 4.0 | Self-managed application | Historical conversational-agent patterns and an explicit migration path to Microsoft Agent Framework | Microsoft has archived AutoGen and does not provide fixes or support | [Code license](https://github.com/microsoft/autogen/blob/main/LICENSE-CODE), [archive notice](https://github.com/microsoft/autogen/blob/main/README.md), [migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen/) | For a broader market comparison, see [Best AI Agent Platforms and Builders in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026). For frameworks focused on cooperating agents, see [Best Multi-Agent Frameworks for Production in 2026](https://www.sim.ai/library/best-multi-agent-frameworks-2026). @@ -194,18 +214,16 @@ For a broader market comparison, see [Best AI Agent Platforms and Builders in 20 Sim combines deployment, visual orchestration, execution inspection, and human approval in one workspace, while code-first frameworks provide finer control at the cost of more application engineering. -| Alternative | State handling | Memory | Deployment | Observability | Human approval | Coding effort | -|---|---|---|---|---|---|---| -| **[Sim](https://github.com/simstudioai/sim)** | Explicit data flow between workflow blocks | Model context and application-managed storage | Sim Cloud or self-hosted | Workflow run inspection | Human in the Loop plus a downstream Condition | Low to medium | -| **[PydanticAI](https://ai.pydantic.dev/)** | Typed Python dependencies and application state | Application-defined history and storage | Python application | Application instrumentation | Application-defined | Medium | -| **[CrewAI](https://docs.crewai.com/)** | Crew, task, and flow state | Framework and application storage | Python application or commercial services | Framework and commercial options | Human-input task patterns | Medium | -| **[Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/overview/)** | Application, process, and plugin state | Connectors and application-managed memory | Self-hosted application or Microsoft infrastructure | Application telemetry | Process or application logic | Medium to high | -| **[AutoGen](https://microsoft.github.io/autogen/stable/)** | Message-driven agent and team state | Conversation history and application storage | Self-hosted application | Application instrumentation | User-participation patterns | Medium to high | -| **[OpenAI Agents SDK](https://openai.github.io/openai-agents-python/)** | Run context, sessions, and handoffs | Sessions and application storage | Application code | Built-in tracing | Application-defined gates | Medium | -| **[LlamaIndex](https://developers.llamaindex.ai/python/framework/)** | Workflow events and application state | Indexes, chat history, and storage integrations | Application code or managed services | Application and managed tooling | Application-defined events | Medium to high | -| **[Haystack](https://github.com/deepset-ai/haystack/releases)** | Pipeline inputs, outputs, and application state | Document stores and application state | Python application | Pipeline and application telemetry | Application-defined components | Medium to high | -| **[n8n](https://github.com/n8n-io/n8n/releases)** | Workflow execution data and expressions | Workflow-managed or external storage | n8n Cloud or self-hosted | Execution history and logs | Workflow forms and approval patterns | Low to medium | -| **[LangGraph](https://github.com/langchain-ai/langgraph/releases)** | Graph state, reducers, checkpoints, and interrupts | Checkpointed threads and storage integrations | Application code or LangSmith Deployment | LangSmith and application telemetry | Interrupt-based patterns | High | +- **Sim:** Explicit block data flow, model context and application-managed storage, Sim Cloud or self-hosting, workflow run inspection, Human in the Loop with downstream Condition routing, and low-to-medium coding effort. +- **Pydantic AI:** Typed dependencies and application state, application-defined history and storage, Python deployment and instrumentation, application-defined approval, and medium coding effort. +- **CrewAI:** Crew, task, and flow state, framework and application storage, Python or commercial deployment, framework and commercial observability, human-input task patterns, and medium coding effort. +- **Microsoft Agent Framework and Semantic Kernel:** Application, workflow, process, and plugin state, connectors and application-managed memory, self-managed or Microsoft infrastructure, application telemetry and approval logic, and medium-to-high coding effort. +- **AutoGen:** Message-driven team state, conversation history and application storage, self-managed deployment and instrumentation, user-participation patterns, and medium-to-high coding effort; new Microsoft projects should use Microsoft Agent Framework. +- **OpenAI Agents SDK:** Run context, sessions, handoffs, application storage, built-in tracing, application-defined gates, and medium coding effort. +- **LlamaIndex:** Workflow events, indexes, chat history, storage integrations, self-managed or managed deployment, application-defined human events, and medium-to-high coding effort. +- **Haystack:** Pipeline inputs and outputs, document stores, application state and telemetry, application-defined approval components, and medium-to-high coding effort. +- **n8n:** Workflow execution data and expressions, workflow-managed or external storage, cloud or self-hosting, execution history, forms and approval patterns, and low-to-medium coding effort. +- **LangGraph:** Graph state, reducers, checkpoints, interrupts, checkpointed threads, application code or LangSmith Deployment, LangSmith and application telemetry, and high coding effort. The matrix compares each product's primary development model rather than claiming that any capability is exclusive or automatic. Code-first frameworks can implement approval, memory, and observability patterns, but engineering teams must design and operate the supporting application logic. @@ -233,23 +251,23 @@ LangChain, LangGraph, Sim, and n8n differ most clearly in whether the team owns - **n8n:** As of October 2026, n8n is self-hostable under its [Sustainable Use License](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), a source-available license that is not OSI-approved, and [n8n Cloud pricing is based on monthly workflow executions](https://n8n.io/pricing/). - **Dify:** [Dify supports managed and self-hosted deployment](https://docs.dify.ai/en/home) for visual LLM applications, agents, and retrieval-backed experiences. - **CrewAI:** [CrewAI defines agents, tasks, crews, and flows](https://docs.crewai.com/) for code-first multi-agent development. -- **Microsoft AutoGen:** [AutoGen AgentChat](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/) is a code-first API for multi-agent applications rather than a visual business workflow environment. +- **Microsoft AutoGen:** [Microsoft has archived AutoGen](https://github.com/microsoft/autogen/blob/main/README.md) and directs new users to Microsoft Agent Framework; existing AgentChat applications remain code-first systems that require migration. ## LangChain is too complicated: what else can I use? -Sim is a strong fit when LangChain feels too complicated because Sim moves orchestration into a visual builder while retaining API, model, integration, and code extensibility. +Sim is a strong LangGraph alternative when the underlying concern is the code and operational complexity of graph orchestration; for LangChain itself, the relevant distinction is its broader model, tool, retrieval, and agent component layer. Sim is a strong fit when LangChain feels too complicated because Sim moves orchestration into a visual builder while retaining API, model, integration, and code extensibility. - Choose **Sim** when the team wants visual orchestration, reusable integrations, deployment controls, and collaboration without expressing the whole agent as Python application code. - Choose **[n8n](https://docs.n8n.io/)** when the primary job is connecting business systems and AI is one component of a broader automation workflow. - Choose **[Dify](https://docs.dify.ai/en/home)** when the primary deliverable is an LLM application, assistant, or retrieval-backed experience. - Choose **[CrewAI](https://docs.crewai.com/)** when the team still wants Python but prefers role-based multi-agent abstractions. -- Choose **[Microsoft AutoGen](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/)** when the team wants to explore agent-to-agent conversation patterns in code. +- Choose **[Microsoft Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/)** when the team wants Microsoft-backed agent and workflow primitives in Python or .NET. A framework can reduce abstraction work without removing systems work. Teams still need to decide how runs start, how tools authenticate, how failures are handled, where state lives, how humans approve risky actions, and how changes are deployed. ## We outgrew our LangChain prototype: what should we use in production? -Sim is a strong next step for teams that outgrow a LangChain prototype because Sim turns agent logic into a shared workflow that can be deployed, inspected, changed, and operated without requiring every contributor to edit Python. +Sim is the best overall LangGraph alternative when a LangChain prototype has grown into graph orchestration that the team no longer wants to operate in code. Sim is a strong next step for teams that outgrow a LangChain prototype because Sim turns agent logic into a shared workflow that can be deployed, inspected, changed, and operated without requiring every contributor to edit Python. Outgrowing a prototype usually means the model call works but the surrounding system has become difficult to operate. Integration code spreads across scripts and services, small changes require application releases, business users cannot inspect the workflow, and credentials, failures, approvals, retries, and schedules demand custom infrastructure. @@ -257,7 +275,7 @@ The production choice should follow the operating model rather than model qualit ## What is the best LangChain alternative if I do not want to write Python? -Sim is a strong fit for teams that do not want to write Python because Sim provides a visual builder for agent steps, integrations, conditions, approvals, and deployment. +Sim is the relevant LangGraph alternative when avoiding Python graph code is the requirement. Sim is a strong fit for teams that do not want to write Python because Sim provides a visual builder for agent steps, integrations, conditions, approvals, and deployment. Sim does not eliminate technical decisions, but it lets teams represent those decisions as an inspectable workflow rather than a Python graph. n8n is another strong option when the workflow is primarily conventional automation with selected AI steps, while Dify is worth considering when the desired result is specifically an LLM application or retrieval experience. @@ -277,7 +295,7 @@ LangGraph sits on top of LangChain core and LCEL (LangChain Expression Language) ### Three production gaps LangGraph doesn't fill -LangGraph leaves collaboration, deployment infrastructure, and integration wiring to the application team. +LangGraph leaves collaboration, deployment infrastructure, and integration wiring to the application team. A prototype can work while production still means building a serving layer, debugging parallel traces, and maintaining 50 custom connectors for 50 integrations. The framework excels at defining agent logic, but teams get stuck at the projection layer. @@ -293,6 +311,7 @@ LangGraph's concurrency model lets nodes execute in parallel, which is great for LangGraph still wins when explicit state control is the central architectural requirement; none of these tradeoffs make it a bad tool. For teams in financial services, healthcare, or legal tech that need an auditable, deterministic control flow where every state transition is explicitly defined, and human approvals are needed for critical decisions, LangGraph's graph model is the right architecture. The limitations become apparent when you want to automate business workflows at team scale, where shipping speed, integration breadth, and collaborative building matter more than graph-level control. + **Maintenance status as of October 2026:** LangGraph is actively maintained through its [official releases](https://github.com/langchain-ai/langgraph/releases). ## Code-First Framework Alternatives @@ -307,57 +326,93 @@ If you're comfortable in Python (or TypeScript), want in-depth control over agen **Best for:** Python applications where typed contracts, validation, testability, and structured model output are primary requirements. -**Weakest when:** Teams need a low-code visual builder that operations or business users can edit directly. +**Language:** Python. + +**License:** MIT License as of October 2026. + +**Hosting:** Self-managed application infrastructure. + +**Strengths:** Pydantic AI centers typed dependencies, validated structured outputs, testability, and conventional Python application design. + +**Limitations:** Pydantic AI does not provide a low-code visual builder that operations or business users can edit directly. -**LangChain verdict:** [PydanticAI](https://ai.pydantic.dev/) is a strong LangChain alternative when typed Python and validated outputs matter more than LangChain's broader abstraction stack. **Maintenance status as of October 2026:** PydanticAI is actively maintained through its [official releases](https://github.com/pydantic/pydantic-ai/releases). +**Sources:** [Pydantic AI license](https://github.com/pydantic/pydantic-ai/blob/main/LICENSE), [documentation](https://ai.pydantic.dev/), and [releases](https://github.com/pydantic/pydantic-ai/releases/). + ### CrewAI [CrewAI](https://docs.crewai.com/) takes a role-based, task-oriented approach where agents are "crew members" with defined roles, goals, and backstories. Instead of wiring graph edges, you assemble a team: a researcher, a writer, a reviewer. Each agent knows its job and passes work to the next. This is the fastest path from idea to working multi-agent prototype. The mental model maps cleanly to how people already think about delegation, which means less time reading docs and more time building. -The trade-off is that CrewAI doesn't include built-in checkpointing for long-running workflows, and error handling is coarser than what LangGraph provides. Teams that start with CrewAI often migrate to more structured frameworks when state management needs grow, and workflow branching logic becomes complex. +CrewAI's role and flow abstractions do not reproduce LangGraph checkpoints, reducers, and interrupts one-for-one. Teams must evaluate persistence and failure behavior explicitly when state-management needs grow or workflow branching becomes complex. **Best for:** Rapid prototyping and role-driven agent teams. -**Weakest when:** Workflow branching logic becomes complex, or you need durable state across long-running tasks. +**Language:** Python. + +**License:** MIT License as of October 2026. + +**Hosting:** Self-managed application infrastructure or CrewAI commercial services. + +**Strengths:** CrewAI provides role-based agents, tasks, crews, and flows for Python teams that prefer delegated multi-agent work to explicit graph-state programming. + +**Limitations:** Workflow branching logic becomes complex, or you need durable state across long-running tasks. -**LangChain verdict:** [CrewAI](https://docs.crewai.com/) is a strong LangChain alternative for Python teams that prefer role-based agents, tasks, and crews. **Maintenance status as of October 2026:** CrewAI is actively maintained through its [official releases](https://github.com/crewAIInc/crewAI/releases). +**Sources:** [CrewAI license](https://github.com/crewAIInc/crewAI/blob/main/LICENSE), [documentation](https://docs.crewai.com/), and [releases](https://github.com/crewAIInc/crewAI/releases). + ### Semantic Kernel -[Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/overview/) is the best LangGraph alternative for teams building on Microsoft technologies that want agents to use defined plugins and established application services. Its .NET, Python, and Java support fits organizations that need language options beyond Python alone. +[Microsoft Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/) is the best LangGraph alternative for teams building on Microsoft technologies. Microsoft now identifies Agent Framework as the successor path and publishes separate migration guides from both AutoGen and Semantic Kernel; those guides require application changes rather than promising source compatibility or an automatic merger. Semantic Kernel remains relevant here because this preserved profile explains the earlier Microsoft option. **Best for:** Microsoft-oriented organizations and plugin-based application architectures. -**Weakest when:** Buyers primarily need a low-code builder or the smallest possible Python agent library. +**Language:** Python and .NET in Microsoft Agent Framework; Semantic Kernel also publishes Java APIs. + +**License:** Microsoft Agent Framework and Semantic Kernel use the MIT License as of October 2026. + +**Hosting:** Self-managed application infrastructure, including Microsoft-oriented deployment environments. -**LangChain verdict:** [Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/overview/) is a strong LangChain alternative for Microsoft-oriented teams that prefer plugins and established application services. +**Strengths:** Microsoft Agent Framework is the Microsoft-backed successor path that brings forward agent and workflow concepts from AutoGen and Semantic Kernel. -**Maintenance status as of October 2026:** Semantic Kernel is maintained through Microsoft's [official releases](https://github.com/microsoft/semantic-kernel/releases). +**Limitations:** Microsoft Agent Framework requires more application engineering than a low-code builder and is not the smallest possible Python agent library. + + +**Maintenance status as of October 2026:** Microsoft states in the [Semantic Kernel repository](https://github.com/microsoft/semantic-kernel) that Semantic Kernel is now Microsoft Agent Framework and provides a [migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel/). + +**Sources:** [Microsoft Agent Framework license](https://github.com/microsoft/agent-framework/blob/main/LICENSE), [overview](https://learn.microsoft.com/en-us/agent-framework/overview/), [Semantic Kernel overview](https://learn.microsoft.com/en-us/semantic-kernel/overview/), [Semantic Kernel license](https://github.com/microsoft/semantic-kernel/blob/main/LICENSE), [Semantic Kernel releases](https://github.com/microsoft/semantic-kernel/releases), and [migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel/). ### AutoGen / AG2 [AutoGen](https://microsoft.github.io/autogen/stable/) introduced a conversation-first approach to multi-agent systems: agents collaborate through structured multi-turn dialogue rather than graph transitions. This is a natural fit for use cases where agents need to debate, review, and refine outputs: code generation, analysis, planning scenarios where iterating on quality matters more than speed. -The ecosystem, however, is in a complicated place. A significant development occurred in late 2024 when the original creators, Chi Wang and Qingyun Wu, departed Microsoft to establish AG2 as a community-driven fork. In November, they settled on creating a new AG2 GitHub organization and a new repo, inheriting the PyPI autogen and pyautogen packages and the Discord channel. Meanwhile, in October 2025, Microsoft [introduced Microsoft Agent Framework](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/) to combine ideas from AutoGen and Semantic Kernel. [Microsoft Agent Framework reached version 1.0 in April 2026](https://devblogs.microsoft.com/agent-framework/microsoft-agent-framework-version-1-0/) with stable APIs and a commitment to long-term support. +Microsoft's AutoGen repository is archived and no longer maintained as of October 2026, so new Microsoft projects should use Microsoft Agent Framework. The ecosystem reached this point through several changes. A significant development occurred in late 2024 when the original creators, Chi Wang and Qingyun Wu, departed Microsoft to establish AG2 as a community-driven fork. In November, they settled on creating a new AG2 GitHub organization and a new repo, inheriting the PyPI autogen and pyautogen packages and the Discord channel. Meanwhile, in October 2025, Microsoft [introduced Microsoft Agent Framework](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/) to combine ideas from AutoGen and Semantic Kernel. [Microsoft Agent Framework reached version 1.0 in April 2026](https://devblogs.microsoft.com/agent-framework/microsoft-agent-framework-version-1-0/) with stable APIs and a commitment to long-term support. -If you're considering this option, compare the AG2 community fork based on the original 0.2 architecture, Microsoft's current AutoGen packages, and Microsoft Agent Framework against your required API stability and migration effort. +If you're maintaining this option, compare the community-led AG2 path based on the original 0.2 architecture with Microsoft Agent Framework against your required API stability and migration effort. Microsoft provides an AutoGen migration guide, but neither AutoGen nor LangGraph code becomes source-compatible automatically. One cost consideration that often gets overlooked: each agent turn in a conversation-based system involves a full LLM call with accumulated conversation history. For high-volume, real-time workflows, this gets expensive fast. AutoGen-style systems are better suited for offline, quality-sensitive tasks where getting the right answer matters more than per-call cost. -**Best for:** Research-grade multi-agent debate workflows and Azure-invested enterprise teams. +**Best for:** Teams maintaining existing conversational multi-agent systems while planning an explicit migration path. + +**Language:** Python and .NET for archived Microsoft AutoGen; Python for AG2. + +**License:** Microsoft AutoGen code uses the MIT License, while its repository documentation uses Creative Commons Attribution 4.0. + +**Hosting:** Self-managed application infrastructure. + +**Strengths:** AutoGen established message-driven conversational agent patterns; Microsoft now directs new users to Microsoft Agent Framework and provides a migration guide. + +**Limitations:** You need real-time, high-volume processing on a tight token budget. -**Weakest when:** You need real-time, high-volume processing on a tight token budget. -**LangChain verdict:** [AutoGen](https://microsoft.github.io/autogen/stable/) is a strong LangChain alternative when conversational multi-agent experimentation matters more than leaving code-first development. +**Maintenance status as of October 2026:** Microsoft has [archived AutoGen and ended active maintenance, fixes, and support](https://github.com/microsoft/autogen/blob/main/README.md); Microsoft directs new users to Agent Framework and existing users to its migration guide. -**Maintenance status as of October 2026:** AutoGen's release history is available in Microsoft's [official repository](https://github.com/microsoft/autogen/releases). +**Sources:** [AutoGen archive notice](https://github.com/microsoft/autogen/blob/main/README.md), [code license](https://github.com/microsoft/autogen/blob/main/LICENSE-CODE), [documentation license](https://github.com/microsoft/autogen/blob/main/LICENSE), [release history](https://github.com/microsoft/autogen/releases), [Microsoft migration guide](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen/), [archived AgentChat documentation](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/), and [AG2 repository](https://github.com/ag2ai/ag2). ### Google ADK @@ -365,16 +420,25 @@ Google introduced the [Agent Development Kit (ADK)](https://adk.dev/) at Google What sets ADK apart from most alternatives is its breadth of language support and deployment integration. ADK is available in Python, TypeScript, Go, and Java, which means teams aren't locked into a single language stack. You can run agents locally or scale them globally using Runtime, Cloud Run, or Google Kubernetes Engine. -The standout capability for teams running multi-framework environments: ADK supports the Agent2Agent protocol (A2A) for agent interoperability and coordination; a primary agent can orchestrate and delegate tasks to specialized sub-agents, whether they are local services or remote deployments. ADK lets you integrate third-party libraries like LangChain and LlamaIndex, or even use other agents as tools, including LangGraph and CrewAI agents. This means an ADK agent can invoke a LangGraph or CrewAI agent through a standardized task interface, making it the strongest choice for cross-framework orchestration. +The standout capability for teams running multi-framework environments: ADK supports the Agent2Agent protocol (A2A) for agent interoperability and coordination; a primary agent can orchestrate and delegate tasks to specialized sub-agents, whether they are local services or remote deployments. ADK lets you integrate third-party libraries like LangChain and LlamaIndex, or even use other agents as tools, including LangGraph and CrewAI agents. This means an ADK agent can invoke a LangGraph or CrewAI agent through a standardized task interface, making it a strong choice for cross-framework orchestration. **Best for:** Google Cloud / Vertex AI-native teams and cross-framework orchestration scenarios. -**Weakest when:** Your infrastructure has no Google Cloud footprint, and you don't need multi-framework agent communication. +**Language:** Python, TypeScript, Go, Java, and Kotlin implementations are linked from Google’s repository. + +**License:** Apache License 2.0 for Google’s Python implementation as of October 2026. + +**Hosting:** Developer-managed infrastructure or Google Cloud services. + +**Strengths:** Google ADK provides graph execution, hierarchical agents, A2A interoperability, sessions, and deployment paths that fit Gemini and Google Cloud teams. + +**Limitations:** Your infrastructure has no Google Cloud footprint, and you don't need multi-framework agent communication. -**LangChain verdict:** [Google ADK](https://adk.dev/) is a strong LangChain alternative for Google Cloud teams that want hierarchical agents and cross-framework interoperability. **Maintenance status as of October 2026:** Google ADK is actively maintained through its [official releases](https://github.com/google/adk-python/releases). +**Sources:** [Google ADK license](https://github.com/google/adk-python/blob/main/LICENSE), [repository](https://github.com/google/adk-python), [documentation](https://adk.dev/), and [releases](https://github.com/google/adk-python/releases). + ### OpenAI Agents SDK The [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) is a lightweight Python framework focused on multi-agent workflows with built-in tracing and guardrails. Despite the OpenAI branding, it's provider-agnostic and compatible with a broad range of LLMs, which makes the name slightly misleading but the tool genuinely flexible. @@ -385,36 +449,63 @@ The handoff model deserves specific mention: when one agent completes its portio **Best for:** Teams wanting minimal framework overhead and clean multi-agent handoffs with strong tracing out of the box. -**Weakest when:** You need complex state machines or durable checkpointing for long-running workflows. +**Language:** Python and TypeScript. + +**License:** MIT License for both first-party implementations as of October 2026. + +**Hosting:** Self-managed application infrastructure that calls configured model services. + +**Strengths:** OpenAI Agents SDK offers focused primitives for tools, handoffs, guardrails, sessions, tracing, and human involvement. + +**Limitations:** You need complex state machines or durable checkpointing for long-running workflows. -**LangChain verdict:** [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) is a strong LangChain alternative for compact applications centered on tools, handoffs, sessions, and tracing. **Maintenance status as of October 2026:** OpenAI Agents SDK is actively maintained through its [official releases](https://github.com/openai/openai-agents-python/releases). +**Sources:** [Python license](https://github.com/openai/openai-agents-python/blob/main/LICENSE), [TypeScript license](https://github.com/openai/openai-agents-js/blob/main/LICENSE), [Python documentation](https://openai.github.io/openai-agents-python/), and [Python releases](https://github.com/openai/openai-agents-python/releases). + ### LlamaIndex [LlamaIndex](https://developers.llamaindex.ai/python/framework/) is the best LangGraph alternative when retrieval, indexing, and grounding agents in private data matter more than low-level graph orchestration. Its connectors, indexes, retrieval components, query engines, and workflow abstractions fit knowledge-intensive applications. **Best for:** Retrieval-augmented generation, document agents, knowledge assistants, and private-data applications. -**Weakest when:** General business automation does not depend heavily on retrieval or indexing. +**Language:** Python and TypeScript. + +**License:** MIT License as of October 2026. + +**Hosting:** Self-managed application infrastructure or separate managed services. + +**Strengths:** LlamaIndex provides connectors, indexes, retrieval components, query engines, and workflow abstractions for private-data applications. + +**Limitations:** General business automation does not depend heavily on retrieval or indexing. -**LangChain verdict:** [LlamaIndex](https://developers.llamaindex.ai/python/framework/) is a strong LangChain alternative when retrieval, indexing, and private-data grounding dominate the application. **Maintenance status as of October 2026:** LlamaIndex is actively maintained through its [official releases](https://github.com/run-llama/llama_index/releases). +**Sources:** [LlamaIndex license](https://github.com/run-llama/llama_index/blob/main/LICENSE), [documentation](https://developers.llamaindex.ai/python/framework/), and [releases](https://github.com/run-llama/llama_index/releases). + ### Haystack [Haystack](https://github.com/deepset-ai/haystack/releases) is the best LangGraph alternative for engineering teams that want component-based retrieval and document-processing pipelines under Apache 2.0. It organizes applications from retrievers, generators, document stores, routers, and custom components. **Best for:** Production retrieval pipelines, document processing, and search. -**Weakest when:** Business users need to assemble and operate agents through a low-code visual interface. +**Language:** Python. + +**License:** Apache License 2.0 as of October 2026. + +**Hosting:** Self-managed application infrastructure. + +**Strengths:** Haystack offers explicit retriever, generator, router, and document-store components for retrieval and document-processing pipelines. + +**Limitations:** Business users need to assemble and operate agents through a low-code visual interface. -**LangChain verdict:** [Haystack](https://github.com/deepset-ai/haystack/releases) is a strong LangChain alternative for component-based retrieval and document-processing pipelines. **Maintenance status as of October 2026:** Haystack is actively maintained through its [official releases](https://github.com/deepset-ai/haystack/releases). +**Sources:** [Haystack license](https://github.com/deepset-ai/haystack/blob/main/LICENSE), [repository](https://github.com/deepset-ai/haystack), and [releases](https://github.com/deepset-ai/haystack/releases). + ### Mastra [Mastra](https://mastra.ai/docs) is an open-source TypeScript framework for building AI-powered applications and agents. It was built by the team behind Gatsby, and it's gained serious production traction since launching. Mastra is trusted by engineering teams at Replit, SoftBank, PayPal, PLAID, and Marsh McLennan. Marsh McLennan deployed an agentic search tool built with the framework to 75,000 employees. @@ -425,12 +516,20 @@ However, Mastra is TypeScript-only. If your ML team lives in Python (and most do **Best for:** TypeScript/Node.js teams building agent systems without switching language stacks. -**Weakest when:** Your team works primarily in Python or needs deep ML library integration. +**Language:** TypeScript. + +**License:** Apache License 2.0 outside `ee/`; code under `ee/` uses the separate Mastra Enterprise Edition License as of October 2026. + +**Hosting:** Self-managed application infrastructure. + +**Strengths:** Mastra combines type-safe agents, graph workflows, Mastra Studio, and tracing for TypeScript teams. + +**Limitations:** Your team works primarily in Python or needs deep ML library integration. -**LangChain verdict:** [Mastra](https://mastra.ai/docs) is a strong LangChain alternative for TypeScript teams that want agents and graph-based workflows without adopting Python. **Maintenance status as of October 2026:** Mastra is actively maintained through its [official releases](https://github.com/mastra-ai/mastra/releases). +**Sources:** [Mastra core license](https://github.com/mastra-ai/mastra/blob/main/LICENSE.md), [enterprise license](https://github.com/mastra-ai/mastra/blob/main/ee/LICENSE), [documentation](https://mastra.ai/docs), and [releases](https://github.com/mastra-ai/mastra/releases). ## Workspace Platform Alternatives Sim and Dify offer workspace alternatives when production operations matter more than adopting another code-first framework. @@ -456,22 +555,44 @@ Chat's build mode is where the workflow differs most from code-first approaches. **Best for:** Technical teams and cross-functional builders who need agents running in production, connected to their existing tool stack, without owning the infrastructure layer. -**LangChain verdict:** [Sim](https://www.sim.ai) is a strong fit for teams that want a visual path from prototype to a deployed, shared agent workflow. +**Language:** The core application is primarily TypeScript; teams assemble workflows in the visual builder and can add code where needed. + +**License:** [Sim's core is Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE). Code in [`apps/sim/ee`](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE) uses the separate Sim Enterprise License; production use of those enterprise features requires an active Sim Enterprise subscription. + +**Hosting:** Sim Cloud or self-hosted. Self-hosted Sim can connect to Ollama, vLLM, LM Studio, or LiteLLM without requiring Enterprise. + +**Strengths:** Sim gives developers and non-developers a shared visual representation of agent logic, multiple model providers, deployment, integrations, and run inspection. + +**Limitations:** Sim is not source-compatible with LangGraph, so teams reconstruct existing graph behavior as a workflow. + **Maintenance status as of October 2026:** Sim is actively maintained in the [official Sim repository](https://github.com/simstudioai/sim/releases). Sim's Human in the Loop block pauses a run and resumes it with submitted form fields; a downstream Condition must route on the submitted approval or rejection value. Self-hosted deployments can use Ollama, vLLM, LM Studio, or LiteLLM as described in the [self-hosting documentation](https://docs.sim.ai/platform/self-hosting), and local-model support does not require Enterprise. +**Sources:** [Sim repository](https://github.com/simstudioai/sim), [Sim core license](https://github.com/simstudioai/sim/blob/main/LICENSE), [Sim Enterprise License](https://github.com/simstudioai/sim/blob/main/apps/sim/ee/LICENSE), [repository releases](https://github.com/simstudioai/sim/releases), and [self-hosting documentation](https://docs.sim.ai/platform/self-hosting). + ### Dify -[Dify](https://dify.ai/pricing) is an open-source LLM app development platform with a visual workflow builder. It's well-suited for teams that want low-code agent construction with RAG (retrieval-augmented generation) pipelines and a broad integration set without needing to think in graph abstractions. +[Dify](https://dify.ai/pricing) is a source-available LLM app development platform with a visual workflow builder. It's well-suited for teams that want low-code agent construction with RAG (retrieval-augmented generation) pipelines and a broad integration set without needing to think in graph abstractions. Relative to LangGraph, Dify offers a significantly lower engineering floor to get started. You drag blocks, connect them visually, and deploy. The trade-off is less fine-grained control over state transitions for complex branching logic. If your workflow needs deterministic, auditable paths through dozens of conditional branches, Dify's visual model can feel limiting. But for RAG-heavy applications and LLM-powered tools where the goal is getting something running and connected to data sources fast, it's one of the most accessible options available. **Best for:** Teams building RAG-heavy applications and LLM-powered apps without deep graph expertise. -**LangChain verdict:** [Dify](https://docs.dify.ai/en/home) is a strong LangChain alternative when the primary deliverable is a visual LLM application or retrieval-backed experience. +**Language:** Python and TypeScript power a visual workflow and application builder. + +**License:** Modified Apache License 2.0 with additional conditions as of October 2026. + +**Hosting:** Dify Cloud or self-hosted deployment. + +**Strengths:** Dify provides accessible visual assembly for LLM applications, model tools, logic, and retrieval-backed experiences. + +**Limitations:** Dify’s license adds conditions, and its product focus is narrower than general business automation. + **Maintenance status as of October 2026:** Dify is actively maintained through its [official releases](https://github.com/langgenius/dify/releases). +**Sources:** [Dify license](https://github.com/langgenius/dify/blob/main/LICENSE), [deployment documentation](https://docs.dify.ai/en/self-host/deploy/overview), and [releases](https://github.com/langgenius/dify/releases). + ### n8n [n8n](https://github.com/n8n-io/n8n/releases) is the best LangGraph alternative when broad SaaS automation and deterministic workflow integration matter more than Python-native agent state graphs. It connects application triggers, API calls, transformations, and AI steps in a visual workflow environment. @@ -480,12 +601,20 @@ n8n is source-available under its Sustainable Use License, not OSI-approved open **Best for:** Integration-heavy business workflows that combine deterministic automation with selected AI steps. -**Weakest when:** Developers need LangGraph's low-level state semantics or an OSI-approved license for the core automation engine. +**Language:** TypeScript with a node-based visual editor and code steps. + +**License:** Sustainable Use License and Enterprise License; the Sustainable Use License is source-available and not OSI-approved as of October 2026. + +**Hosting:** n8n Cloud or self-hosted deployment. + +**Strengths:** n8n offers a mature node-based editor for workflows dominated by application integrations and deterministic automation. + +**Limitations:** Developers need LangGraph's low-level state semantics or an OSI-approved license for the core automation engine. -**LangChain verdict:** [n8n](https://docs.n8n.io/) is a strong choice for teams whose main requirement is broad business automation with selected AI steps. **Maintenance status as of October 2026:** n8n is actively maintained through its [official releases](https://github.com/n8n-io/n8n/releases). +**Sources:** [n8n license](https://github.com/n8n-io/n8n/blob/master/LICENSE.md), [deployment documentation](https://docs.n8n.io/choose-how-to-use-n8n), and [releases](https://github.com/n8n-io/n8n/releases). ## Should I use Sim, n8n, Dify, CrewAI, or LangGraph? Sim fits visual production agents, n8n fits general automation with AI steps, Dify is oriented toward LLM applications, CrewAI suits Python multi-agent development, and LangGraph suits code-embedded stateful orchestration. @@ -537,19 +666,19 @@ A visual migration should simplify ownership and operations, not merely reproduc ## How should I evaluate LangChain and LangGraph alternatives? -Sim, n8n, Dify, CrewAI, and LangGraph should be evaluated with the same production scenario because feature checklists alone do not reveal integration effort or operational burden. +LangGraph alternatives should be evaluated against the same production scenario; LangChain-specific component coverage matters only when the current application uses those broader abstractions. Sim, n8n, Dify, CrewAI, and LangGraph should be evaluated with the same production scenario because feature checklists alone do not reveal integration effort or operational burden. Use a representative workflow with a real trigger, a model call, an authenticated external tool, a structured-output branch, a recoverable failure, a human decision, and deployment into the intended hosting environment. Then compare time to first working run, custom code, credential handling, failure visibility, deployment responsibility, and who can safely maintain the result. ## Which LangChain alternative is best for your team? -Sim is a strong fit for teams that want a visual route from prototype to a deployable agent, while code-first teams should keep LangGraph or evaluate CrewAI and AutoGen according to their preferred programming model. +Sim is the best overall LangGraph alternative for a mixed technical team. In the distinct LangChain context, Sim is a strong fit for teams that want a visual route from prototype to a deployable agent, while code-first teams should keep LangGraph or evaluate CrewAI and AutoGen according to their preferred programming model. - Choose **Sim** to reduce Python dependence and make production agent workflows visible and collaborative. - Choose **n8n** to center the solution on broad application automation. - Choose **Dify** to center the solution on an LLM application or retrieval experience. - Choose **CrewAI** to remain in Python while adopting role-based multi-agent concepts. -- Choose **Microsoft AutoGen** to explore code-first conversational multi-agent systems. +- Choose **Microsoft Agent Framework** for Microsoft-backed code-first agents and workflows; migrate existing AutoGen systems explicitly. - Keep **LangGraph** when custom stateful orchestration inside an application is a deliberate advantage rather than a burden. ## How to Choose: A Decision Framework @@ -558,16 +687,14 @@ Sim fits teams that want visual orchestration, while LangGraph remains the speci There's no single right answer here. Different teams have different needs, and the "best" tool depends on where you are today and what's holding you back. Use this table to find the right tool for your needs. -| If your situation is... | Choose this | Why it fits | -| --- | --- | --- | -| Need production deployment without infrastructure ownership, team-based building | Sim | Deployment, 270+ integrations, real-time collaboration, and observability ship as built-in features | -| Need stateful graphs with human-in-the-loop + audit trails | LangGraph (stay) | Deterministic, auditable control flow with explicit state management is its core strength | -| Need fastest prototype, Python-comfortable team | CrewAI | Role-based mental model gets you from idea to a working multi-agent demo quickly | -| Need agents that debate/review outputs for quality | AutoGen / AG2 | Conversation-first architecture is purpose-built for iterative refinement and multi-agent review | -| Deep in Azure infrastructure | Microsoft Agent Framework | Enterprise successor to AutoGen with Semantic Kernel integration, built for Azure-native deployments | -| Deep in Google Cloud / Vertex AI | Google ADK | First-party framework with native Vertex AI deployment and A2A protocol for cross-framework orchestration | -| TypeScript team, no Python | Mastra | Full-featured agent framework without a language stack switch, with enterprise production traction | -| Need RAG-heavy low-code apps | Dify | Visual workflow builder with strong RAG pipeline support and a low engineering floor | +- **Choose Sim** for production deployment, team-based visual building, integrations, collaboration, and observability in one workspace. +- **Keep LangGraph** for explicit state graphs, human interrupts, checkpoints, and auditable control flow. +- **Choose CrewAI** for a Python-friendly, role-based multi-agent prototype. +- **Choose Microsoft Agent Framework** for Microsoft-oriented Python or .NET agents and for the supported migration path from AutoGen or Semantic Kernel. +- **Choose Google ADK** for Google Cloud, Gemini, and A2A-oriented orchestration. +- **Choose Mastra** for TypeScript agents and graph workflows. +- **Choose Dify** for visual, retrieval-heavy LLM applications. +- **Choose n8n** for integration-heavy visual business automation. ### Two signs that an AI workspace is the better path @@ -634,4 +761,4 @@ For Python teams that want a different architectural model but still want to own For teams where the real bottleneck is getting agents into production, connected to business tools, and maintained by more than one person, a workspace platform like Sim removes the infrastructure burden so you can focus on the agent logic itself. You can [start building in Sim](https://www.sim.ai) and see how visual, conversational, and API-driven agent building compares to graph definitions in code. -For wider context: [open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms) covers the self-hostable field including the code-first frameworks, and [the best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) adds the commercial options. [How to build AI agents](https://www.sim.ai/library/how-to-create-an-ai-agent) is the visual-first walkthrough if you're moving off code. +For wider context, [LangChain vs LangGraph](https://www.sim.ai/library/langchain-vs-langgraph) explains the distinct roles of the two LangChain products, while [CrewAI vs LangGraph vs AutoGen](https://www.sim.ai/library/crewai-vs-langgraph-vs-autogen) compares three code-first orchestration models. [Open-source AI agent platforms](https://www.sim.ai/library/open-source-ai-agent-platforms) covers the self-hostable field including the code-first frameworks, and [the best AI agent platforms in 2026](https://www.sim.ai/library/best-ai-agent-platforms-2026) adds the commercial options. [How to build AI agents](https://www.sim.ai/library/how-to-create-an-ai-agent) is the visual-first walkthrough if you're moving off code. From 4df5d8522e3bcc5864a04c33b048c1ce49d8e7da Mon Sep 17 00:00:00 2001 From: Waleed Date: Fri, 23 Oct 2026 16:30:03 -0700 Subject: [PATCH 24/30] improvement(library): rank related posts by tag rarity, add backlinks, and widen comparison and pagination links (#9354) * improvement(library): rank related posts by tag rarity, add backlinks, and widen comparison and pagination links * fix(library): keep every referencing post out of related posts, not just the six shown --- apps/sim/app/(landing)/blog/[slug]/page.tsx | 2 +- .../comparison-link-row.tsx | 3 +- .../(landing)/comparisons/library-links.ts | 59 ++++-- apps/sim/app/(landing)/comparisons/utils.ts | 5 + .../content-index-page/content-index-page.tsx | 27 ++- .../content-post-page/content-backlinks.tsx | 48 +++++ .../content-related-posts.tsx | 66 +++--- .../components/content-post-page/index.ts | 1 + apps/sim/app/(landing)/components/index.ts | 2 +- .../app/(landing)/content-routing.test.tsx | 2 + .../sim/app/(landing)/library/[slug]/page.tsx | 36 +++- apps/sim/lib/content/links.test.ts | 64 ++++++ apps/sim/lib/content/links.ts | 191 ++++++++++++++++++ apps/sim/lib/content/registry-factory.ts | 90 ++++++--- apps/sim/lib/library/registry.ts | 2 + 15 files changed, 507 insertions(+), 91 deletions(-) create mode 100644 apps/sim/app/(landing)/components/content-post-page/content-backlinks.tsx create mode 100644 apps/sim/lib/content/links.test.ts create mode 100644 apps/sim/lib/content/links.ts diff --git a/apps/sim/app/(landing)/blog/[slug]/page.tsx b/apps/sim/app/(landing)/blog/[slug]/page.tsx index 89385e4b472..15d994b96c0 100644 --- a/apps/sim/app/(landing)/blog/[slug]/page.tsx +++ b/apps/sim/app/(landing)/blog/[slug]/page.tsx @@ -33,7 +33,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string const { slug } = await params const post = await getPostBySlug(slug) if (!post || post.draft) notFound() - const related = await getRelatedPosts(slug, 3) + const related = await getRelatedPosts(slug) return ( diff --git a/apps/sim/app/(landing)/comparisons/library-links.ts b/apps/sim/app/(landing)/comparisons/library-links.ts index 990cb23dd0b..2c295da7a2b 100644 --- a/apps/sim/app/(landing)/comparisons/library-links.ts +++ b/apps/sim/app/(landing)/comparisons/library-links.ts @@ -1,31 +1,44 @@ import { escapeRegExp } from '@sim/utils/string' import type { CompetitorProfile } from '@/lib/compare/data' import type { ContentMeta } from '@/lib/content/schema' -import { getAllPostMeta } from '@/lib/library/registry' -import { ALL_COMPETITORS } from '@/app/(landing)/comparisons/utils' +import { getAllPostMeta, getPostsLinkingTo } from '@/lib/library/registry' +import { ALL_COMPETITORS, comparisonPath } from '@/app/(landing)/comparisons/utils' /** Most links either side renders, so the section stays a short "read next" list. */ -const MAX_LINKS = 3 +const MAX_LINKS = 6 -/** A competitor named in an article's title or tags is its subject; one named only in the description is a mention. */ -const SUBJECT_SCORE = 2 +/** + * An article titled "{Competitor} Alternatives" answers the same buyer question as the + * comparison page, so it leads, then articles about the competitor, articles linking to its + * comparison page, and articles naming it only in the description. + */ +const ALTERNATIVES_SCORE = 4 +const SUBJECT_SCORE = 3 +const LINKS_TO_COMPARISON_SCORE = 2 const MENTION_SCORE = 1 interface MentionPatterns { + /** Matches a title naming the competitor's alternatives ("n8n Alternatives", "LangChain and LangGraph Alternatives"). */ + alternatives: RegExp /** Matches Title Case titles and tags, where a bare common-word name ("Make") is ambiguous. */ subject: RegExp /** Matches sentence-case descriptions, where the bare name is unambiguous. */ mention: RegExp } +function phraseAlternation(phrases: string[]): string { + return `(?:${phrases.map(escapeRegExp).join('|')})` +} + /** Whole-word, case-sensitive match on any of `phrases`. */ function wordPattern(phrases: string[]): RegExp { - return new RegExp(`\\b(?:${phrases.map(escapeRegExp).join('|')})\\b`) + return new RegExp(`\\b${phraseAlternation(phrases)}\\b`) } function buildMentionPatterns(competitor: CompetitorProfile): MentionPatterns { const aliases = competitor.mentions ?? [competitor.name] return { + alternatives: new RegExp(`\\b${phraseAlternation(aliases)}(?: and [^,:]+?)? Alternatives\\b`), subject: wordPattern(aliases), mention: wordPattern([competitor.name, ...aliases]), } @@ -36,8 +49,14 @@ const COMPETITOR_PATTERNS = ALL_COMPETITORS.map((competitor) => ({ patterns: buildMentionPatterns(competitor), })) -function scoreRelevance(post: ContentMeta, { subject, mention }: MentionPatterns): number { +function scoreRelevance( + post: ContentMeta, + { alternatives, subject, mention }: MentionPatterns, + linksToComparison: boolean +): number { + if (alternatives.test(post.title)) return ALTERNATIVES_SCORE if (subject.test(post.title) || post.tags.some((tag) => subject.test(tag))) return SUBJECT_SCORE + if (linksToComparison) return LINKS_TO_COMPARISON_SCORE return mention.test(post.description) ? MENTION_SCORE : 0 } @@ -52,22 +71,30 @@ function topByScore(items: T[], score: (item: T) => number): T[] { } /** - * Library articles about or naming `competitor`, articles where it is the - * subject first, then newest first. + * Library articles about, linking to, or naming `competitor`, strongest match first, then + * newest first. */ export async function getLibraryPostsForCompetitor( competitor: CompetitorProfile ): Promise { const patterns = buildMentionPatterns(competitor) - return topByScore(await getAllPostMeta(), (post) => scoreRelevance(post, patterns)) + const [posts, linkingPosts] = await Promise.all([ + getAllPostMeta(), + getPostsLinkingTo(comparisonPath(competitor)), + ]) + const linkingSlugs = new Set(linkingPosts.map((post) => post.slug)) + return topByScore(posts, (post) => scoreRelevance(post, patterns, linkingSlugs.has(post.slug))) } /** - * Comparison pages for the competitors a library article is about or names, - * subjects first, then in {@link ALL_COMPETITORS} order. + * Comparison pages for the competitors a library article is about, links to (`linkedPaths`, + * from its body), or names, strongest match first, then in {@link ALL_COMPETITORS} order. */ -export function getComparisonsForPost(post: ContentMeta): CompetitorProfile[] { - return topByScore(COMPETITOR_PATTERNS, ({ patterns }) => scoreRelevance(post, patterns)).map( - ({ competitor }) => competitor - ) +export function getComparisonsForPost( + post: ContentMeta, + linkedPaths: ReadonlySet +): CompetitorProfile[] { + return topByScore(COMPETITOR_PATTERNS, ({ competitor, patterns }) => + scoreRelevance(post, patterns, linkedPaths.has(comparisonPath(competitor))) + ).map(({ competitor }) => competitor) } diff --git a/apps/sim/app/(landing)/comparisons/utils.ts b/apps/sim/app/(landing)/comparisons/utils.ts index 2c1a962e142..94c9d0fd6d2 100644 --- a/apps/sim/app/(landing)/comparisons/utils.ts +++ b/apps/sim/app/(landing)/comparisons/utils.ts @@ -54,6 +54,11 @@ export const ALL_COMPETITORS: CompetitorProfile[] = [ const COMPETITOR_BY_SLUG = new Map(ALL_COMPETITORS.map((c) => [c.id, c])) +/** Path of the Sim vs `competitor` comparison page. */ +export function comparisonPath(competitor: CompetitorProfile): string { + return `/comparisons/${competitor.id}` +} + export function getCompetitorBySlug(slug: string): CompetitorProfile | null { return COMPETITOR_BY_SLUG.get(slug) ?? null } diff --git a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx index e4ca2bcf9b5..c0d735ab346 100644 --- a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx +++ b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx @@ -36,8 +36,12 @@ export function ContentIndexPage({ }: ContentIndexPageProps) { const { featured, remaining, totalPages } = paginateContentPosts(posts, { tag, page }) - const pageHref = (targetPage: number) => - `${basePath}?page=${targetPage}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}` + const tagQuery = tag ? `tag=${encodeURIComponent(tag)}` : '' + const pageHref = (targetPage: number) => { + if (targetPage === 1) return tagQuery ? `${basePath}?${tagQuery}` : basePath + return `${basePath}?page=${targetPage}${tagQuery ? `&${tagQuery}` : ''}` + } + const pageNumbers = Array.from({ length: totalPages }, (_, index) => index + 1) return ( <> @@ -138,7 +142,7 @@ export function ContentIndexPage({ {totalPages > 1 && (