diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index 7a5ea8e7caf..2ec88151f15 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -102,8 +102,8 @@ For **every** tool file, check: - [ ] No fields are missing that the API provides and users would commonly need - [ ] No phantom fields defined that the API doesn't return - [ ] `optional: true` is set on fields that may not exist in all responses -- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields -- [ ] When using `type: 'array'`, `items` defines the item structure with `properties` +- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields (tool outputs only — block outputs do not support `properties`) +- [ ] When using `type: 'array'`, `items` defines the item structure with `properties` (tool outputs only) - [ ] Field descriptions are accurate and helpful ### Types (types.ts) @@ -190,9 +190,8 @@ For **each tool** in `tools.access`: ### Block Outputs - [ ] Outputs cover the key fields returned by ALL tools (not just one operation) - [ ] Output types are correct (`'string'`, `'number'`, `'boolean'`, `'json'`) -- [ ] `type: 'json'` outputs either: - - Describe inner fields in the description string (GOOD): `'User profile (id, name, username, bio)'` - - Use nested output definitions (BEST): `{ id: { type: 'string' }, name: { type: 'string' } }` +- [ ] `type: 'json'` outputs describe inner fields in the description string: `'User profile (id, name, username, bio)'` or `'[{address, status, type}]'` for arrays +- [ ] **Do NOT add a `properties: {...}` field on block outputs.** Block-level `OutputFieldDefinition` (from `@sim/workflow-types/blocks`) only accepts `{ type, description?, condition?, hiddenFromDisplay? }`. Nested `properties` is a tool-level construct (`OutputProperty`) — adding it to a block output will fail TypeScript at build time - [ ] No opaque `type: 'json'` with vague descriptions like `'Response data'` - [ ] Outputs that only appear for certain operations use `condition` if supported, or document which operations return them diff --git a/.claude/commands/validate-integration.md b/.claude/commands/validate-integration.md index e36cefd319b..0de683ffebb 100644 --- a/.claude/commands/validate-integration.md +++ b/.claude/commands/validate-integration.md @@ -87,8 +87,8 @@ For **every** tool file, check: - [ ] No fields are missing that the API provides and users would commonly need - [ ] No phantom fields defined that the API doesn't return - [ ] `optional: true` is set on fields that may not exist in all responses -- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields -- [ ] When using `type: 'array'`, `items` defines the item structure with `properties` +- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields (tool outputs only — block outputs do not support `properties`) +- [ ] When using `type: 'array'`, `items` defines the item structure with `properties` (tool outputs only) - [ ] Field descriptions are accurate and helpful ### Types (types.ts) @@ -175,9 +175,8 @@ For **each tool** in `tools.access`: ### Block Outputs - [ ] Outputs cover the key fields returned by ALL tools (not just one operation) - [ ] Output types are correct (`'string'`, `'number'`, `'boolean'`, `'json'`) -- [ ] `type: 'json'` outputs either: - - Describe inner fields in the description string (GOOD): `'User profile (id, name, username, bio)'` - - Use nested output definitions (BEST): `{ id: { type: 'string' }, name: { type: 'string' } }` +- [ ] `type: 'json'` outputs describe inner fields in the description string: `'User profile (id, name, username, bio)'` or `'[{address, status, type}]'` for arrays +- [ ] **Do NOT add a `properties: {...}` field on block outputs.** Block-level `OutputFieldDefinition` (from `@sim/workflow-types/blocks`) only accepts `{ type, description?, condition?, hiddenFromDisplay? }`. Nested `properties` is a tool-level construct (`OutputProperty`) — adding it to a block output will fail TypeScript at build time - [ ] No opaque `type: 'json'` with vague descriptions like `'Response data'` - [ ] Outputs that only appear for certain operations use `condition` if supported, or document which operations return them diff --git a/.cursor/commands/validate-integration.md b/.cursor/commands/validate-integration.md index db2810d3394..8a5c5d2073b 100644 --- a/.cursor/commands/validate-integration.md +++ b/.cursor/commands/validate-integration.md @@ -82,8 +82,8 @@ For **every** tool file, check: - [ ] No fields are missing that the API provides and users would commonly need - [ ] No phantom fields defined that the API doesn't return - [ ] `optional: true` is set on fields that may not exist in all responses -- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields -- [ ] When using `type: 'array'`, `items` defines the item structure with `properties` +- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields (tool outputs only — block outputs do not support `properties`) +- [ ] When using `type: 'array'`, `items` defines the item structure with `properties` (tool outputs only) - [ ] Field descriptions are accurate and helpful ### Types (types.ts) @@ -170,9 +170,8 @@ For **each tool** in `tools.access`: ### Block Outputs - [ ] Outputs cover the key fields returned by ALL tools (not just one operation) - [ ] Output types are correct (`'string'`, `'number'`, `'boolean'`, `'json'`) -- [ ] `type: 'json'` outputs either: - - Describe inner fields in the description string (GOOD): `'User profile (id, name, username, bio)'` - - Use nested output definitions (BEST): `{ id: { type: 'string' }, name: { type: 'string' } }` +- [ ] `type: 'json'` outputs describe inner fields in the description string: `'User profile (id, name, username, bio)'` or `'[{address, status, type}]'` for arrays +- [ ] **Do NOT add a `properties: {...}` field on block outputs.** Block-level `OutputFieldDefinition` (from `@sim/workflow-types/blocks`) only accepts `{ type, description?, condition?, hiddenFromDisplay? }`. Nested `properties` is a tool-level construct (`OutputProperty`) — adding it to a block output will fail TypeScript at build time - [ ] No opaque `type: 'json'` with vague descriptions like `'Response data'` - [ ] Outputs that only appear for certain operations use `condition` if supported, or document which operations return them diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index cd326d6220a..725bad45557 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -415,6 +415,18 @@ export function MailIcon(props: SVGProps) { ) } +export function InstantlyIcon(props: SVGProps) { + return ( + + + + + ) +} + export function EmailBisonIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 3f27a66e0c9..29ff65d4eb1 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -99,6 +99,7 @@ import { ImageIcon, IncidentioIcon, InfisicalIcon, + InstantlyIcon, IntercomIcon, JinaAIIcon, JiraIcon, @@ -319,6 +320,7 @@ export const blockTypeToIconMap: Record = { imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, + instantly: InstantlyIcon, intercom: IntercomIcon, intercom_v2: IntercomIcon, jina: JinaAIIcon, diff --git a/apps/docs/content/docs/en/tools/instantly.mdx b/apps/docs/content/docs/en/tools/instantly.mdx new file mode 100644 index 00000000000..811fefde4ca --- /dev/null +++ b/apps/docs/content/docs/en/tools/instantly.mdx @@ -0,0 +1,522 @@ +--- +title: Instantly +description: Manage Instantly leads, campaigns, emails, and lead lists +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Instantly API V2 into workflows. Create and list leads, manage lead interest status, delete leads in bulk, list and create campaigns, reply to emails, and manage lead lists. + + + +## Tools + +### `instantly_list_leads` + +Retrieves Instantly V2 leads with search, campaign, list, and pagination filters. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `search` | string | No | Search by first name, last name, or email | +| `filter` | string | No | Instantly lead filter value, such as FILTER_VAL_CONTACTED or FILTER_VAL_ACTIVE | +| `campaign` | string | No | Campaign ID to filter leads | +| `list_id` | string | No | Lead list ID to filter leads | +| `in_campaign` | boolean | No | Whether the lead is in a campaign | +| `in_list` | boolean | No | Whether the lead is in a list | +| `ids` | array | No | Lead IDs to include | +| `items` | string | No | No description | +| `excluded_ids` | array | No | Lead IDs to exclude | +| `items` | string | No | No description | +| `contacts` | array | No | Lead email addresses to include | +| `items` | string | No | No description | +| `limit` | number | No | Number of leads to return, from 1 to 100 | +| `starting_after` | string | No | Forward pagination cursor from next_starting_after | +| `distinct_contacts` | boolean | No | Whether to return distinct contacts | +| `enrichment_status` | number | No | Enrichment status filter | +| `esg_code` | string | No | Email security gateway code filter | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_get_lead` + +Retrieves an Instantly V2 lead by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `leadId` | string | Yes | Lead ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_create_lead` + +Creates an Instantly V2 lead in a campaign or lead list. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `campaign` | string | No | Campaign ID associated with the lead | +| `list_id` | string | No | Lead list ID associated with the lead | +| `email` | string | No | Lead email address. Required when adding to a campaign. | +| `first_name` | string | No | Lead first name | +| `last_name` | string | No | Lead last name | +| `company_name` | string | No | Lead company name | +| `job_title` | string | No | Lead job title | +| `phone` | string | No | Lead phone number | +| `website` | string | No | Lead website | +| `personalization` | string | No | Lead personalization text | +| `lt_interest_status` | number | No | Lead interest status value | +| `pl_value_lead` | string | No | Potential value of the lead | +| `assigned_to` | string | No | Organization user ID assigned to the lead | +| `skip_if_in_workspace` | boolean | No | Skip if the lead already exists in the workspace | +| `skip_if_in_campaign` | boolean | No | Skip if the lead already exists in the campaign | +| `skip_if_in_list` | boolean | No | Skip if the lead already exists in the list | +| `blocklist_id` | string | No | Blocklist ID to check for the lead | +| `verify_leads_on_import` | boolean | No | Whether to verify leads on import | +| `custom_variables` | json | No | Custom variable object with string, number, boolean, or null values | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_delete_leads` + +Deletes Instantly V2 leads in bulk from a campaign or lead list. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `campaign_id` | string | No | Campaign ID to delete leads from. Required if list_id is not provided. | +| `list_id` | string | No | Lead list ID to delete leads from. Required if campaign_id is not provided. | +| `status` | number | No | Optional lead status filter | +| `ids` | array | No | Specific lead IDs to delete | +| `items` | string | No | No description | +| `limit` | number | No | Maximum number of matching leads to delete, up to 10000 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `count` | number | Number of leads deleted | + +### `instantly_update_lead_interest_status` + +Submits an Instantly V2 background job to update a lead interest status. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `lead_email` | string | Yes | Lead email address | +| `interest_value` | number | Yes | Interest status value. Use null in JSON/tool input to reset to Lead. | +| `campaign_id` | string | No | Campaign ID for the lead | +| `list_id` | string | No | Lead list ID for the lead | +| `ai_interest_value` | number | No | AI interest value to set for the lead | +| `disable_auto_interest` | boolean | No | Whether to disable auto interest | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Background job submission message | + +### `instantly_list_campaigns` + +Retrieves Instantly V2 campaigns with search, status, tag, and pagination filters. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `limit` | number | No | Number of campaigns to return, from 1 to 100 | +| `starting_after` | string | No | Pagination cursor from next_starting_after | +| `search` | string | No | Search by campaign name | +| `tag_ids` | string | No | Comma-separated campaign tag IDs | +| `ai_sales_agent_id` | string | No | AI Sales Agent ID filter | +| `status` | number | No | Campaign status enum value | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_create_campaign` + +Creates an Instantly V2 campaign using the documented campaign schedule schema. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Campaign name | +| `campaign_schedule` | json | Yes | Campaign schedule object with schedules array | +| `sequences` | array | No | Campaign sequence definitions | +| `items` | object | No | No description | +| `email_list` | array | No | Sending email accounts | +| `items` | string | No | No description | +| `daily_limit` | number | No | Daily sending limit | +| `daily_max_leads` | number | No | Daily maximum new leads to contact | +| `open_tracking` | boolean | No | Whether to track opens | +| `stop_on_reply` | boolean | No | Whether to stop the campaign on reply | +| `link_tracking` | boolean | No | Whether to track links | +| `text_only` | boolean | No | Whether the campaign is text only | +| `email_gap` | number | No | Gap between emails in minutes | +| `pl_value` | number | No | Value of every positive lead | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_patch_campaign` + +Updates documented Instantly V2 campaign fields. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `campaignId` | string | Yes | Campaign ID | +| `name` | string | No | Campaign name | +| `campaign_schedule` | json | No | Campaign schedule object with schedules array | +| `sequences` | array | No | Campaign sequence definitions | +| `items` | object | No | No description | +| `email_list` | array | No | Sending email accounts | +| `items` | string | No | No description | +| `daily_limit` | number | No | Daily sending limit | +| `daily_max_leads` | number | No | Daily maximum new leads to contact | +| `open_tracking` | boolean | No | Whether to track opens | +| `stop_on_reply` | boolean | No | Whether to stop the campaign on reply | +| `link_tracking` | boolean | No | Whether to track links | +| `text_only` | boolean | No | Whether the campaign is text only | +| `email_gap` | number | No | Gap between emails in minutes | +| `pl_value` | number | No | Value of every positive lead | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_activate_campaign` + +Activates, starts, or resumes an Instantly V2 campaign. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `campaignId` | string | Yes | Campaign ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_list_emails` + +Retrieves Instantly V2 Unibox emails with search and pagination filters. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `limit` | number | No | Number of emails to return, from 1 to 100 | +| `starting_after` | string | No | Pagination cursor from next_starting_after | +| `search` | string | No | Search query, email address, or thread:<thread-id> | +| `campaign_id` | string | No | Campaign ID filter | +| `list_id` | string | No | Lead list ID filter | +| `i_status` | number | No | Email interest status filter | +| `eaccount` | string | No | Sending email account filter | +| `lead` | string | No | Lead email address filter | +| `lead_id` | string | No | Lead ID filter | +| `is_unread` | number | No | Unread status filter | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_reply_to_email` + +Sends an Instantly V2 reply to an existing Unibox email. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `eaccount` | string | Yes | Connected email account used to send the reply | +| `reply_to_uuid` | string | Yes | Email ID to reply to | +| `subject` | string | Yes | Reply subject | +| `body` | json | Yes | Reply body object with text and/or html | +| `cc_address_email_list` | string | No | Comma-separated CC email addresses | +| `bcc_address_email_list` | string | No | Comma-separated BCC email addresses | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_list_lead_lists` + +Retrieves Instantly V2 lead lists with search and pagination filters. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `limit` | number | No | Number of lead lists to return, from 1 to 100 | +| `starting_after` | string | No | Starting-after timestamp cursor | +| `has_enrichment_task` | boolean | No | Filter by enrichment task setting | +| `search` | string | No | Search query to filter lead lists by name | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + +### `instantly_create_lead_list` + +Creates an Instantly V2 lead list. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Lead list name | +| `has_enrichment_task` | boolean | No | Whether this list runs enrichment for every added lead | +| `owned_by` | string | No | User ID of the lead list owner | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `leads` | array | List of leads | +| `lead` | json | Lead details | +| `campaigns` | array | List of campaigns | +| `campaign` | json | Campaign details | +| `emails` | array | List of emails | +| `email` | json | Email details | +| `lead_lists` | array | List of lead lists | +| `lead_list` | json | Lead list details | +| `count` | number | Returned or affected record count | +| `next_starting_after` | string | Cursor for the next page | +| `id` | string | Record ID | +| `name` | string | Record name | +| `email_address` | string | Lead email address | +| `first_name` | string | Lead first name | +| `last_name` | string | Lead last name | +| `status` | number | Lead or campaign status | +| `subject` | string | Email subject | +| `thread_id` | string | Email thread ID | +| `message` | string | Operation message | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index f259653fdaa..4ebb74aacca 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -95,6 +95,7 @@ "imap", "incidentio", "infisical", + "instantly", "intercom", "jina", "jira", diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index ff677b09153..8db9d2f447b 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -98,6 +98,7 @@ import { ImageIcon, IncidentioIcon, InfisicalIcon, + InstantlyIcon, IntercomIcon, JinaAIIcon, JiraIcon, @@ -305,6 +306,7 @@ export const blockTypeToIconMap: Record = { imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, + instantly: InstantlyIcon, intercom_v2: IntercomIcon, jina: JinaAIIcon, jira: JiraIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index bb13c23c653..0832536e1de 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -7028,6 +7028,178 @@ "integrationTypes": ["security"], "tags": ["secrets-management"] }, + { + "type": "instantly", + "slug": "instantly", + "name": "Instantly", + "description": "Manage Instantly leads, campaigns, emails, and lead lists", + "longDescription": "Integrate Instantly API V2 into workflows. Create and list leads, manage lead interest status, delete leads in bulk, list and create campaigns, reply to emails, and manage lead lists.", + "bgColor": "#FFFFFF", + "iconName": "InstantlyIcon", + "docsUrl": "https://docs.sim.ai/tools/instantly", + "operations": [ + { + "name": "List Leads", + "description": "Retrieves Instantly V2 leads with search, campaign, list, and pagination filters." + }, + { + "name": "Get Lead", + "description": "Retrieves an Instantly V2 lead by ID." + }, + { + "name": "Create Lead", + "description": "Creates an Instantly V2 lead in a campaign or lead list." + }, + { + "name": "Delete Leads", + "description": "Deletes Instantly V2 leads in bulk from a campaign or lead list." + }, + { + "name": "Update Lead Interest Status", + "description": "Submits an Instantly V2 background job to update a lead interest status." + }, + { + "name": "List Campaigns", + "description": "Retrieves Instantly V2 campaigns with search, status, tag, and pagination filters." + }, + { + "name": "Create Campaign", + "description": "Creates an Instantly V2 campaign using the documented campaign schedule schema." + }, + { + "name": "Patch Campaign", + "description": "Updates documented Instantly V2 campaign fields." + }, + { + "name": "Activate Campaign", + "description": "Activates, starts, or resumes an Instantly V2 campaign." + }, + { + "name": "List Emails", + "description": "Retrieves Instantly V2 Unibox emails with search and pagination filters." + }, + { + "name": "Reply To Email", + "description": "Sends an Instantly V2 reply to an existing Unibox email." + }, + { + "name": "List Lead Lists", + "description": "Retrieves Instantly V2 lead lists with search and pagination filters." + }, + { + "name": "Create Lead List", + "description": "Creates an Instantly V2 lead list." + } + ], + "operationCount": 13, + "triggers": [ + { + "id": "instantly_webhook", + "name": "Instantly Webhook", + "description": "Trigger workflow on any Instantly webhook event" + }, + { + "id": "instantly_email_sent", + "name": "Instantly Email Sent", + "description": "Trigger when Instantly sends an email" + }, + { + "id": "instantly_email_opened", + "name": "Instantly Email Opened", + "description": "Trigger when a lead opens an Instantly email" + }, + { + "id": "instantly_reply_received", + "name": "Instantly Reply Received", + "description": "Trigger when a lead replies to an Instantly email" + }, + { + "id": "instantly_auto_reply_received", + "name": "Instantly Auto Reply Received", + "description": "Trigger when Instantly receives an auto-reply from a lead" + }, + { + "id": "instantly_link_clicked", + "name": "Instantly Link Clicked", + "description": "Trigger when a lead clicks a tracked Instantly link" + }, + { + "id": "instantly_email_bounced", + "name": "Instantly Email Bounced", + "description": "Trigger when an Instantly email bounces" + }, + { + "id": "instantly_lead_unsubscribed", + "name": "Instantly Lead Unsubscribed", + "description": "Trigger when an Instantly lead unsubscribes" + }, + { + "id": "instantly_account_error", + "name": "Instantly Account Error", + "description": "Trigger when Instantly reports an account-level error" + }, + { + "id": "instantly_campaign_completed", + "name": "Instantly Campaign Completed", + "description": "Trigger when an Instantly campaign completes" + }, + { + "id": "instantly_lead_neutral", + "name": "Instantly Lead Neutral", + "description": "Trigger when an Instantly lead is marked neutral" + }, + { + "id": "instantly_lead_interested", + "name": "Instantly Lead Interested", + "description": "Trigger when an Instantly lead is marked interested" + }, + { + "id": "instantly_lead_not_interested", + "name": "Instantly Lead Not Interested", + "description": "Trigger when an Instantly lead is marked not interested" + }, + { + "id": "instantly_lead_meeting_booked", + "name": "Instantly Lead Meeting Booked", + "description": "Trigger when an Instantly lead books a meeting" + }, + { + "id": "instantly_lead_meeting_completed", + "name": "Instantly Lead Meeting Completed", + "description": "Trigger when an Instantly lead completes a meeting" + }, + { + "id": "instantly_lead_closed", + "name": "Instantly Lead Closed", + "description": "Trigger when an Instantly lead is marked closed" + }, + { + "id": "instantly_lead_out_of_office", + "name": "Instantly Lead Out Of Office", + "description": "Trigger when an Instantly lead is out of office" + }, + { + "id": "instantly_lead_wrong_person", + "name": "Instantly Lead Wrong Person", + "description": "Trigger when an Instantly lead is marked wrong person" + }, + { + "id": "instantly_lead_no_show", + "name": "Instantly Lead No Show", + "description": "Trigger when an Instantly lead is marked no show" + }, + { + "id": "instantly_supersearch_enrichment_completed", + "name": "Instantly Supersearch Enrichment Completed", + "description": "Trigger when Instantly completes a Supersearch enrichment" + } + ], + "triggerCount": 20, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["email", "developer-tools", "sales"], + "tags": ["sales-engagement", "email-marketing", "automation"] + }, { "type": "intercom_v2", "slug": "intercom", diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts new file mode 100644 index 00000000000..1f4b74cbce1 --- /dev/null +++ b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts @@ -0,0 +1,90 @@ +/** + * Tests for the Teams subscription renewal cron route. + * + * @vitest-environment node + */ +import { + authOAuthUtilsMock, + createMockRequest, + dbChainMock, + dbChainMockFns, + redisConfigMock, + redisConfigMockFns, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyCronAuth } = vi.hoisted(() => ({ + mockVerifyCronAuth: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/auth/internal', () => ({ + verifyCronAuth: mockVerifyCronAuth, +})) + +vi.mock('@/lib/core/config/redis', () => redisConfigMock) +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) + +import { GET } from './route' + +function createRequest() { + return createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/cron/renew-subscriptions' + ) +} + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('Teams subscription renewal route (fire-and-forget)', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + redisConfigMockFns.mockAcquireLock.mockResolvedValue(true) + redisConfigMockFns.mockReleaseLock.mockResolvedValue(true) + mockVerifyCronAuth.mockReturnValue(null) + }) + + it('returns the auth error when cron auth fails', async () => { + mockVerifyCronAuth.mockReturnValueOnce(new Response(null, { status: 401 }) as never) + + const response = await GET(createRequest()) + + expect(response.status).toBe(401) + expect(redisConfigMockFns.mockAcquireLock).not.toHaveBeenCalled() + }) + + it('acknowledges with 202 and renews in the background after acquiring the lock', async () => { + const response = await GET(createRequest()) + + expect(response.status).toBe(202) + const data = await response.json() + expect(data).toMatchObject({ status: 'started' }) + expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledWith( + 'teams-subscription-renewal-lock', + expect.any(String), + expect.any(Number) + ) + + await flushMicrotasks() + expect(dbChainMockFns.select).toHaveBeenCalled() + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith( + 'teams-subscription-renewal-lock', + expect.any(String) + ) + }) + + it('skips with 202 when the lock is already held', async () => { + redisConfigMockFns.mockAcquireLock.mockResolvedValueOnce(false) + + const response = await GET(createRequest()) + + expect(response.status).toBe(202) + const data = await response.json() + expect(data).toMatchObject({ status: 'skip' }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index a22156b3c94..9dcc8375623 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -1,14 +1,21 @@ import { db } from '@sim/db' import { account, webhook as webhookTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' import { and, eq, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { runDetached } from '@/lib/core/utils/background' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('TeamsSubscriptionRenewal') +const LOCK_KEY = 'teams-subscription-renewal-lock' +/** Lock TTL in seconds — generous enough to cover the Graph API renewal loop. */ +const LOCK_TTL_SECONDS = 300 + async function getCredentialOwner( credentialId: string ): Promise<{ userId: string; accountId: string } | null> { @@ -29,159 +36,189 @@ async function getCredentialOwner( } /** - * Cron endpoint to renew Microsoft Teams chat subscriptions before they expire + * Renews Microsoft Teams chat subscriptions that are close to expiring. * - * Teams subscriptions expire after ~3 days and must be renewed. - * Configured in helm/sim/values.yaml under cronjobs.jobs.renewSubscriptions + * Teams subscriptions expire after ~3 days and must be renewed. Runs detached + * from the HTTP response so the cron caller does not wait for the Graph API loop. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const authError = verifyCronAuth(request, 'Teams subscription renewal') - if (authError) { - return authError - } - - logger.info('Starting Teams subscription renewal job') - - let totalRenewed = 0 - let totalFailed = 0 - let totalChecked = 0 - - // Get all active Microsoft Teams webhooks - const webhooksWithWorkflows = await db - .select({ - webhook: webhookTable, - }) - .from(webhookTable) - .where( - and( - eq(webhookTable.isActive, true), - or( - eq(webhookTable.provider, 'microsoft-teams'), - eq(webhookTable.provider, 'microsoftteams') - ) +async function renewExpiringSubscriptions(): Promise<{ + checked: number + renewed: number + failed: number + total: number +}> { + logger.info('Starting Teams subscription renewal job') + + let totalRenewed = 0 + let totalFailed = 0 + let totalChecked = 0 + + // Get all active Microsoft Teams webhooks + const webhooksWithWorkflows = await db + .select({ + webhook: webhookTable, + }) + .from(webhookTable) + .where( + and( + eq(webhookTable.isActive, true), + or( + eq(webhookTable.provider, 'microsoft-teams'), + eq(webhookTable.provider, 'microsoftteams') ) ) - - logger.info( - `Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions` ) - // Renewal threshold: 48 hours before expiration - const renewalThreshold = new Date(Date.now() + 48 * 60 * 60 * 1000) + logger.info( + `Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions` + ) - for (const { webhook } of webhooksWithWorkflows) { - const config = (webhook.providerConfig as Record) || {} + // Renewal threshold: 48 hours before expiration + const renewalThreshold = new Date(Date.now() + 48 * 60 * 60 * 1000) - // Check if this is a Teams chat subscription that needs renewal - if (config.triggerId !== 'microsoftteams_chat_subscription') continue + for (const { webhook } of webhooksWithWorkflows) { + const config = (webhook.providerConfig as Record) || {} - const expirationStr = config.subscriptionExpiration as string | undefined - if (!expirationStr) continue + // Check if this is a Teams chat subscription that needs renewal + if (config.triggerId !== 'microsoftteams_chat_subscription') continue - const expiresAt = new Date(expirationStr) - if (expiresAt > renewalThreshold) continue // Not expiring soon + const expirationStr = config.subscriptionExpiration as string | undefined + if (!expirationStr) continue - totalChecked++ + const expiresAt = new Date(expirationStr) + if (expiresAt > renewalThreshold) continue // Not expiring soon - try { - logger.info( - `Renewing Teams subscription for webhook ${webhook.id} (expires: ${expiresAt.toISOString()})` - ) - - const credentialId = config.credentialId as string | undefined - const externalSubscriptionId = config.externalSubscriptionId as string | undefined - - if (!credentialId || !externalSubscriptionId) { - logger.error(`Missing credentialId or externalSubscriptionId for webhook ${webhook.id}`) - totalFailed++ - continue - } + totalChecked++ - const credentialOwner = await getCredentialOwner(credentialId) - if (!credentialOwner) { - logger.error(`Credential owner not found for credential ${credentialId}`) - totalFailed++ - continue - } + try { + logger.info( + `Renewing Teams subscription for webhook ${webhook.id} (expires: ${expiresAt.toISOString()})` + ) - // Get fresh access token - const accessToken = await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - `renewal-${webhook.id}` - ) + const credentialId = config.credentialId as string | undefined + const externalSubscriptionId = config.externalSubscriptionId as string | undefined - if (!accessToken) { - logger.error(`Failed to get access token for webhook ${webhook.id}`) - totalFailed++ - continue - } + if (!credentialId || !externalSubscriptionId) { + logger.error(`Missing credentialId or externalSubscriptionId for webhook ${webhook.id}`) + totalFailed++ + continue + } - // Extend subscription to maximum lifetime (4230 minutes = ~3 days) - const maxLifetimeMinutes = 4230 - const newExpirationDateTime = new Date( - Date.now() + maxLifetimeMinutes * 60 * 1000 - ).toISOString() - - const res = await fetch( - `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ expirationDateTime: newExpirationDateTime }), - } - ) + const credentialOwner = await getCredentialOwner(credentialId) + if (!credentialOwner) { + logger.error(`Credential owner not found for credential ${credentialId}`) + totalFailed++ + continue + } - if (!res.ok) { - const error = await res.json() - logger.error( - `Failed to renew Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`, - { status: res.status, error: error.error } - ) - totalFailed++ - continue - } + // Get fresh access token + const accessToken = await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + `renewal-${webhook.id}` + ) - const payload = await res.json() + if (!accessToken) { + logger.error(`Failed to get access token for webhook ${webhook.id}`) + totalFailed++ + continue + } - // Update webhook config with new expiration - const updatedConfig = { - ...config, - subscriptionExpiration: payload.expirationDateTime, + // Extend subscription to maximum lifetime (4230 minutes = ~3 days) + const maxLifetimeMinutes = 4230 + const newExpirationDateTime = new Date( + Date.now() + maxLifetimeMinutes * 60 * 1000 + ).toISOString() + + const res = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ expirationDateTime: newExpirationDateTime }), } + ) - await db - .update(webhookTable) - .set({ providerConfig: updatedConfig, updatedAt: new Date() }) - .where(eq(webhookTable.id, webhook.id)) - - logger.info( - `Successfully renewed Teams subscription for webhook ${webhook.id}. New expiration: ${payload.expirationDateTime}` + if (!res.ok) { + const error = await res.json() + logger.error( + `Failed to renew Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`, + { status: res.status, error: error.error } ) - totalRenewed++ - } catch (error) { - logger.error(`Error renewing subscription for webhook ${webhook.id}:`, error) totalFailed++ + continue + } + + const payload = await res.json() + + // Update webhook config with new expiration + const updatedConfig = { + ...config, + subscriptionExpiration: payload.expirationDateTime, } + + await db + .update(webhookTable) + .set({ providerConfig: updatedConfig, updatedAt: new Date() }) + .where(eq(webhookTable.id, webhook.id)) + + logger.info( + `Successfully renewed Teams subscription for webhook ${webhook.id}. New expiration: ${payload.expirationDateTime}` + ) + totalRenewed++ + } catch (error) { + logger.error(`Error renewing subscription for webhook ${webhook.id}:`, error) + totalFailed++ } + } - logger.info( - `Teams subscription renewal job completed. Checked: ${totalChecked}, Renewed: ${totalRenewed}, Failed: ${totalFailed}` - ) + logger.info( + `Teams subscription renewal job completed. Checked: ${totalChecked}, Renewed: ${totalRenewed}, Failed: ${totalFailed}` + ) - return NextResponse.json({ - success: true, - checked: totalChecked, - renewed: totalRenewed, - failed: totalFailed, - total: webhooksWithWorkflows.length, - }) - } catch (error) { - logger.error('Error in Teams subscription renewal job:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return { + checked: totalChecked, + renewed: totalRenewed, + failed: totalFailed, + total: webhooksWithWorkflows.length, } +} + +/** + * Cron endpoint to renew Microsoft Teams chat subscriptions before they expire. + * Configured in helm/sim/values.yaml under cronjobs.jobs.renewSubscriptions. + * + * Acknowledges the cron call immediately and renews subscriptions in the + * background; a Redis lock prevents overlapping runs. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'Teams subscription renewal') + if (authError) { + return authError + } + + const lockValue = generateShortId() + const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS) + if (!locked) { + return NextResponse.json( + { success: true, message: 'Renewal already in progress – skipped', status: 'skip' }, + { status: 202 } + ) + } + + runDetached('teams-subscription-renewal', async () => { + try { + await renewExpiringSubscriptions() + } finally { + await releaseLock(LOCK_KEY, lockValue).catch(() => {}) + } + }) + + return NextResponse.json( + { success: true, message: 'Teams subscription renewal started', status: 'started' }, + { status: 202 } + ) }) diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 964a46fcfa5..a976da6b6ee 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { environment } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -136,8 +137,8 @@ export const GET = withRouteHandler(async (request: Request) => { > return NextResponse.json({ data: decryptedVariables }, { status: 200 }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Environment fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ error: toError(error).message }, { status: 500 }) } }) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index cd9c5523231..31d4defca0a 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -20,12 +20,17 @@ const { mockGenerateInternalToken, fetchMock } = vi.hoisted(() => ({ })) const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions +const MCP_BYTE_LIMIT = 10 * 1024 * 1024 +const MCP_TOOLS_LIST_LIMIT = 100 vi.mock('@sim/db', () => dbChainMock) vi.mock('drizzle-orm', () => ({ and: vi.fn(), + asc: vi.fn(), eq: vi.fn(), + gt: vi.fn(), isNull: vi.fn(), + sql: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) @@ -43,7 +48,7 @@ vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 10_000, })) -import { GET, POST } from '@/app/api/mcp/serve/[serverId]/route' +import { DELETE, GET, POST } from '@/app/api/mcp/serve/[serverId]/route' describe('MCP Serve Route', () => { beforeEach(() => { @@ -101,7 +106,103 @@ describe('MCP Serve Route', () => { expect(response.status).toBe(401) }) - it('forwards X-API-Key for private server api_key auth', async () => { + it('allows unauthenticated GET metadata for public servers', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1') + const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.name).toBe('Public Server') + expect(hybridAuthMockFns.mockCheckHybridAuth).not.toHaveBeenCalled() + }) + + it('authenticates private SSE-style GET before returning unsupported transport', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Private Server', + workspaceId: 'ws-1', + isPublic: false, + createdBy: 'owner-1', + }, + ]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: false, + error: 'Unauthorized', + }) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + headers: { accept: 'text/event-stream' }, + }) + + const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + + expect(response.status).toBe(401) + }) + + it('returns 405 for authorized SSE-style GET', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Private Server', + workspaceId: 'ws-1', + isPublic: false, + createdBy: 'owner-1', + }, + ]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockGetUserEntityPermissions.mockResolvedValueOnce('read') + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + headers: { accept: 'text/event-stream' }, + }) + + const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(405) + expect(response.headers.get('allow')).toBe('GET, POST, DELETE') + expect(body.error.code).toBe('unsupported_transport') + }) + + it('requires authentication for DELETE even on public servers', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: false, + error: 'Unauthorized', + }) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'DELETE', + }) + const response = await DELETE(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + + expect(response.status).toBe(401) + }) + + it('uses an internal bridge token for private server api_key auth', async () => { dbChainMockFns.limit .mockResolvedValueOnce([ { @@ -122,6 +223,7 @@ describe('MCP Serve Route', () => { apiKeyType: 'personal', }) mockGetUserEntityPermissions.mockResolvedValueOnce('write') + mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1') fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ output: { ok: true } }), { status: 200, @@ -145,9 +247,10 @@ describe('MCP Serve Route', () => { expect(fetchMock).toHaveBeenCalledTimes(1) const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit const headers = fetchOptions.headers as Record - expect(headers['X-API-Key']).toBe('pk_test_123') - expect(headers.Authorization).toBeUndefined() - expect(mockGenerateInternalToken).not.toHaveBeenCalled() + expect(headers.Authorization).toBe('Bearer internal-token-user-1') + expect(headers['X-Sim-MCP-Tool-Actor']).toBe('authenticated-user') + expect(headers['X-API-Key']).toBeUndefined() + expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') }) it('forwards internal token for private server session auth', async () => { @@ -194,10 +297,586 @@ describe('MCP Serve Route', () => { const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit const headers = fetchOptions.headers as Record expect(headers.Authorization).toBe('Bearer internal-token-user-1') + expect(headers['X-Sim-MCP-Tool-Actor']).toBeUndefined() expect(headers['X-API-Key']).toBeUndefined() expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1') }) + it('rejects oversized MCP request bodies before parsing JSON', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + headers: { 'content-length': String(MCP_BYTE_LIMIT + 1) }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error.message).toContain('MCP request body exceeds maximum size') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects streamed MCP request bodies that exceed the cap without content-length', async () => { + const cancelSpy = vi.fn() + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MCP_BYTE_LIMIT)) + controller.enqueue(new Uint8Array(1)) + }, + cancel: cancelSpy, + }) + const request = new Request('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: stream, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) + const req = new NextRequest(request) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error.message).toContain('MCP request body') + expect(cancelSpy).toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects oversized tools/call arguments before internal fetch', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { payload: 'x'.repeat(MCP_BYTE_LIMIT) } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error.message).toContain('MCP request body') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('cancels and rejects oversized workflow execution responses', async () => { + const cancelSpy = vi.fn() + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + fetchMock.mockResolvedValueOnce( + new Response( + new ReadableStream({ + cancel: cancelSpy, + }), + { + status: 200, + headers: { 'content-length': String(MCP_BYTE_LIMIT + 1) }, + } + ) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error.message).toContain('MCP workflow execution response') + expect(cancelSpy).toHaveBeenCalled() + }) + + it('cancels and rejects streamed workflow responses that exceed the cap', async () => { + const cancelSpy = vi.fn() + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + fetchMock.mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MCP_BYTE_LIMIT)) + controller.enqueue(new Uint8Array(1)) + }, + cancel: cancelSpy, + }), + { + status: 200, + headers: { 'content-length': '1' }, + } + ) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error.message).toContain('MCP workflow execution response') + expect(cancelSpy).toHaveBeenCalled() + }) + + it('preserves recoverable workflow execution statuses through the MCP bridge', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: false, + error: 'Workflow execution request body exceeds maximum size', + }), + { + status: 413, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error.code).toBe(-32600) + expect(body.error.data.httpStatus).toBe(413) + const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit + const headers = fetchOptions.headers as Record + expect(headers['X-Sim-MCP-Tool-Call']).toBe('true') + }) + + it('preserves upstream error status when workflow response is not JSON', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + fetchMock.mockResolvedValueOnce(new Response('gateway timeout', { status: 408 })) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(408) + expect(body.error.data.httpStatus).toBe(408) + expect(body.error.data.retryable).toBe(true) + }) + + it('preserves falsy workflow outputs in MCP tool results', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, output: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.result.content[0].text).toBe('false') + }) + + it('serializes missing workflow output without failing the MCP tool call', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.result.content[0].text).toContain('"success": true') + }) + + it('serializes non-object workflow JSON responses from response blocks', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Private Server', + workspaceId: 'ws-1', + isPublic: false, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'user-1', + authType: 'api_key', + apiKeyType: 'personal', + }) + mockGetUserEntityPermissions.mockResolvedValueOnce('write') + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(['a', 'b']), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + headers: { 'X-API-Key': 'pk_test_123' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.result.content[0].text).toBe(JSON.stringify(['a', 'b'], null, 2)) + }) + + it('rejects duplicate tool names instead of choosing an arbitrary workflow', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([ + { toolName: 'tool_a', workflowId: 'wf-1' }, + { toolName: 'tool_a', workflowId: 'wf-2' }, + ]) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body.error.data.code).toBe('duplicate_tool_name') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('aborts the internal workflow fetch when the MCP client disconnects', async () => { + const requestAbortController = new AbortController() + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ isDeployed: true }]) + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + const signal = init.signal as AbortSignal + return new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })) + }, + { once: true } + ) + requestAbortController.abort() + }) + }) + + const req = new NextRequest( + new Request('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + signal: requestAbortController.signal, + }) + ) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + + expect(response.status).toBe(499) + }) + + it('paginates tools/list by tool count', async () => { + const pageRows = Array.from({ length: MCP_TOOLS_LIST_LIMIT + 1 }, (_, index) => ({ + id: `tool-id-${String(index).padStart(3, '0')}`, + toolNameBytes: 10 + index, + toolDescriptionBytes: 0, + parameterSchemaBytes: 32, + })) + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(pageRows) + .mockResolvedValueOnce( + pageRows.map((row, index) => ({ + id: row.id, + toolName: `tool_${index}`, + toolDescription: null, + parameterSchema: { type: 'object', properties: {} }, + })) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.result.tools).toHaveLength(MCP_TOOLS_LIST_LIMIT) + expect(body.result.nextCursor).toBe('tool-id-099') + }) + + it('bounds tools/list by stored metadata estimate', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'tool-id-1', + toolNameBytes: 6, + toolDescriptionBytes: MCP_BYTE_LIMIT + 1, + parameterSchemaBytes: 32, + }, + ]) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error.message).toContain('tools/list response is too large') + }) + + it('bounds tools/list by final serialized response size', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'tool-id-1', + toolNameBytes: 6, + toolDescriptionBytes: 1, + parameterSchemaBytes: 32, + }, + ]) + .mockResolvedValueOnce([ + { + id: 'tool-id-1', + toolName: 'tool_a', + toolDescription: 'x'.repeat(MCP_BYTE_LIMIT), + parameterSchema: { type: 'object', properties: {} }, + }, + ]) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }) + + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error.message).toContain('tools/list response is too large') + }) + describe('initialize protocol version negotiation', () => { async function callInitialize(protocolVersion?: string) { dbChainMockFns.limit.mockResolvedValueOnce([ diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index d876dcd0ef2..8f910764b3d 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -20,7 +20,7 @@ import { import { db } from '@sim/db' import { workflow, workflowMcpServer, workflowMcpTool, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' +import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { mcpJsonRpcNotificationSchema, @@ -28,15 +28,36 @@ import { mcpServeRouteParamsSchema, mcpToolCallParamsSchema, } from '@/lib/api/contracts/mcp' -import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid' +import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { + assertContentLengthWithinLimit, + assertKnownSizeWithinLimit, + isPayloadSizeLimitError, + readResponseTextWithLimit, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' +import { + MAX_MCP_PARAMETER_SCHEMA_BYTES, + MAX_MCP_TOOLS_LIST_RESPONSE_BYTES, + MAX_MCP_TOOLS_PER_SERVER, + MAX_MCP_WORKFLOW_RESPONSE_BYTES, + MCP_TOOL_BRIDGE_ACTOR_HEADER, + MCP_TOOL_BRIDGE_HEADER, +} from '@/lib/mcp/constants' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') +const MAX_MCP_SERVE_BODY_BYTES = 10 * 1024 * 1024 +const MAX_MCP_WORKFLOW_REQUEST_BYTES = 10 * 1024 * 1024 +const MAX_MCP_TOOL_RESULT_TEXT_BYTES = 10 * 1024 * 1024 +const MAX_MCP_TOOLS_LIST_COUNT = MAX_MCP_TOOLS_PER_SERVER +const MAX_MCP_TOOLS_LIST_SCHEMA_BYTES = MAX_MCP_PARAMETER_SCHEMA_BYTES +const MB = 1024 * 1024 function negotiateProtocolVersion(rpcParams: unknown): string { const requested = @@ -56,9 +77,8 @@ interface RouteParams { } interface ExecuteAuthContext { - authType?: AuthResult['authType'] userId: string - apiKey?: string | null + useAuthenticatedUserAsActor: boolean } function createResponse(id: RequestId, result: unknown): JSONRPCResultResponse { @@ -69,12 +89,197 @@ function createResponse(id: RequestId, result: unknown): JSONRPCResultResponse { } } -function createError(id: RequestId, code: ErrorCode | number, message: string): JSONRPCError { +function createError( + id: RequestId, + code: ErrorCode | number, + message: string, + data?: unknown +): JSONRPCError { return { jsonrpc: '2.0', id, - error: { code, message }, + error: { code, message, ...(data !== undefined && { data }) }, + } +} + +function clientCancelledJsonRpcResponse(id: RequestId): NextResponse { + return NextResponse.json( + createError(id, ErrorCode.ConnectionClosed, 'Client cancelled request'), + { + status: 499, + } + ) +} + +function callerAbortedJsonRpcResponse( + id: RequestId, + abortSignal?: ManagedAbortSignal | null +): NextResponse | null { + return abortSignal?.isCallerAborted() ? clientCancelledJsonRpcResponse(id) : null +} + +function limitMessage(label: string, maxBytes: number): string { + return `${label} exceeds maximum size of ${Math.round(maxBytes / MB)}MB` +} + +async function readJsonRpcBody(request: NextRequest): Promise { + assertContentLengthWithinLimit(request.headers, MAX_MCP_SERVE_BODY_BYTES, 'MCP request body') + const buffer = await readStreamToBufferWithLimit(request.body, { + maxBytes: MAX_MCP_SERVE_BODY_BYTES, + label: 'MCP request body', + signal: request.signal, + }) + return JSON.parse(buffer.toString('utf-8')) +} + +interface ManagedAbortSignal { + signal: AbortSignal + cleanup: () => void + isCallerAborted: () => boolean + isTimedOut: () => boolean +} + +function createManagedAbortSignal( + parentSignal: AbortSignal, + timeoutMs: number +): ManagedAbortSignal { + const controller = new AbortController() + let callerAborted = false + let timedOut = false + + const timeoutId = setTimeout(() => { + timedOut = true + controller.abort(new Error(`MCP workflow execution timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + const abortFromParent = () => { + callerAborted = true + controller.abort(parentSignal.reason ?? new Error('MCP client disconnected')) + } + + if (parentSignal.aborted) { + abortFromParent() + } else { + parentSignal.addEventListener('abort', abortFromParent, { once: true }) + } + + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeoutId) + parentSignal.removeEventListener('abort', abortFromParent) + }, + isCallerAborted: () => callerAborted || parentSignal.aborted, + isTimedOut: () => timedOut, + } +} + +function serializeToolText(value: unknown): string { + const text = JSON.stringify(value, null, 2) ?? 'null' + assertKnownSizeWithinLimit( + Buffer.byteLength(text, 'utf-8'), + MAX_MCP_TOOL_RESULT_TEXT_BYTES, + 'MCP tool result text' + ) + return text +} + +function createJsonRpcResponseWithLimit( + id: RequestId, + result: unknown, + maxBytes: number, + label: string +): NextResponse { + const responseBody = createResponse(id, result) + const responseText = JSON.stringify(responseBody) + assertKnownSizeWithinLimit(Buffer.byteLength(responseText, 'utf-8'), maxBytes, label) + return new NextResponse(responseText, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function toToolInputSchema(schema: unknown): Partial { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return {} + + const candidate = schema as Record + const properties = + candidate.properties && + typeof candidate.properties === 'object' && + !Array.isArray(candidate.properties) + ? (candidate.properties as Tool['inputSchema']['properties']) + : {} + const required = Array.isArray(candidate.required) + ? candidate.required.filter((entry): entry is string => typeof entry === 'string') + : undefined + + return { + properties, + ...(required && required.length > 0 && { required }), + } +} + +function isJsonObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function parseJsonValue(text: string): { success: true; value: unknown } | { success: false } { + if (!text) return { success: true, value: {} } + try { + return { success: true, value: JSON.parse(text) } + } catch { + return { success: false } + } +} + +function hasResponseField(value: Record, property: string): boolean { + return Object.hasOwn(value, property) +} + +function getWorkflowErrorStatus(status: number): number { + return [400, 401, 403, 404, 408, 409, 413, 429, 499, 503].includes(status) ? status : 500 +} + +function getWorkflowErrorCode(status: number, executeResult: Record): ErrorCode { + if (status === 499) return ErrorCode.ConnectionClosed + if (status === 400) return ErrorCode.InvalidParams + if (status === 413 && executeResult.code !== 'workflow_response_too_large') { + return ErrorCode.InvalidRequest } + return ErrorCode.InternalError +} + +function getToolsListCursor(rpcParams: unknown): string | undefined { + if (!rpcParams || typeof rpcParams !== 'object' || !('cursor' in rpcParams)) return undefined + const cursor = (rpcParams as { cursor?: unknown }).cursor + return typeof cursor === 'string' && cursor.length > 0 ? cursor : undefined +} + +async function getDuplicateToolName(serverId: string): Promise { + const [duplicate] = await db + .select({ toolName: workflowMcpTool.toolName }) + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + .groupBy(workflowMcpTool.toolName) + .having(sql`count(*) > 1`) + .limit(1) + + return duplicate?.toolName ?? null +} + +async function readWorkflowExecutionResult( + response: Response, + signal: AbortSignal +): Promise { + const text = await readResponseTextWithLimit(response, { + maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES, + label: 'MCP workflow execution response', + signal, + }) + const parsed = parseJsonValue(text) + if (parsed.success) return parsed.value + if (!response.ok) return { error: response.statusText || 'Workflow execution failed' } + throw new Error('Invalid workflow execution response') } async function getServer(serverId: string) { @@ -100,6 +305,64 @@ async function getServer(serverId: string) { return server } +type WorkflowMcpServeServer = NonNullable>> + +async function authorizeMcpServeRequest( + request: NextRequest, + server: WorkflowMcpServeServer, + options: { requireAuthForPublic?: boolean } = {} +): Promise<{ response?: NextResponse; executeAuthContext?: ExecuteAuthContext }> { + if (server.isPublic && !options.requireAuthForPublic) return {} + + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + } + + if (server.isPublic) return {} + + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== server.workspaceId) { + return { response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + } + + const workspacePermission = await getUserEntityPermissions( + auth.userId, + 'workspace', + server.workspaceId + ) + if (workspacePermission === null) { + return { response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } + } + + return { + executeAuthContext: { + userId: auth.userId, + useAuthenticatedUserAsActor: + auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal', + }, + } +} + +function unsupportedSseTransportResponse(): NextResponse { + return NextResponse.json( + { + error: { + code: 'unsupported_transport', + message: 'SSE transport is not supported for workflow MCP servers', + supportedTransports: ['streamable-http'], + allowedMethods: ['GET', 'POST', 'DELETE'], + }, + }, + { + status: 405, + headers: { + Allow: 'GET, POST, DELETE', + 'X-MCP-Supported-Transport': 'streamable-http', + }, + } + ) +} + export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise }) => { try { @@ -109,24 +372,11 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } - if (!server.isPublic) { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const authResult = await authorizeMcpServeRequest(request, server) + if (authResult.response) return authResult.response - if (auth.apiKeyType === 'workspace' && auth.workspaceId !== server.workspaceId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const workspacePermission = await getUserEntityPermissions( - auth.userId, - 'workspace', - server.workspaceId - ) - if (workspacePermission === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + if (request.headers.get('accept')?.includes('text/event-stream')) { + return unsupportedSseTransportResponse() } return NextResponse.json({ @@ -152,36 +402,29 @@ export const POST = withRouteHandler( } let executeAuthContext: ExecuteAuthContext | null = null - if (!server.isPublic) { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (auth.apiKeyType === 'workspace' && auth.workspaceId !== server.workspaceId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const workspacePermission = await getUserEntityPermissions( - auth.userId, - 'workspace', - server.workspaceId - ) - if (workspacePermission === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - executeAuthContext = { - authType: auth.authType, - userId: auth.userId, - apiKey: auth.authType === AuthType.API_KEY ? request.headers.get('X-API-Key') : null, - } - } + const authResult = await authorizeMcpServeRequest(request, server) + if (authResult.response) return authResult.response + executeAuthContext = authResult.executeAuthContext ?? null let body: unknown try { - body = await request.json() - } catch { + body = await readJsonRpcBody(request) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + logger.warn('MCP request body exceeded size limit', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }) + return NextResponse.json( + createError( + 0, + ErrorCode.InvalidRequest, + limitMessage('MCP request body', MAX_MCP_SERVE_BODY_BYTES) + ), + { status: 413 } + ) + } + if (request.signal.aborted) return clientCancelledJsonRpcResponse(0) return NextResponse.json(createError(0, ErrorCode.ParseError, 'Invalid JSON body'), { status: 400, }) @@ -238,7 +481,7 @@ export const POST = withRouteHandler( return NextResponse.json(createResponse(id, {})) case 'tools/list': - return handleToolsList(id, serverId) + return handleToolsList(id, serverId, rpcParams) case 'tools/call': { const paramsValidation = mcpToolCallParamsSchema.safeParse(rpcParams) @@ -257,7 +500,8 @@ export const POST = withRouteHandler( paramsValidation.data, executeAuthContext, server.isPublic ? server.createdBy : undefined, - request.headers.get(SIM_VIA_HEADER) + request.headers.get(SIM_VIA_HEADER), + request.signal ) } @@ -278,20 +522,98 @@ export const POST = withRouteHandler( } ) -async function handleToolsList(id: RequestId, serverId: string): Promise { +async function handleToolsList( + id: RequestId, + serverId: string, + rpcParams: unknown +): Promise { try { + const duplicateToolName = await getDuplicateToolName(serverId) + if (duplicateToolName) { + return NextResponse.json( + createError(id, ErrorCode.InvalidRequest, 'MCP server has duplicate tool names', { + code: 'duplicate_tool_name', + toolName: duplicateToolName, + recovery: 'Rename or remove duplicate workflow MCP tools before listing this server', + }), + { status: 409 } + ) + } + + const cursor = getToolsListCursor(rpcParams) + const pageCondition = cursor ? gt(workflowMcpTool.id, cursor) : undefined + const toolSizes = await db + .select({ + id: workflowMcpTool.id, + toolNameBytes: sql`octet_length(${workflowMcpTool.toolName})`, + toolDescriptionBytes: sql`coalesce(octet_length(${workflowMcpTool.toolDescription}), 0)`, + parameterSchemaBytes: sql`octet_length(${workflowMcpTool.parameterSchema}::text)`, + }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt), + pageCondition + ) + ) + .orderBy(asc(workflowMcpTool.id)) + .limit(MAX_MCP_TOOLS_LIST_COUNT + 1) + + const pageSizes = toolSizes.slice(0, MAX_MCP_TOOLS_LIST_COUNT) + + let estimatedSchemaBytes = 0 + let estimatedMetadataBytes = 0 + for (const toolSize of pageSizes) { + estimatedSchemaBytes += Number(toolSize.parameterSchemaBytes) || 0 + estimatedMetadataBytes += + (Number(toolSize.toolNameBytes) || 0) + + (Number(toolSize.toolDescriptionBytes) || 0) + + (Number(toolSize.parameterSchemaBytes) || 0) + assertKnownSizeWithinLimit( + estimatedSchemaBytes, + MAX_MCP_TOOLS_LIST_SCHEMA_BYTES, + 'MCP tools/list schemas' + ) + assertKnownSizeWithinLimit( + estimatedMetadataBytes, + MAX_MCP_TOOLS_LIST_RESPONSE_BYTES, + 'MCP tools/list stored metadata' + ) + } + const tools = await db .select({ + id: workflowMcpTool.id, toolName: workflowMcpTool.toolName, toolDescription: workflowMcpTool.toolDescription, parameterSchema: workflowMcpTool.parameterSchema, }) .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + .where( + and( + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt), + pageCondition + ) + ) + .orderBy(asc(workflowMcpTool.id)) + .limit(MAX_MCP_TOOLS_LIST_COUNT + 1) + const hasNextPage = tools.length > MAX_MCP_TOOLS_LIST_COUNT + const pageTools = tools.slice(0, MAX_MCP_TOOLS_LIST_COUNT) + const nextCursor = hasNextPage ? pageTools.at(-1)?.id : undefined + let schemaBytes = 0 const result: ListToolsResult = { - tools: tools.map((tool) => { - const schema = tool.parameterSchema as Partial | null + tools: pageTools.map((tool) => { + const schema = toToolInputSchema(tool.parameterSchema) + const schemaByteLength = Buffer.byteLength(JSON.stringify(schema ?? {}), 'utf-8') + schemaBytes += schemaByteLength + assertKnownSizeWithinLimit( + schemaBytes, + MAX_MCP_TOOLS_LIST_SCHEMA_BYTES, + 'MCP tools/list schemas' + ) return { name: tool.toolName, description: tool.toolDescription || `Execute workflow: ${tool.toolName}`, @@ -302,10 +624,32 @@ async function handleToolsList(id: RequestId, serverId: string): Promise } | undefined, executeAuthContext?: ExecuteAuthContext | null, publicServerOwnerId?: string, - simViaHeader?: string | null + simViaHeader?: string | null, + requestSignal?: AbortSignal ): Promise { + let abortSignal: ManagedAbortSignal | null = null try { if (!params?.name) { return NextResponse.json(createError(id, ErrorCode.InvalidParams, 'Tool name required'), { status: 400, }) } + abortSignal = createManagedAbortSignal( + requestSignal ?? new AbortController().signal, + getMaxExecutionTimeout() + ) + const abortedBeforeToolLookup = callerAbortedJsonRpcResponse(id, abortSignal) + if (abortedBeforeToolLookup) return abortedBeforeToolLookup - const [tool] = await db + const matchingTools = await db .select({ toolName: workflowMcpTool.toolName, workflowId: workflowMcpTool.workflowId, @@ -341,7 +693,21 @@ async function handleToolsCall( isNull(workflowMcpTool.archivedAt) ) ) - .limit(1) + .orderBy(asc(workflowMcpTool.id)) + .limit(2) + const abortedAfterToolLookup = callerAbortedJsonRpcResponse(id, abortSignal) + if (abortedAfterToolLookup) return abortedAfterToolLookup + if (matchingTools.length > 1) { + return NextResponse.json( + createError(id, ErrorCode.InvalidRequest, `Duplicate tool name: ${params.name}`, { + code: 'duplicate_tool_name', + toolName: params.name, + recovery: 'Rename or remove duplicate workflow MCP tools before calling this tool', + }), + { status: 409 } + ) + } + const [tool] = matchingTools if (!tool) { return NextResponse.json( createError(id, ErrorCode.InvalidParams, `Tool not found: ${params.name}`), @@ -356,6 +722,8 @@ async function handleToolsCall( .from(workflow) .where(and(eq(workflow.id, tool.workflowId), isNull(workflow.archivedAt))) .limit(1) + const abortedAfterWorkflowLookup = callerAbortedJsonRpcResponse(id, abortSignal) + if (abortedAfterWorkflowLookup) return abortedAfterWorkflowLookup if (!wf?.isDeployed) { return NextResponse.json( @@ -367,17 +735,22 @@ async function handleToolsCall( } const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute` - const headers: Record = { 'Content-Type': 'application/json' } + const headers: Record = { + 'Content-Type': 'application/json', + [MCP_TOOL_BRIDGE_HEADER]: 'true', + } + + const abortedBeforeExecute = callerAbortedJsonRpcResponse(id, abortSignal) + if (abortedBeforeExecute) return abortedBeforeExecute if (publicServerOwnerId) { const internalToken = await generateInternalToken(publicServerOwnerId) headers.Authorization = `Bearer ${internalToken}` } else if (executeAuthContext) { - if (executeAuthContext.authType === AuthType.API_KEY && executeAuthContext.apiKey) { - headers['X-API-Key'] = executeAuthContext.apiKey - } else { - const internalToken = await generateInternalToken(executeAuthContext.userId) - headers.Authorization = `Bearer ${internalToken}` + const internalToken = await generateInternalToken(executeAuthContext.userId) + headers.Authorization = `Bearer ${internalToken}` + if (executeAuthContext.useAuthenticatedUserAsActor) { + headers[MCP_TOOL_BRIDGE_ACTOR_HEADER] = 'authenticated-user' } } @@ -387,39 +760,111 @@ async function handleToolsCall( logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) + const workflowRequestBody = JSON.stringify({ + input: params.arguments || {}, + triggerType: 'mcp', + includeFileBase64: false, + }) + assertKnownSizeWithinLimit( + Buffer.byteLength(workflowRequestBody, 'utf-8'), + MAX_MCP_WORKFLOW_REQUEST_BYTES, + 'MCP workflow execution request body' + ) const response = await fetch(executeUrl, { method: 'POST', headers, - body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }), - signal: AbortSignal.timeout(getMaxExecutionTimeout()), + body: workflowRequestBody, + signal: abortSignal.signal, }) - const executeResult = await response.json() + const executeResult = await readWorkflowExecutionResult(response, abortSignal.signal) + const executeResultObject = isJsonObject(executeResult) ? executeResult : null if (!response.ok) { + const errorMessage = + typeof executeResultObject?.error === 'string' + ? executeResultObject.error + : 'Workflow execution failed' + const status = getWorkflowErrorStatus(response.status) + const responseHeaders: Record = {} + const retryAfter = response.headers.get('retry-after') + if (retryAfter) responseHeaders['Retry-After'] = retryAfter return NextResponse.json( createError( id, - ErrorCode.InternalError, - executeResult.error || 'Workflow execution failed' + getWorkflowErrorCode(response.status, executeResultObject ?? {}), + errorMessage, + { + httpStatus: response.status, + retryable: [408, 429, 503].includes(response.status), + code: + typeof executeResultObject?.code === 'string' ? executeResultObject.code : undefined, + } ), - { status: 500 } + { status, headers: responseHeaders } ) } + const toolOutput = + executeResultObject?.success === false + ? executeResult + : executeResultObject && hasResponseField(executeResultObject, 'output') + ? executeResultObject.output + : executeResult const result: CallToolResult = { - content: [ - { type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) }, - ], - isError: executeResult.success === false, + content: [{ type: 'text', text: serializeToolText(toolOutput) }], + isError: executeResultObject?.success === false, } - return NextResponse.json(createResponse(id, result)) + return createJsonRpcResponseWithLimit( + id, + result, + MAX_MCP_WORKFLOW_RESPONSE_BYTES, + 'MCP tool call response' + ) } catch (error) { + if (abortSignal?.isTimedOut()) { + return NextResponse.json( + createError(id, ErrorCode.InternalError, 'Tool execution timed out', { + code: 'timeout', + retryable: true, + }), + { + status: 408, + } + ) + } + const abortedAfterExecute = callerAbortedJsonRpcResponse(id, abortSignal) + if (abortedAfterExecute) return abortedAfterExecute + if (isPayloadSizeLimitError(error)) { + logger.warn('MCP tool call exceeded size limit', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + label: error.label, + }) + return NextResponse.json( + createError( + id, + error.label === 'MCP workflow execution request body' + ? ErrorCode.InvalidParams + : ErrorCode.InternalError, + limitMessage(error.label, error.maxBytes), + { + code: 'payload_too_large', + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + retryable: false, + } + ), + { status: 413 } + ) + } logger.error('Error calling tool:', error) return NextResponse.json(createError(id, ErrorCode.InternalError, 'Tool execution failed'), { status: 500, }) + } finally { + abortSignal?.cleanup() } } @@ -432,21 +877,10 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (!server.isPublic) { - const workspacePermission = await getUserEntityPermissions( - auth.userId, - 'workspace', - server.workspaceId - ) - if (workspacePermission === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } + const authResult = await authorizeMcpServeRequest(request, server, { + requireAuthForPublic: true, + }) + if (authResult.response) return authResult.response logger.info(`MCP session terminated for server ${serverId}`) return new NextResponse(null, { status: 204 }) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index 4242fdef119..b008e8ae971 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -3,7 +3,11 @@ import { toError } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { updateMcpServerBodySchema } from '@/lib/api/contracts/mcp' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { performUpdateMcpServer } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, @@ -28,7 +32,7 @@ export const PATCH = withRouteHandler( try { const { id: serverId } = await params - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = updateMcpServerBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -82,6 +86,8 @@ export const PATCH = withRouteHandler( server: { ...rest, hasOauthClientSecret: !!_secret }, }) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse logger.error(`[${requestId}] Error updating MCP server:`, error) return createMcpErrorResponse(toError(error), 'Failed to update MCP server', 500) } diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index 1d02caeef74..cfcc099eb85 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -7,7 +7,11 @@ import type { NextRequest } from 'next/server' import { createMcpServerBodySchema, deleteMcpServerByQuerySchema } from '@/lib/api/contracts/mcp' import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { performCreateMcpServer, performDeleteMcpServer } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, @@ -55,7 +59,7 @@ export const POST = withRouteHandler( withMcpAuth('write')( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = createMcpServerBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -120,6 +124,8 @@ export const POST = withRouteHandler( result.updated ? 200 : 201 ) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse logger.error(`[${requestId}] Error registering MCP server:`, error) return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) } diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index c017de7a34c..b570f8f8ff0 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -11,7 +11,11 @@ import { validateMcpDomain, validateMcpServerSsrf, } from '@/lib/mcp/domain-check' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { detectMcpAuthType } from '@/lib/mcp/oauth' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import type { McpAuthType, McpTransport } from '@/lib/mcp/types' @@ -64,7 +68,7 @@ function sanitizeConnectionError(error: unknown): string { export const POST = withRouteHandler( withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = mcpServerTestBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -235,6 +239,8 @@ export const POST = withRouteHandler( return createMcpSuccessResponse(result, result.success ? 200 : 400) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse logger.error(`[${requestId}] Error testing MCP server connection:`, error) return createMcpErrorResponse(toError(error), 'Failed to test server connection', 500) } diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index 612788b4875..84acdad0b3d 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -1,18 +1,55 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { mcpToolDiscoveryQuerySchema, refreshMcpToolsBodySchema } from '@/lib/api/contracts/mcp' import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { McpOauthAuthorizationRequiredError, type McpToolDiscoveryResponse } from '@/lib/mcp/types' import { categorizeError, createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('McpToolDiscoveryAPI') +const MCP_REFRESH_DISCOVERY_CONCURRENCY = 5 export const dynamic = 'force-dynamic' +async function settleWithConcurrency( + items: T[], + concurrency: number, + task: (item: T) => Promise +): Promise>> { + const results: Array | undefined> = new Array(items.length) + let nextIndex = 0 + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + try { + results[index] = { status: 'fulfilled', value: await task(items[index]) } + } catch (reason) { + results[index] = { status: 'rejected', reason } + } + } + }) + + await Promise.all(workers) + + return results.map( + (result) => + result ?? { + status: 'rejected', + reason: new Error('MCP refresh discovery task did not run'), + } + ) +} + export const GET = withRouteHandler( withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { @@ -63,7 +100,7 @@ export const GET = withRouteHandler( export const POST = withRouteHandler( withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = refreshMcpToolsBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -74,11 +111,13 @@ export const POST = withRouteHandler( logger.info(`[${requestId}] Refreshing tools for ${serverIds.length} servers`) - const results = await Promise.allSettled( - serverIds.map(async (serverId: string) => { + const results = await settleWithConcurrency( + serverIds, + MCP_REFRESH_DISCOVERY_CONCURRENCY, + async (serverId: string) => { const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, true) return { serverId, toolCount: tools.length } - }) + } ) const successes: Array<{ serverId: string; toolCount: number }> = [] @@ -91,7 +130,7 @@ export const POST = withRouteHandler( } else { failures.push({ serverId, - error: result.reason instanceof Error ? result.reason.message : 'Unknown error', + error: getErrorMessage(result.reason, 'Unknown error'), }) } }) @@ -107,6 +146,8 @@ export const POST = withRouteHandler( }, }) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse if ( error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts index 8599a5fcadf..bb4e3650fc7 100644 --- a/apps/sim/app/api/mcp/tools/execute/route.ts +++ b/apps/sim/app/api/mcp/tools/execute/route.ts @@ -8,7 +8,11 @@ import { getExecutionTimeout } from '@/lib/core/execution-limits' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' import { @@ -53,7 +57,7 @@ export const POST = withRouteHandler( withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { let serverId: string | undefined try { - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = mcpToolExecutionBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -235,6 +239,8 @@ export const POST = withRouteHandler( return createMcpSuccessResponse(transformedResult) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse if ( error instanceof McpOauthAuthorizationRequiredError || error instanceof McpOauthRedirectRequired || diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 803e9879e70..718b65aa0bb 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -9,7 +9,11 @@ import { workflowMcpServerParamsSchema, } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { performDeleteWorkflowMcpServer, performUpdateWorkflowMcpServer, @@ -94,7 +98,7 @@ export const PATCH = withRouteHandler( ) => { try { const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = updateWorkflowMcpServerBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -130,6 +134,8 @@ export const PATCH = withRouteHandler( return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse logger.error(`[${requestId}] Error updating workflow MCP server:`, error) return createMcpErrorResponse(toError(error), 'Failed to update workflow MCP server', 500) } diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 95e54946ded..3d6c65dfe32 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -9,9 +9,17 @@ import { workflowMcpToolParamsSchema, } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { performDeleteWorkflowMcpTool, performUpdateWorkflowMcpTool } from '@/lib/mcp/orchestration' -import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { + createMcpErrorResponse, + createMcpSuccessResponse, + mcpOrchestrationStatus, +} from '@/lib/mcp/utils' const logger = createLogger('WorkflowMcpToolAPI') @@ -86,7 +94,7 @@ export const PATCH = withRouteHandler( ) => { try { const { id: serverId, toolId } = workflowMcpToolParamsSchema.parse(await params) - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = updateWorkflowMcpToolBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -109,12 +117,10 @@ export const PATCH = withRouteHandler( parameterSchema: body.parameterSchema, }) if (!result.success || !result.tool) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 return createMcpErrorResponse( new Error(result.error || 'Failed to update tool'), result.error || 'Failed to update tool', - status + mcpOrchestrationStatus(result.errorCode) ) } @@ -124,6 +130,8 @@ export const PATCH = withRouteHandler( return createMcpSuccessResponse({ tool: updatedTool }) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse logger.error(`[${requestId}] Error updating tool:`, error) return createMcpErrorResponse(toError(error), 'Failed to update tool', 500) } @@ -158,7 +166,7 @@ export const DELETE = withRouteHandler( return createMcpErrorResponse( new Error(result.error || 'Tool not found'), result.error || 'Tool not found', - result.errorCode === 'not_found' ? 404 : 500 + mcpOrchestrationStatus(result.errorCode) ) } const deletedTool = result.tool diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 4d87728cc2e..e3fa659cd14 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -9,7 +9,11 @@ import { workflowMcpServerParamsSchema, } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { performCreateWorkflowMcpTool } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, @@ -96,7 +100,7 @@ export const POST = withRouteHandler( ) => { try { const { id: serverId } = workflowMcpServerParamsSchema.parse(await params) - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = createWorkflowMcpToolBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -136,6 +140,8 @@ export const POST = withRouteHandler( return createMcpSuccessResponse({ tool }, 201) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse logger.error(`[${requestId}] Error adding tool:`, error) return createMcpErrorResponse(toError(error), 'Failed to add tool', 500) } diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 4356592e4c8..10398e6eeb4 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -6,7 +6,11 @@ import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createWorkflowMcpServerBodySchema } from '@/lib/api/contracts/workflow-mcp-servers' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' +import { + mcpBodyReadErrorResponse, + readMcpJsonBodyWithLimit, + withMcpAuth, +} from '@/lib/mcp/middleware' import { performCreateWorkflowMcpServer } from '@/lib/mcp/orchestration' import { createMcpErrorResponse, @@ -96,7 +100,7 @@ export const POST = withRouteHandler( withMcpAuth('write')( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { - const rawBody = getParsedBody(request) ?? (await request.json()) + const rawBody = await readMcpJsonBodyWithLimit(request) const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody) if (!parsedBody.success) { @@ -138,6 +142,8 @@ export const POST = withRouteHandler( return createMcpSuccessResponse({ server, addedTools }, 201) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse logger.error(`[${requestId}] Error creating workflow MCP server:`, error) return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500) } diff --git a/apps/sim/app/api/notifications/poll/route.test.ts b/apps/sim/app/api/notifications/poll/route.test.ts new file mode 100644 index 00000000000..fd41807c47e --- /dev/null +++ b/apps/sim/app/api/notifications/poll/route.test.ts @@ -0,0 +1,93 @@ +/** + * Tests for the inactivity-alert polling cron route. + * + * @vitest-environment node + */ +import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyCronAuth, mockPollInactivityAlerts } = vi.hoisted(() => ({ + mockVerifyCronAuth: vi.fn().mockReturnValue(null), + mockPollInactivityAlerts: vi.fn().mockResolvedValue({ checked: 0, delivered: 0 }), +})) + +vi.mock('@/lib/auth/internal', () => ({ + verifyCronAuth: mockVerifyCronAuth, +})) + +vi.mock('@/lib/core/config/redis', () => redisConfigMock) + +vi.mock('@/lib/notifications/inactivity-polling', () => ({ + pollInactivityAlerts: mockPollInactivityAlerts, +})) + +import { GET } from './route' + +function createRequest() { + return createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/notifications/poll') +} + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('inactivity alert polling route (fire-and-forget)', () => { + beforeEach(() => { + vi.clearAllMocks() + redisConfigMockFns.mockAcquireLock.mockResolvedValue(true) + redisConfigMockFns.mockReleaseLock.mockResolvedValue(true) + mockVerifyCronAuth.mockReturnValue(null) + mockPollInactivityAlerts.mockResolvedValue({ checked: 0, delivered: 0 }) + }) + + it('returns the auth error when cron auth fails', async () => { + mockVerifyCronAuth.mockReturnValueOnce(new Response(null, { status: 401 }) as never) + + const response = await GET(createRequest()) + + expect(response.status).toBe(401) + expect(mockPollInactivityAlerts).not.toHaveBeenCalled() + }) + + it('acknowledges with 202 and polls in the background after acquiring the lock', async () => { + const response = await GET(createRequest()) + + expect(response.status).toBe(202) + const data = await response.json() + expect(data).toMatchObject({ status: 'started' }) + expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledWith( + 'inactivity-alert-polling-lock', + expect.any(String), + expect.any(Number) + ) + + await flushMicrotasks() + expect(mockPollInactivityAlerts).toHaveBeenCalledTimes(1) + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith( + 'inactivity-alert-polling-lock', + expect.any(String) + ) + }) + + it('skips with 202 when the lock is already held', async () => { + redisConfigMockFns.mockAcquireLock.mockResolvedValueOnce(false) + + const response = await GET(createRequest()) + + expect(response.status).toBe(202) + const data = await response.json() + expect(data).toMatchObject({ status: 'skip' }) + expect(mockPollInactivityAlerts).not.toHaveBeenCalled() + }) + + it('releases the lock even when polling throws', async () => { + mockPollInactivityAlerts.mockRejectedValueOnce(new Error('poll failed')) + + const response = await GET(createRequest()) + + expect(response.status).toBe(202) + await flushMicrotasks() + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith( + 'inactivity-alert-polling-lock', + expect.any(String) + ) + }) +}) diff --git a/apps/sim/app/api/notifications/poll/route.ts b/apps/sim/app/api/notifications/poll/route.ts index f9e3c8c0c2b..d81707cb192 100644 --- a/apps/sim/app/api/notifications/poll/route.ts +++ b/apps/sim/app/api/notifications/poll/route.ts @@ -6,6 +6,7 @@ import { noInputSchema } from '@/lib/api/contracts/primitives' import { validationErrorResponse } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { runDetached } from '@/lib/core/utils/background' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { pollInactivityAlerts } from '@/lib/notifications/inactivity-polling' @@ -24,15 +25,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!queryValidation.success) return validationErrorResponse(queryValidation.error) - let lockAcquired = false - try { const authError = verifyCronAuth(request, 'Inactivity alert polling') if (authError) { return authError } - lockAcquired = await acquireLock(LOCK_KEY, requestId, LOCK_TTL_SECONDS) + const lockAcquired = await acquireLock(LOCK_KEY, requestId, LOCK_TTL_SECONDS) if (!lockAcquired) { return NextResponse.json( @@ -46,15 +45,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const results = await pollInactivityAlerts() - - return NextResponse.json({ - success: true, - message: 'Inactivity alert polling completed', - requestId, - status: 'completed', - ...results, + runDetached('inactivity-alert-polling', async () => { + try { + await pollInactivityAlerts() + } finally { + await releaseLock(LOCK_KEY, requestId).catch(() => {}) + } }) + + return NextResponse.json( + { + success: true, + message: 'Inactivity alert polling started', + requestId, + status: 'started', + }, + { status: 202 } + ) } catch (error) { logger.error(`Error during inactivity alert polling (${requestId}):`, error) return NextResponse.json( @@ -66,9 +73,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }, { status: 500 } ) - } finally { - if (lockAcquired) { - await releaseLock(LOCK_KEY, requestId).catch(() => {}) - } } }) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 129ec4c1582..45a92832b95 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -135,7 +135,7 @@ export const PUT = withRouteHandler( await db .update(workflowSchedule) - .set({ status: 'disabled', nextRunAt: null, updatedAt: new Date() }) + .set({ status: 'disabled', nextRunAt: null, lastQueuedAt: null, updatedAt: new Date() }) .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`) @@ -220,7 +220,7 @@ export const PUT = withRouteHandler( await db .update(workflowSchedule) - .set({ status: 'active', failedCount: 0, updatedAt: now, nextRunAt }) + .set({ status: 'active', failedCount: 0, infraRetryCount: 0, updatedAt: now, nextRunAt }) .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 05434276654..2fe434e87b2 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -3,31 +3,30 @@ * * @vitest-environment node */ -import { - dbChainMock, - dbChainMockFns, - requestUtilsMockFns, - resetDbChainMock, - workflowsUtilsMock, - workflowsUtilsMockFns, -} from '@sim/testing' -import type { NextRequest } from 'next/server' +import { dbChainMock, dbChainMockFns, requestUtilsMockFns, resetDbChainMock } from '@sim/testing' +import { type NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +const orderByLimitMock = vi.fn() + const { mockVerifyCronAuth, mockExecuteScheduleJob, mockExecuteJobInline, + mockReleaseScheduleLock, mockFeatureFlags, mockEnqueue, mockGetJob, mockStartJob, mockCompleteJob, mockMarkJobFailed, + mockCancelJob, + mockShouldExecuteInline, } = vi.hoisted(() => ({ mockVerifyCronAuth: vi.fn().mockReturnValue(null), mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined), mockExecuteJobInline: vi.fn().mockResolvedValue(undefined), + mockReleaseScheduleLock: vi.fn().mockResolvedValue(undefined), mockFeatureFlags: { isTriggerDevEnabled: false, isHosted: false, @@ -39,6 +38,8 @@ const { mockStartJob: vi.fn().mockResolvedValue(undefined), mockCompleteJob: vi.fn().mockResolvedValue(undefined), mockMarkJobFailed: vi.fn().mockResolvedValue(undefined), + mockCancelJob: vi.fn().mockResolvedValue(undefined), + mockShouldExecuteInline: vi.fn().mockReturnValue(false), })) vi.mock('@/lib/auth/internal', () => ({ @@ -48,7 +49,7 @@ vi.mock('@/lib/auth/internal', () => ({ vi.mock('@/background/schedule-execution', () => ({ executeScheduleJob: mockExecuteScheduleJob, executeJobInline: mockExecuteJobInline, - releaseScheduleLock: vi.fn().mockResolvedValue(undefined), + releaseScheduleLock: mockReleaseScheduleLock, })) vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags) @@ -60,12 +61,11 @@ vi.mock('@/lib/core/async-jobs', () => ({ startJob: mockStartJob, completeJob: mockCompleteJob, markJobFailed: mockMarkJobFailed, + cancelJob: mockCancelJob, }), - shouldExecuteInline: vi.fn().mockReturnValue(false), + shouldExecuteInline: mockShouldExecuteInline, })) -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) - vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), @@ -76,6 +76,7 @@ vi.mock('drizzle-orm', () => ({ not: vi.fn((condition: unknown) => ({ type: 'not', condition })), isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), + asc: vi.fn((field: unknown) => ({ type: 'asc', field })), sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', strings, values })), })) @@ -88,7 +89,9 @@ vi.mock('@sim/db', () => ({ cronExpression: 'cronExpression', lastRanAt: 'lastRanAt', failedCount: 'failedCount', + infraRetryCount: 'infraRetryCount', status: 'status', + timezone: 'timezone', nextRunAt: 'nextRunAt', lastQueuedAt: 'lastQueuedAt', deploymentVersionId: 'deploymentVersionId', @@ -104,6 +107,20 @@ vi.mock('@sim/db', () => ({ userId: 'userId', workspaceId: 'workspaceId', }, + asyncJobs: { + id: 'id', + type: 'type', + payload: 'payload', + status: 'status', + createdAt: 'createdAt', + runAt: 'runAt', + startedAt: 'startedAt', + completedAt: 'completedAt', + attempts: 'attempts', + maxAttempts: 'maxAttempts', + error: 'error', + updatedAt: 'updatedAt', + }, })) vi.mock('@sim/utils/id', () => ({ @@ -114,7 +131,7 @@ vi.mock('@sim/utils/id', () => ({ ), })) -import { GET } from './route' +import { GET, runScheduleTick } from './route' const SINGLE_SCHEDULE = [ { @@ -124,8 +141,11 @@ const SINGLE_SCHEDULE = [ cronExpression: null, lastRanAt: null, failedCount: 0, + infraRetryCount: 0, + timezone: 'UTC', nextRunAt: new Date('2025-01-01T00:00:00.000Z'), lastQueuedAt: undefined, + workspaceId: 'workspace-1', }, ] @@ -138,21 +158,74 @@ const MULTIPLE_SCHEDULES = [ cronExpression: null, lastRanAt: null, failedCount: 0, + infraRetryCount: 0, + timezone: 'UTC', nextRunAt: new Date('2025-01-01T01:00:00.000Z'), lastQueuedAt: undefined, + workspaceId: 'workspace-2', }, ] +const SINGLE_CLAIMED_SCHEDULE_ROWS = [{ id: 'schedule-1', workspaceId: 'workspace-1' }] + const SINGLE_JOB = [ { id: 'job-1', cronExpression: '0 * * * *', failedCount: 0, + infraRetryCount: 0, + timezone: 'UTC', lastQueuedAt: undefined, sourceType: 'job', }, ] +function conditionContains( + condition: unknown, + predicate: (entry: Record) => boolean +): boolean { + if (!condition || typeof condition !== 'object') return false + if (Array.isArray(condition)) { + return condition.some((item) => conditionContains(item, predicate)) + } + + const entry = condition as Record + if (predicate(entry)) return true + + return Object.values(entry).some((value) => conditionContains(value, predicate)) +} + +function isActiveScheduleExecutionCountCondition(condition: unknown): boolean { + return ( + conditionContains( + condition, + (entry) => + entry.type === 'eq' && entry.field === 'type' && entry.value === 'schedule-execution' + ) && + conditionContains( + condition, + (entry) => entry.type === 'eq' && entry.field === 'status' && entry.value === 'processing' + ) && + !conditionContains(condition, (entry) => entry.type === 'or') + ) +} + +function mockProcessingCounts(...counts: number[]) { + const defaultWhere = dbChainMockFns.where.getMockImplementation() + if (!defaultWhere) throw new Error('Expected default where mock implementation') + let index = 0 + + dbChainMockFns.where.mockImplementation((condition: unknown) => { + if (isActiveScheduleExecutionCountCondition(condition) && index < counts.length) { + const count = counts[index] + index += 1 + return Promise.resolve([{ count }]) as ReturnType + } + + return defaultWhere(condition) + }) +} + function createMockRequest(): NextRequest { const mockHeaders = new Map([ ['authorization', 'Bearer test-cron-secret'], @@ -172,76 +245,89 @@ describe('Scheduled Workflow Execution API Route', () => { vi.clearAllMocks() dbChainMockFns.limit.mockReset() dbChainMockFns.returning.mockReset() + dbChainMockFns.execute.mockReset() + orderByLimitMock.mockReset() + orderByLimitMock.mockResolvedValue([]) resetDbChainMock() + dbChainMockFns.orderBy.mockReturnValue({ limit: orderByLimitMock } as never) + dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id') - workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({ - id: 'workflow-1', - workspaceId: 'workspace-1', - }) mockFeatureFlags.isTriggerDevEnabled = false mockFeatureFlags.isHosted = false mockFeatureFlags.isProd = false mockFeatureFlags.isDev = true + mockShouldExecuteInline.mockReturnValue(false) + mockEnqueue.mockReset() + mockEnqueue.mockResolvedValue('job-id-1') + mockGetJob.mockReset() + mockGetJob.mockResolvedValue(null) + mockStartJob.mockReset() + mockStartJob.mockResolvedValue(undefined) + mockCompleteJob.mockReset() + mockCompleteJob.mockResolvedValue(undefined) + mockMarkJobFailed.mockReset() + mockMarkJobFailed.mockResolvedValue(undefined) + mockCancelJob.mockReset() + mockCancelJob.mockResolvedValue(undefined) + mockExecuteScheduleJob.mockReset() + mockExecuteScheduleJob.mockResolvedValue(undefined) + mockExecuteJobInline.mockReset() + mockExecuteJobInline.mockResolvedValue(undefined) + mockReleaseScheduleLock.mockReset() + mockReleaseScheduleLock.mockResolvedValue(undefined) dbChainMockFns.returning.mockReturnValue([]) }) it('should execute scheduled workflows with Trigger.dev disabled', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'schedule-1' }]).mockResolvedValueOnce([]) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) + const result = await runScheduleTick('test-request-id') - expect(response).toBeDefined() - expect(response.status).toBe(200) - const data = await response.json() - expect(data).toHaveProperty('message') - expect(data).toHaveProperty('executedCount', 1) + expect(result.processedCount).toBe(1) }) it('should queue schedules to Trigger.dev when enabled', async () => { mockFeatureFlags.isTriggerDevEnabled = true - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'schedule-1' }]).mockResolvedValueOnce([]) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) + const result = await runScheduleTick('test-request-id') - expect(response).toBeDefined() - expect(response.status).toBe(200) - const data = await response.json() - expect(data).toHaveProperty('executedCount', 1) + expect(result.processedCount).toBe(1) }) it('should handle case with no due schedules', async () => { dbChainMockFns.returning.mockReturnValueOnce([]).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) + const result = await runScheduleTick('test-request-id') - expect(response.status).toBe(200) - const data = await response.json() - expect(data).toHaveProperty('message') - expect(data).toHaveProperty('executedCount', 0) + expect(result.processedCount).toBe(0) }) it('should execute multiple schedules in parallel', async () => { dbChainMockFns.limit - .mockResolvedValueOnce([{ id: 'schedule-1' }, { id: 'schedule-2' }]) + .mockResolvedValueOnce([ + { id: 'schedule-1', workspaceId: 'workspace-1' }, + { id: 'schedule-2', workspaceId: 'workspace-2' }, + ]) .mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(MULTIPLE_SCHEDULES).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) + const result = await runScheduleTick('test-request-id') - expect(response.status).toBe(200) - const data = await response.json() - expect(data).toHaveProperty('executedCount', 2) + expect(result.processedCount).toBe(2) }) it('should execute mothership jobs inline', async () => { dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'job-1' }]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_JOB) - const response = await GET(createMockRequest()) - - expect(response.status).toBe(200) + await runScheduleTick('test-request-id') expect(mockExecuteJobInline).toHaveBeenCalledWith( expect.objectContaining({ scheduleId: 'job-1', @@ -253,12 +339,12 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should enqueue schedule with correlation metadata via job queue', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'schedule-1' }]).mockResolvedValueOnce([]) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) - const response = await GET(createMockRequest()) - - expect(response.status).toBe(200) + await runScheduleTick('test-request-id') expect(mockEnqueue).toHaveBeenCalledWith( 'schedule-execution', expect.objectContaining({ @@ -269,7 +355,6 @@ describe('Scheduled Workflow Execution API Route', () => { }), expect.objectContaining({ jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/), - concurrencyKey: expect.stringMatching(/^schedule_[0-9a-f]{32}$/), metadata: expect.objectContaining({ workflowId: 'workflow-1', workspaceId: 'workspace-1', @@ -283,5 +368,514 @@ describe('Scheduled Workflow Execution API Route', () => { }), }) ) + expect(mockEnqueue.mock.calls[0][2].concurrencyKey).toBeUndefined() + }) + + it('executes database fallback schedules through durable async job rows', async () => { + mockShouldExecuteInline.mockReturnValue(true) + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning + .mockReturnValueOnce(SINGLE_SCHEDULE) + .mockResolvedValueOnce([{ id: 'job-id-1' }]) + + try { + await runScheduleTick('test-request-id') + expect(mockEnqueue).toHaveBeenCalledWith( + 'schedule-execution', + expect.objectContaining({ scheduleId: 'schedule-1' }), + expect.objectContaining({ + jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/), + metadata: expect.objectContaining({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }), + }) + ) + expect(mockStartJob).not.toHaveBeenCalled() + expect(mockExecuteScheduleJob).toHaveBeenCalledWith( + expect.objectContaining({ scheduleId: 'schedule-1' }) + ) + expect(mockCompleteJob).toHaveBeenCalledWith('job-id-1', null) + } finally { + randomSpy.mockRestore() + } + }) + + it('releases database fallback claims when the global concurrency cap is full', async () => { + mockShouldExecuteInline.mockReturnValue(true) + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + mockProcessingCounts(0, 0, 50) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning + .mockReturnValueOnce([{ ...SINGLE_SCHEDULE[0], lastQueuedAt: claimedAt }]) + .mockResolvedValueOnce([]) + + try { + await runScheduleTick('test-request-id') + expect(mockEnqueue).toHaveBeenCalled() + expect(mockExecuteScheduleJob).not.toHaveBeenCalled() + expect(mockCompleteJob).not.toHaveBeenCalled() + expect(mockReleaseScheduleLock).not.toHaveBeenCalled() + } finally { + randomSpy.mockRestore() + } + }) + + it('recovers stale database fallback processing jobs before resuming them', async () => { + mockShouldExecuteInline.mockReturnValue(true) + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const staleStartedAt = new Date('2024-12-31T00:00:00.000Z') + mockProcessingCounts(0, 0) + mockGetJob + .mockResolvedValueOnce({ + id: 'job-id-1', + status: 'processing', + startedAt: staleStartedAt, + }) + .mockResolvedValueOnce({ + id: 'job-id-1', + status: 'pending', + }) + orderByLimitMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'job-id-1', + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: '2025-01-01T00:00:00.000Z', + }, + attempts: 0, + maxAttempts: 3, + }, + ]) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning + .mockReturnValueOnce([{ ...SINGLE_SCHEDULE[0], lastQueuedAt: new Date('2025-01-01') }]) + .mockResolvedValueOnce([{ id: 'job-id-1' }]) + + try { + await runScheduleTick('test-request-id') + expect(mockExecuteScheduleJob).toHaveBeenCalledWith( + expect.objectContaining({ scheduleId: 'schedule-1' }) + ) + expect(mockCompleteJob).toHaveBeenCalledWith( + expect.stringMatching(/^schedule_[0-9a-f]{32}$/), + null + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'pending', + startedAt: null, + error: expect.stringContaining('stale schedule execution processing lease'), + }) + ) + } finally { + randomSpy.mockRestore() + } + }) + + it('resumes pending database fallback jobs without waiting for a stale schedule claim', async () => { + mockShouldExecuteInline.mockReturnValue(true) + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + mockProcessingCounts(0, 0, 0) + orderByLimitMock.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + id: 'pending-job-id', + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: claimedAt.toISOString(), + }, + }, + ]) + dbChainMockFns.limit + .mockResolvedValueOnce([{ lastQueuedAt: claimedAt }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'pending-job-id' }]) + + const result = await runScheduleTick('test-request-id') + + expect(result.processedCount).toBe(1) + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockExecuteScheduleJob).toHaveBeenCalledWith( + expect.objectContaining({ + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: claimedAt.toISOString(), + }) + ) + expect(mockCompleteJob).toHaveBeenCalledWith('pending-job-id', null) + }) + + it('completes stale pending database fallback jobs whose schedule claim was already released', async () => { + mockShouldExecuteInline.mockReturnValue(true) + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + mockProcessingCounts(0, 0) + orderByLimitMock.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + id: 'stale-pending-job-id', + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: claimedAt.toISOString(), + }, + }, + ]) + dbChainMockFns.limit + .mockResolvedValueOnce([{ lastQueuedAt: null }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + expect(mockExecuteScheduleJob).not.toHaveBeenCalled() + expect(mockCompleteJob).toHaveBeenCalledWith( + 'stale-pending-job-id', + expect.objectContaining({ + skipped: true, + }) + ) + }) + + it('fails exhausted stale database fallback jobs instead of retrying forever', async () => { + mockShouldExecuteInline.mockReturnValue(true) + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + mockProcessingCounts(0, 0) + orderByLimitMock.mockResolvedValueOnce([ + { + id: 'exhausted-job-id', + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: claimedAt.toISOString(), + }, + attempts: 3, + maxAttempts: 3, + }, + ]) + dbChainMockFns.limit + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + + await runScheduleTick('test-request-id') + expect(mockExecuteScheduleJob).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + error: expect.stringContaining('exhausted retry attempts'), + }) + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastQueuedAt: null, + lastFailedAt: expect.any(Date), + nextRunAt: expect.any(Date), + }) + ) + }) + + it('defers schedule claims when retryable lookup infrastructure fails before enqueue', async () => { + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: claimedAt, + } + mockGetJob.mockRejectedValueOnce( + Object.assign(new Error('queue lookup failed'), { code: 'ECONNRESET' }) + ) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockReleaseScheduleLock).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastQueuedAt: null, + nextRunAt: expect.any(Date), + infraRetryCount: 1, + }) + ) + }) + + it('marks schedules failed when non-retryable setup errors happen before enqueue', async () => { + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: claimedAt, + } + mockGetJob.mockRejectedValueOnce(new Error('bad setup invariant')) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + expect(mockEnqueue).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastQueuedAt: null, + lastFailedAt: expect.any(Date), + nextRunAt: expect.any(Date), + infraRetryCount: 0, + }) + ) + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ + infraRetryCount: 1, + }) + ) + }) + + it('uses one backend mode decision for slot accounting and schedule processing', async () => { + mockShouldExecuteInline.mockReturnValue(true) + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning + .mockReturnValueOnce(SINGLE_SCHEDULE) + .mockResolvedValueOnce([{ id: 'job-id-1' }]) + + try { + await runScheduleTick('test-request-id') + expect(mockShouldExecuteInline).toHaveBeenCalledTimes(1) + expect(mockExecuteScheduleJob).toHaveBeenCalledWith( + expect.objectContaining({ scheduleId: 'schedule-1' }) + ) + } finally { + randomSpy.mockRestore() + } + }) + + it('restores the original claim token when an active durable job owns the occurrence', async () => { + const originalClaim = new Date() + const staleReclaim = new Date(originalClaim.getTime() + 60_000) + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: staleReclaim, + } + mockGetJob.mockResolvedValueOnce({ + id: 'job-id-1', + status: 'processing', + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: originalClaim.toISOString(), + }, + }) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockReleaseScheduleLock).not.toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastQueuedAt: originalClaim, + }) + ) + }) + + it('does not restore stale database fallback claims for fresh processing jobs', async () => { + mockShouldExecuteInline.mockReturnValue(true) + const originalClaim = new Date('2024-01-01T00:00:00.000Z') + const staleReclaim = new Date() + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: staleReclaim, + } + mockGetJob + .mockResolvedValueOnce({ + id: 'job-id-1', + status: 'processing', + startedAt: new Date(), + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: originalClaim.toISOString(), + }, + }) + .mockResolvedValueOnce({ + id: 'job-id-1', + status: 'processing', + startedAt: new Date(), + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: originalClaim.toISOString(), + }, + }) + dbChainMockFns.limit + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([schedule]) + .mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + expect(mockEnqueue).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( + expect.objectContaining({ + lastQueuedAt: originalClaim, + }) + ) + }) + + it('restores the original claim token when Trigger.dev returns an idempotent existing run', async () => { + const originalClaim = new Date() + const staleReclaim = new Date(originalClaim.getTime() + 60_000) + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: staleReclaim, + } + mockEnqueue.mockResolvedValueOnce('trigger-run-id') + mockGetJob.mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: 'trigger-run-id', + status: 'processing', + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: originalClaim.toISOString(), + }, + }) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + expect(mockEnqueue).toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastQueuedAt: originalClaim, + }) + ) + }) + + it('cancels stale Trigger.dev runs instead of restoring an expired claim forever', async () => { + const originalClaim = new Date('2024-01-01T00:00:00.000Z') + const staleReclaim = new Date() + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: staleReclaim, + } + mockEnqueue.mockResolvedValueOnce('trigger-run-id') + mockGetJob.mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: 'trigger-run-id', + status: 'processing', + payload: { + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + now: originalClaim.toISOString(), + }, + }) + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + + await runScheduleTick('test-request-id') + expect(mockCancelJob).toHaveBeenCalledWith('trigger-run-id') + expect(mockReleaseScheduleLock).toHaveBeenCalledWith( + 'schedule-1', + 'test-request-id', + expect.any(Date), + expect.stringContaining('cancelling stale queued schedule execution job'), + undefined, + { expectedLastQueuedAt: staleReclaim } + ) + }) + + it('bounds workflow schedule claims to the configured enqueue budget', async () => { + const claimedIds = Array.from({ length: 100 }, (_, index) => ({ + id: `schedule-${index}`, + workspaceId: `workspace-${index}`, + })) + const claimedSchedules = claimedIds.map((row, index) => ({ + id: row.id, + workflowId: `workflow-${index}`, + blockId: null, + cronExpression: null, + lastRanAt: null, + failedCount: 0, + infraRetryCount: 0, + timezone: 'UTC', + nextRunAt: new Date('2025-01-01T00:00:00.000Z'), + lastQueuedAt: undefined, + workspaceId: row.workspaceId, + })) + + dbChainMockFns.limit.mockResolvedValueOnce(claimedIds).mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce(claimedSchedules).mockReturnValueOnce([]) + + const result = await runScheduleTick('test-request-id') + + expect(result.processedCount).toBe(100) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(100) + expect(mockEnqueue).toHaveBeenCalledTimes(100) + }) + + it('guards route-side stale release updates with the claimed occurrence', async () => { + const claimedAt = new Date('2025-01-01T00:00:00.000Z') + const schedule = { + ...SINGLE_SCHEDULE[0], + lastQueuedAt: claimedAt, + } + dbChainMockFns.limit + .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) + .mockResolvedValueOnce([]) + dbChainMockFns.returning.mockReturnValueOnce([schedule]).mockReturnValueOnce([]) + mockGetJob.mockResolvedValueOnce({ id: 'job-id-1', status: 'completed' }) + + await runScheduleTick('test-request-id') + expect(mockReleaseScheduleLock).toHaveBeenCalledWith( + 'schedule-1', + 'test-request-id', + expect.any(Date), + expect.stringContaining('finished job'), + null, + { expectedLastQueuedAt: claimedAt } + ) + }) + + describe('GET handler (fire-and-forget)', () => { + it('returns the auth error when cron auth fails', async () => { + mockVerifyCronAuth.mockReturnValueOnce( + NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + ) + + const response = await GET(createMockRequest()) + + expect(response.status).toBe(401) + }) + + it('acknowledges immediately with 202 and starts the tick in the background', async () => { + const response = await GET(createMockRequest()) + + expect(response.status).toBe(202) + const data = await response.json() + expect(data).toMatchObject({ status: 'started' }) + }) }) }) diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 33fb374bbd5..6dedd8d7494 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -1,21 +1,38 @@ -import { db, workflowDeploymentVersion, workflowSchedule } from '@sim/db' +import { asyncJobs, db, workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' +import { backoffWithJitter } from '@sim/utils/retry' import { Cron } from 'croner' -import { and, eq, inArray, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import type { ExecuteSchedulesResponse } from '@/lib/api/contracts/schedules' import { verifyCronAuth } from '@/lib/auth/internal' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' +import { JOB_STATUS, type Job } from '@/lib/core/async-jobs/types' +import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + SCHEDULE_EXECUTION_QUEUE_NAME, + SCHEDULE_INFRA_RETRY_BASE_MS, + SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS, + SCHEDULE_INFRA_RETRY_MAX_MS, + SCHEDULE_JITTER_MAX_MS, + SCHEDULE_WORKFLOW_ENQUEUE_LIMIT, +} from '@/lib/workflows/schedules/execution-limits' import { executeJobInline, executeScheduleJob, releaseScheduleLock, + type ScheduleExecutionPayload, } from '@/background/schedule-execution' +import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' export const dynamic = 'force-dynamic' export const maxDuration = 3600 @@ -25,21 +42,16 @@ const WORKFLOW_CHUNK_SIZE = 100 const JOB_CHUNK_SIZE = 100 const MAX_TICK_DURATION_MS = 3 * 60 * 1000 const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout() - -/** - * Upper bound (ms) for the random start delay applied to each scheduled - * execution. Cron schedules all fire on the same boundary (e.g. every `:00`), - * which stampedes the database connection pool at the top of each minute/hour. - * Spreading starts across a [0, 30s) window smooths that burst. - */ -const SCHEDULE_JITTER_MAX_MS = 30_000 +const STALE_SCHEDULE_RECOVERY_BATCH_SIZE = 100 +const DATABASE_SCHEDULE_START_TURN_WAIT_MS = 1_000 +type DatabaseScheduleStartResult = 'started' | 'capacity_full' | 'not_pending' +let databaseScheduleStartTurn: Promise | null = null const dueFilter = (queuedAt: Date) => and( isNull(workflowSchedule.archivedAt), lte(workflowSchedule.nextRunAt, queuedAt), - not(eq(workflowSchedule.status, 'disabled')), - ne(workflowSchedule.status, 'completed'), + sql`${workflowSchedule.status} NOT IN ('disabled', 'completed')`, or( isNull(workflowSchedule.lastQueuedAt), lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt), @@ -53,12 +65,40 @@ const activeWorkflowDeploymentFilter = () => const workflowScheduleFilter = (queuedAt: Date) => and( dueFilter(queuedAt), - or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)), + sql`(${workflowSchedule.sourceType} = 'workflow' OR ${workflowSchedule.sourceType} IS NULL)`, activeWorkflowDeploymentFilter() ) const jobScheduleFilter = (queuedAt: Date) => - and(dueFilter(queuedAt), eq(workflowSchedule.sourceType, 'job')) + and(dueFilter(queuedAt), sql`${workflowSchedule.sourceType} = 'job'`) + +async function runWithDatabaseScheduleStartTurn( + operation: () => Promise +): Promise { + const activeTurn = databaseScheduleStartTurn + if (activeTurn) { + const turnOpened = await Promise.race([ + activeTurn.then(() => true), + sleep(DATABASE_SCHEDULE_START_TURN_WAIT_MS).then(() => false), + ]) + if (!turnOpened || databaseScheduleStartTurn) return 'capacity_full' + } + + let releaseTurn = () => {} + const currentTurn = new Promise((resolve) => { + releaseTurn = resolve + }) + databaseScheduleStartTurn = currentTurn + + try { + return await operation() + } finally { + if (databaseScheduleStartTurn === currentTurn) { + databaseScheduleStartTurn = null + } + releaseTurn() + } +} function buildScheduleExecutionJobId(schedule: { id: string @@ -70,9 +110,12 @@ function buildScheduleExecutionJobId(schedule: { return `schedule_${sha256Hex(`${schedule.id}:${occurrence}`).slice(0, 32)}` } -function getNextRunFromCronExpression(cronExpression?: string | null): Date | null { +function getNextRunFromCronExpression( + cronExpression?: string | null, + timezone = 'UTC' +): Date | null { if (!cronExpression) return null - const cron = new Cron(cronExpression) + const cron = new Cron(cronExpression, { timezone }) return cron.nextRun() } @@ -81,15 +124,20 @@ async function claimWorkflowSchedules(queuedAt: Date, limit: number) { return db.transaction(async (tx) => { const rows = await tx - .select({ id: workflowSchedule.id }) + .select({ + id: workflowSchedule.id, + workspaceId: workflow.workspaceId, + }) .from(workflowSchedule) + .innerJoin(workflow, eq(workflowSchedule.workflowId, workflow.id)) .where(workflowScheduleFilter(queuedAt)) .for('update', { skipLocked: true }) .limit(limit) if (rows.length === 0) return [] + const workspaceIdsByScheduleId = new Map(rows.map((row) => [row.id, row.workspaceId])) - return tx + const claimedRows = await tx .update(workflowSchedule) .set({ lastQueuedAt: queuedAt, updatedAt: queuedAt }) .where( @@ -108,11 +156,18 @@ async function claimWorkflowSchedules(queuedAt: Date, limit: number) { cronExpression: workflowSchedule.cronExpression, lastRanAt: workflowSchedule.lastRanAt, failedCount: workflowSchedule.failedCount, + infraRetryCount: workflowSchedule.infraRetryCount, nextRunAt: workflowSchedule.nextRunAt, lastQueuedAt: workflowSchedule.lastQueuedAt, + timezone: workflowSchedule.timezone, deploymentVersionId: workflowSchedule.deploymentVersionId, sourceType: workflowSchedule.sourceType, }) + + return claimedRows.map((row) => ({ + ...row, + workspaceId: workspaceIdsByScheduleId.get(row.id) ?? null, + })) }) } @@ -144,6 +199,7 @@ async function claimJobSchedules(queuedAt: Date, limit: number) { .returning({ id: workflowSchedule.id, cronExpression: workflowSchedule.cronExpression, + timezone: workflowSchedule.timezone, failedCount: workflowSchedule.failedCount, lastQueuedAt: workflowSchedule.lastQueuedAt, sourceType: workflowSchedule.sourceType, @@ -153,15 +209,549 @@ async function claimJobSchedules(queuedAt: Date, limit: number) { type ClaimedSchedule = Awaited>[number] type ClaimedJob = Awaited>[number] -type WorkflowUtils = typeof import('@/lib/workflows/utils') type JobQueue = Awaited> +type DatabaseScheduleExecutionTarget = Pick< + ClaimedSchedule, + 'id' | 'workflowId' | 'cronExpression' | 'timezone' +> + +function getSchedulePayloadFromValue(payload: unknown): ScheduleExecutionPayload | null { + if (!payload || typeof payload !== 'object') return null + const candidate = payload as Partial + if ( + typeof candidate.scheduleId !== 'string' || + typeof candidate.workflowId !== 'string' || + typeof candidate.now !== 'string' + ) { + return null + } + + return candidate as ScheduleExecutionPayload +} + +function getSchedulePayloadFromJob(job: Job): ScheduleExecutionPayload | null { + return getSchedulePayloadFromValue(job.payload) +} + +function getSchedulePayloadClaimedAt(payload: ScheduleExecutionPayload | null): Date | null { + if (!payload) return null + const claimedAt = new Date(payload.now) + return Number.isNaN(claimedAt.getTime()) ? null : claimedAt +} + +async function restoreScheduleClaim( + scheduleId: string, + requestId: string, + currentClaim: Date, + activeClaim: Date, + context: string +): Promise { + if (currentClaim.getTime() === activeClaim.getTime()) return + + const [restored] = await db + .update(workflowSchedule) + .set({ lastQueuedAt: activeClaim, updatedAt: new Date() }) + .where( + and( + eq(workflowSchedule.id, scheduleId), + isNull(workflowSchedule.archivedAt), + eq(workflowSchedule.lastQueuedAt, currentClaim) + ) + ) + .returning({ id: workflowSchedule.id }) + .catch((error) => { + logger.error(`[${requestId}] ${context}`, error) + throw error + }) + + if (!restored) { + const error = new Error(`Schedule claim restore did not update schedule ${scheduleId}`) + logger.warn(`[${requestId}] ${context}`, { + scheduleId, + currentClaim: currentClaim.toISOString(), + activeClaim: activeClaim.toISOString(), + }) + throw error + } +} + +function getStaleScheduleExecutionCutoff(now: Date): Date { + return new Date(now.getTime() - STALE_SCHEDULE_CLAIM_MS) +} + +function isStaleScheduleClaim(claimedAt: Date): boolean { + return claimedAt < getStaleScheduleExecutionCutoff(new Date()) +} + +function activeScheduleExecutionJobsFilter() { + return sql`${asyncJobs.type} = 'schedule-execution' AND ${asyncJobs.status} = 'processing'` +} + +function pendingScheduleExecutionJobsFilter(now: Date) { + return and( + sql`${asyncJobs.type} = 'schedule-execution' AND ${asyncJobs.status} = 'pending'`, + sql`${asyncJobs.attempts} < ${asyncJobs.maxAttempts}`, + or(isNull(asyncJobs.runAt), lte(asyncJobs.runAt, now)) + ) +} + +function staleScheduleExecutionJobsFilter(staleStartedBefore: Date) { + return and( + activeScheduleExecutionJobsFilter(), + or(isNull(asyncJobs.startedAt), lt(asyncJobs.startedAt, staleStartedBefore)) + ) +} + +function getScheduleNextRunAt( + schedule: { cronExpression?: string | null; timezone?: string }, + now: Date +): Date { + return ( + getNextRunFromCronExpression(schedule.cronExpression, schedule.timezone) ?? + new Date(now.getTime() + 24 * 60 * 60 * 1000) + ) +} + +async function markClaimedScheduleFailed( + schedule: DatabaseScheduleExecutionTarget, + requestId: string, + expectedLastQueuedAt: Date, + context: string +): Promise { + const now = new Date() + await db + .update(workflowSchedule) + .set({ + updatedAt: now, + lastQueuedAt: null, + lastFailedAt: now, + nextRunAt: getScheduleNextRunAt(schedule, now), + failedCount: sql`COALESCE(${workflowSchedule.failedCount}, 0) + 1`, + status: sql`CASE WHEN COALESCE(${workflowSchedule.failedCount}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN 'disabled' ELSE 'active' END`, + infraRetryCount: 0, + }) + .where( + and( + eq(workflowSchedule.id, schedule.id), + isNull(workflowSchedule.archivedAt), + eq(workflowSchedule.lastQueuedAt, expectedLastQueuedAt) + ) + ) + .catch((error) => { + logger.error(`[${requestId}] ${context}`, error) + throw error + }) +} + +async function deferClaimedScheduleAfterQueueFailure( + schedule: ClaimedSchedule, + requestId: string, + expectedLastQueuedAt: Date, + error: unknown, + context: string +): Promise { + const now = new Date() + const retryAttempt = (schedule.infraRetryCount || 0) + 1 + if (retryAttempt > SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS) { + await markClaimedScheduleFailed( + schedule, + requestId, + expectedLastQueuedAt, + `Failed to mark schedule ${schedule.id} failed after queue retry exhaustion` + ) + return + } + + const retryDelayMs = Math.min( + SCHEDULE_INFRA_RETRY_MAX_MS, + Math.round( + backoffWithJitter(retryAttempt, null, { + baseMs: SCHEDULE_INFRA_RETRY_BASE_MS, + maxMs: SCHEDULE_INFRA_RETRY_MAX_MS, + }) + ) + ) + const nextRetryAt = new Date(now.getTime() + retryDelayMs) + + logger.warn(`[${requestId}] Deferring schedule after queue infrastructure failure`, { + scheduleId: schedule.id, + workflowId: schedule.workflowId, + retryAttempt, + retryDelayMs, + error: toError(error).message, + }) + + await db + .update(workflowSchedule) + .set({ + updatedAt: now, + nextRunAt: nextRetryAt, + lastQueuedAt: null, + infraRetryCount: retryAttempt, + }) + .where( + and( + eq(workflowSchedule.id, schedule.id), + isNull(workflowSchedule.archivedAt), + eq(workflowSchedule.lastQueuedAt, expectedLastQueuedAt) + ) + ) + .catch((updateError) => { + logger.error(`[${requestId}] ${context}`, updateError) + throw updateError + }) +} + +async function handleClaimedScheduleSetupFailure( + schedule: ClaimedSchedule, + requestId: string, + expectedLastQueuedAt: Date, + error: unknown, + retryContext: string, + failureContext: string +): Promise { + if (isRetryableInfrastructureError(error)) { + await deferClaimedScheduleAfterQueueFailure( + schedule, + requestId, + expectedLastQueuedAt, + error, + retryContext + ) + return + } + + logger.error(`[${requestId}] Non-retryable schedule setup failure`, { + scheduleId: schedule.id, + workflowId: schedule.workflowId, + error: toError(error).message, + }) + await markClaimedScheduleFailed(schedule, requestId, expectedLastQueuedAt, failureContext) +} + +async function recoverStaleDatabaseScheduleJobs(now: Date): Promise { + const staleStartedBefore = getStaleScheduleExecutionCutoff(now) + + await db.transaction(async (tx) => { + const [lock] = await tx.execute<{ acquired: boolean }>( + sql`SELECT pg_try_advisory_xact_lock(hashtext(${SCHEDULE_EXECUTION_QUEUE_NAME})) AS acquired` + ) + if (!lock?.acquired) { + logger.info( + 'Skipped stale database schedule job recovery because another worker holds the lock' + ) + return + } + + const staleRows = await tx + .select({ + id: asyncJobs.id, + payload: asyncJobs.payload, + attempts: asyncJobs.attempts, + maxAttempts: asyncJobs.maxAttempts, + }) + .from(asyncJobs) + .where(staleScheduleExecutionJobsFilter(staleStartedBefore)) + .orderBy(asc(asyncJobs.startedAt), asc(asyncJobs.id)) + .limit(STALE_SCHEDULE_RECOVERY_BATCH_SIZE) + + const exhaustedRows = staleRows.filter((row) => row.attempts >= row.maxAttempts) + const retryableRows = staleRows.filter((row) => row.attempts < row.maxAttempts) + + if (exhaustedRows.length > 0) { + await tx + .update(asyncJobs) + .set({ + status: JOB_STATUS.FAILED, + completedAt: now, + error: 'Stale schedule execution processing lease exhausted retry attempts', + updatedAt: now, + }) + .where( + inArray( + asyncJobs.id, + exhaustedRows.map((row) => row.id) + ) + ) + } + + for (const row of exhaustedRows) { + const payload = getSchedulePayloadFromValue(row.payload) + const claimedAt = getSchedulePayloadClaimedAt(payload) + if (!payload || !claimedAt) continue + + await tx + .update(workflowSchedule) + .set({ + updatedAt: now, + lastQueuedAt: null, + lastFailedAt: now, + nextRunAt: getScheduleNextRunAt(payload, now), + failedCount: sql`COALESCE(${workflowSchedule.failedCount}, 0) + 1`, + status: sql`CASE WHEN COALESCE(${workflowSchedule.failedCount}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN 'disabled' ELSE 'active' END`, + infraRetryCount: 0, + }) + .where( + and( + eq(workflowSchedule.id, payload.scheduleId), + isNull(workflowSchedule.archivedAt), + eq(workflowSchedule.lastQueuedAt, claimedAt) + ) + ) + } + + if (retryableRows.length > 0) { + await tx + .update(asyncJobs) + .set({ + status: JOB_STATUS.PENDING, + startedAt: null, + error: 'Recovered after stale schedule execution processing lease', + updatedAt: now, + }) + .where( + inArray( + asyncJobs.id, + retryableRows.map((row) => row.id) + ) + ) + } + }) +} + +function isStaleDatabaseScheduleJob(job: { status: string; startedAt?: Date }): boolean { + return ( + job.status === JOB_STATUS.PROCESSING && + (!job.startedAt || job.startedAt < getStaleScheduleExecutionCutoff(new Date())) + ) +} + +async function getDatabaseScheduleExecutionSlots(): Promise { + const [row] = await db + .select({ + count: sql`count(*)`, + }) + .from(asyncJobs) + .where(activeScheduleExecutionJobsFilter()) + + const processingCount = Number(row?.count ?? 0) + return Math.max(0, SCHEDULE_EXECUTION_CONCURRENCY_LIMIT - processingCount) +} + +async function tryStartDatabaseScheduleJob(jobId: string): Promise { + const now = new Date() + + return db.transaction(async (tx) => { + const [lock] = await tx.execute<{ acquired: boolean }>( + sql`SELECT pg_try_advisory_xact_lock(hashtext(${SCHEDULE_EXECUTION_QUEUE_NAME})) AS acquired` + ) + if (!lock?.acquired) return 'capacity_full' + + const [row] = await tx + .select({ + count: sql`count(*)`, + }) + .from(asyncJobs) + .where(activeScheduleExecutionJobsFilter()) + + if (Number(row?.count ?? 0) >= SCHEDULE_EXECUTION_CONCURRENCY_LIMIT) { + return 'capacity_full' + } + + const [startedJob] = await tx + .update(asyncJobs) + .set({ + status: JOB_STATUS.PROCESSING, + startedAt: now, + attempts: sql`${asyncJobs.attempts} + 1`, + updatedAt: now, + }) + .where(and(eq(asyncJobs.id, jobId), eq(asyncJobs.status, JOB_STATUS.PENDING))) + .returning({ id: asyncJobs.id }) + + return startedJob ? 'started' : 'not_pending' + }) +} + +async function executeDatabaseScheduleJob( + jobQueue: JobQueue, + jobId: string, + payload: ScheduleExecutionPayload, + schedule: DatabaseScheduleExecutionTarget, + queuedAt: Date, + requestId: string, + delayMs: number +): Promise { + if (delayMs > 0) await sleep(delayMs) + + const startResult = await runWithDatabaseScheduleStartTurn(() => + tryStartDatabaseScheduleJob(jobId) + ) + if (startResult === 'not_pending') { + logger.info(`[${requestId}] Database schedule execution job is no longer pending`, { + scheduleId: schedule.id, + workflowId: schedule.workflowId, + jobId, + }) + return + } + + if (startResult === 'capacity_full') { + logger.info(`[${requestId}] Deferred database schedule execution because capacity is full`, { + scheduleId: schedule.id, + workflowId: schedule.workflowId, + jobId, + concurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + }) + return + } + + try { + const output = await executeScheduleJob(payload) + await jobQueue.completeJob(jobId, output ?? null) + } catch (error) { + const errorMessage = toError(error).message + logger.error(`[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, { + scheduleId: schedule.id, + jobId, + error: errorMessage, + }) + await jobQueue.markJobFailed(jobId, errorMessage) + await releaseScheduleLock( + schedule.id, + requestId, + new Date(), + `Failed to release lock for schedule ${schedule.id} after inline execution failure`, + undefined, + { expectedLastQueuedAt: queuedAt } + ) + } +} + +async function getPendingDatabaseScheduleJobs(limit: number) { + if (limit <= 0) return [] + const now = new Date() + + return db + .select({ + id: asyncJobs.id, + payload: asyncJobs.payload, + }) + .from(asyncJobs) + .where(pendingScheduleExecutionJobsFilter(now)) + .orderBy(asc(asyncJobs.runAt), asc(asyncJobs.createdAt), asc(asyncJobs.id)) + .limit(limit) +} + +function getScheduleTargetFromPayload( + payload: ScheduleExecutionPayload +): DatabaseScheduleExecutionTarget { + return { + id: payload.scheduleId, + workflowId: payload.workflowId, + cronExpression: payload.cronExpression ?? null, + timezone: payload.timezone ?? 'UTC', + } +} + +async function getScheduleClaimState( + payload: ScheduleExecutionPayload, + claimedAt: Date +): Promise<'matches' | 'released' | 'claimed_by_other'> { + const [schedule] = await db + .select({ + lastQueuedAt: workflowSchedule.lastQueuedAt, + }) + .from(workflowSchedule) + .where(and(eq(workflowSchedule.id, payload.scheduleId), isNull(workflowSchedule.archivedAt))) + .limit(1) + + if (!schedule?.lastQueuedAt) return 'released' + return schedule.lastQueuedAt.getTime() === claimedAt.getTime() ? 'matches' : 'claimed_by_other' +} + +async function resumePendingDatabaseScheduleJobs( + jobQueue: JobQueue, + requestId: string, + slots: number +): Promise { + const pendingJobs = await getPendingDatabaseScheduleJobs(slots) + if (pendingJobs.length === 0) return 0 + + const results = await Promise.allSettled( + pendingJobs.map(async (job) => { + const payload = getSchedulePayloadFromValue(job.payload) + const claimedAt = getSchedulePayloadClaimedAt(payload) + if (!payload || !claimedAt) { + await jobQueue.markJobFailed(job.id, 'Invalid pending schedule execution payload') + return true + } + + const claimState = await getScheduleClaimState(payload, claimedAt) + if (claimState === 'released') { + logger.info(`[${requestId}] Completing stale pending schedule execution job`, { + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + jobId: job.id, + }) + await jobQueue.completeJob(job.id, { + skipped: true, + reason: 'schedule claim no longer matches pending job occurrence', + }) + return true + } + if (claimState === 'claimed_by_other') { + logger.info(`[${requestId}] Leaving pending schedule execution job for active claimant`, { + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + jobId: job.id, + }) + return false + } + + logger.info(`[${requestId}] Resuming pending database schedule execution job`, { + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + jobId: job.id, + }) + + await executeDatabaseScheduleJob( + jobQueue, + job.id, + payload, + getScheduleTargetFromPayload(payload), + claimedAt, + requestId, + 0 + ) + return true + }) + ) + + let processedCount = 0 + results.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value) { + processedCount += 1 + return + } + + if (result.status === 'rejected') { + logger.error(`[${requestId}] Failed to resume pending database schedule execution job`, { + jobId: pendingJobs[index]?.id, + error: toError(result.reason).message, + }) + } + }) + + return processedCount +} async function processScheduleItem( schedule: ClaimedSchedule, queuedAt: Date, requestId: string, jobQueue: JobQueue, - workflowUtils: WorkflowUtils + useDatabaseFallback: boolean ) { const queueTime = schedule.lastQueuedAt ?? queuedAt const executionId = generateId() @@ -182,23 +772,135 @@ async function processScheduleItem( requestId, correlation, blockId: schedule.blockId || undefined, + workspaceId: schedule.workspaceId || undefined, deploymentVersionId: schedule.deploymentVersionId || undefined, cronExpression: schedule.cronExpression || undefined, + timezone: schedule.timezone || undefined, lastRanAt: schedule.lastRanAt?.toISOString(), failedCount: schedule.failedCount || 0, + infraRetryCount: schedule.infraRetryCount || 0, now: queueTime.toISOString(), scheduledFor: schedule.nextRunAt?.toISOString(), } + let enqueuedJobId: string | null = null + try { + const delayMs = Math.floor(Math.random() * SCHEDULE_JITTER_MAX_MS) + const scheduleJobId = buildScheduleExecutionJobId(schedule) const existingJob = await jobQueue.getJob(scheduleJobId) if (existingJob && ['pending', 'processing'].includes(existingJob.status)) { + const activeJobPayload = getSchedulePayloadFromJob(existingJob) + const activeJobClaim = getSchedulePayloadClaimedAt(activeJobPayload) + + if (useDatabaseFallback && isStaleDatabaseScheduleJob(existingJob)) { + await recoverStaleDatabaseScheduleJobs(new Date()) + logger.info(`[${requestId}] Recovered stale database schedule execution jobs`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + }) + } + + const databaseJob = useDatabaseFallback ? await jobQueue.getJob(scheduleJobId) : existingJob + const databaseJobPayload = databaseJob ? getSchedulePayloadFromJob(databaseJob) : null + const databaseJobClaim = getSchedulePayloadClaimedAt(databaseJobPayload) ?? activeJobClaim + if (!useDatabaseFallback && activeJobClaim && isStaleScheduleClaim(activeJobClaim)) { + logger.warn(`[${requestId}] Cancelling stale schedule execution job`, { + scheduleId: schedule.id, + jobId: existingJob.id, + claimedAt: activeJobClaim.toISOString(), + }) + await jobQueue.cancelJob(existingJob.id) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Released stale schedule ${schedule.id} after cancelling stale schedule execution job`, + undefined, + { expectedLastQueuedAt: queueTime } + ) + return + } + + if (useDatabaseFallback && databaseJob?.status === JOB_STATUS.PENDING) { + logger.info(`[${requestId}] Resuming pending database schedule execution job`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + }) + if (databaseJobClaim) { + await restoreScheduleClaim( + schedule.id, + requestId, + queueTime, + databaseJobClaim, + `Failed to restore schedule ${schedule.id} claim for pending database fallback job` + ) + } + enqueuedJobId = scheduleJobId + await executeDatabaseScheduleJob( + jobQueue, + scheduleJobId, + databaseJobPayload ?? payload, + schedule, + databaseJobClaim ?? queueTime, + requestId, + delayMs + ) + return + } + if ( + useDatabaseFallback && + databaseJob && + databaseJob.status !== JOB_STATUS.PENDING && + databaseJob.status !== JOB_STATUS.PROCESSING + ) { + logger.info(`[${requestId}] Database schedule execution job reached terminal state`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + status: databaseJob.status, + }) + if (databaseJob.status === JOB_STATUS.FAILED) { + await markClaimedScheduleFailed( + schedule, + requestId, + queueTime, + `Failed to mark schedule ${schedule.id} failed after terminal database fallback job` + ) + return + } + + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Released stale schedule ${schedule.id} for terminal database fallback job ${scheduleJobId}`, + getNextRunFromCronExpression(schedule.cronExpression, schedule.timezone), + { expectedLastQueuedAt: queueTime } + ) + return + } + logger.info(`[${requestId}] Schedule execution job already exists`, { scheduleId: schedule.id, jobId: scheduleJobId, - status: existingJob.status, + status: databaseJob?.status ?? existingJob.status, }) + const shouldRestoreActiveClaim = + activeJobClaim && + (!useDatabaseFallback || + databaseJob?.status !== JOB_STATUS.PROCESSING || + !isStaleScheduleClaim(activeJobClaim)) + + if (shouldRestoreActiveClaim) { + await restoreScheduleClaim( + schedule.id, + requestId, + queueTime, + activeJobClaim, + `Failed to restore schedule ${schedule.id} claim for active schedule execution job` + ) + } return } if (existingJob) { @@ -212,30 +914,64 @@ async function processScheduleItem( requestId, queuedAt, `Released stale schedule ${schedule.id} for finished job ${scheduleJobId}`, - getNextRunFromCronExpression(schedule.cronExpression) + getNextRunFromCronExpression(schedule.cronExpression, schedule.timezone), + { expectedLastQueuedAt: queueTime } + ) + return + } + + let jobId: string + try { + jobId = await jobQueue.enqueue(SCHEDULE_EXECUTION_QUEUE_NAME, payload, { + jobId: scheduleJobId, + delayMs, + metadata: { + workflowId: schedule.workflowId ?? undefined, + workspaceId: schedule.workspaceId ?? undefined, + correlation, + }, + }) + enqueuedJobId = jobId + } catch (error) { + logger.error( + `[${requestId}] Failed to enqueue schedule execution for workflow ${schedule.workflowId}`, + error + ) + await handleClaimedScheduleSetupFailure( + schedule, + requestId, + queueTime, + error, + `Failed to defer schedule ${schedule.id} after enqueue failure`, + `Failed to mark schedule ${schedule.id} failed after non-retryable enqueue failure` ) return } - const resolvedWorkflow = schedule.workflowId - ? await workflowUtils.getWorkflowById(schedule.workflowId) - : null - const resolvedWorkspaceId = resolvedWorkflow?.workspaceId - - const jobId = await jobQueue.enqueue('schedule-execution', payload, { - jobId: scheduleJobId, - concurrencyKey: scheduleJobId, - delayMs: Math.floor(Math.random() * SCHEDULE_JITTER_MAX_MS), - metadata: { - workflowId: schedule.workflowId ?? undefined, - workspaceId: resolvedWorkspaceId ?? undefined, - correlation, - }, - }) logger.info( `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` ) + if (useDatabaseFallback) { + logger.info(`[${requestId}] Executing durable database schedule execution job`, { + scheduleId: schedule.id, + workflowId: schedule.workflowId, + jobId, + delayMs, + concurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + }) + await executeDatabaseScheduleJob( + jobQueue, + jobId, + payload, + schedule, + queueTime, + requestId, + delayMs + ) + return + } + const queuedJob = await jobQueue.getJob(jobId) if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) { logger.info(`[${requestId}] Schedule execution job already finished`, { @@ -248,52 +984,65 @@ async function processScheduleItem( requestId, queuedAt, `Released stale schedule ${schedule.id} for finished job ${jobId}`, - getNextRunFromCronExpression(schedule.cronExpression) + getNextRunFromCronExpression(schedule.cronExpression, schedule.timezone), + { expectedLastQueuedAt: queueTime } ) return } - - if (shouldExecuteInline()) { - try { - await jobQueue.startJob(jobId) - const output = await executeScheduleJob(payload) - await jobQueue.completeJob(jobId, output) - } catch (error) { - const errorMessage = toError(error).message - logger.error( - `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, - { - jobId, - error: errorMessage, - } - ) - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markFailedError) { - logger.error(`[${requestId}] Failed to mark job as failed`, { + if (queuedJob) { + const queuedJobClaim = getSchedulePayloadClaimedAt(getSchedulePayloadFromJob(queuedJob)) + if (queuedJobClaim) { + if (isStaleScheduleClaim(queuedJobClaim)) { + logger.warn(`[${requestId}] Cancelling stale queued schedule execution job`, { + scheduleId: schedule.id, jobId, - error: toError(markFailedError).message, + claimedAt: queuedJobClaim.toISOString(), }) + await jobQueue.cancelJob(jobId) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Released stale schedule ${schedule.id} after cancelling stale queued schedule execution job`, + undefined, + { expectedLastQueuedAt: queueTime } + ) + return } - await releaseScheduleLock( + + await restoreScheduleClaim( schedule.id, requestId, - queuedAt, - `Failed to release lock for schedule ${schedule.id} after inline execution failure` + queueTime, + queuedJobClaim, + `Failed to restore schedule ${schedule.id} claim for queued schedule execution job` ) } } + + logger.info(`[${requestId}] Schedule execution task accepted`, { + scheduleId: schedule.id, + workflowId: schedule.workflowId, + jobId, + delayMs, + concurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + backend: useDatabaseFallback ? 'database-fallback' : 'trigger-dev', + }) } catch (error) { logger.error( - `[${requestId}] Failed to queue schedule execution for workflow ${schedule.workflowId}`, + `[${requestId}] Failed after queueing schedule execution for workflow ${schedule.workflowId}`, error ) - await releaseScheduleLock( - schedule.id, - requestId, - queuedAt, - `Failed to release lock for schedule ${schedule.id} after queue failure` - ) + if (!enqueuedJobId) { + await handleClaimedScheduleSetupFailure( + schedule, + requestId, + queueTime, + error, + `Failed to defer schedule ${schedule.id} after pre-enqueue failure`, + `Failed to mark schedule ${schedule.id} failed after non-retryable setup failure` + ) + } } } @@ -316,83 +1065,132 @@ async function processJobItem(job: ClaimedJob, queuedAt: Date, requestId: string job.id, requestId, queuedAt, - `Failed to release lock for job ${job.id}` + `Failed to release lock for job ${job.id}`, + undefined, + { expectedLastQueuedAt: queueTime } ) } } -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() +interface ScheduleTickResult { + processedCount: number + totalSchedules: number + totalJobs: number +} + +/** + * Drains due schedules and jobs, claiming and enqueuing work until the tick + * budget is exhausted or no more items are due. Runs detached from the HTTP + * response so the cron caller does not wait; cross-replica safety is provided by + * the `FOR UPDATE SKIP LOCKED` claim layer, not this function. + */ +export async function runScheduleTick(requestId: string): Promise { const tickStart = Date.now() - logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) - const authError = verifyCronAuth(request, 'Schedule execution') - if (authError) { - return authError - } + const jobQueue = await getJobQueue() + const useDatabaseFallback = shouldExecuteInline() + let totalSchedules = 0 + let totalJobs = 0 + let iterations = 0 + let remainingWorkflowBudget = SCHEDULE_WORKFLOW_ENQUEUE_LIMIT + let schedulesExhausted = false + let jobsExhausted = false - try { - const jobQueue = await getJobQueue() - let workflowUtils: WorkflowUtils | undefined + while (Date.now() - tickStart < MAX_TICK_DURATION_MS) { + if (schedulesExhausted && jobsExhausted) break + const queuedAt = new Date() + let resumedPendingSchedules = 0 + let databaseScheduleSlots = SCHEDULE_EXECUTION_CONCURRENCY_LIMIT - let totalSchedules = 0 - let totalJobs = 0 - let iterations = 0 - let schedulesExhausted = false - let jobsExhausted = false + if (useDatabaseFallback) { + await recoverStaleDatabaseScheduleJobs(queuedAt) + databaseScheduleSlots = await getDatabaseScheduleExecutionSlots() + resumedPendingSchedules = await resumePendingDatabaseScheduleJobs( + jobQueue, + requestId, + databaseScheduleSlots + ) + databaseScheduleSlots = await getDatabaseScheduleExecutionSlots() + } - while (Date.now() - tickStart < MAX_TICK_DURATION_MS) { - if (schedulesExhausted && jobsExhausted) break - const queuedAt = new Date() + const workflowClaimLimit = Math.min( + WORKFLOW_CHUNK_SIZE, + remainingWorkflowBudget, + useDatabaseFallback ? databaseScheduleSlots : WORKFLOW_CHUNK_SIZE + ) - const [dueSchedules, dueJobs] = await Promise.all([ - schedulesExhausted ? [] : claimWorkflowSchedules(queuedAt, WORKFLOW_CHUNK_SIZE), - jobsExhausted ? [] : claimJobSchedules(queuedAt, JOB_CHUNK_SIZE), - ]) + if (useDatabaseFallback && workflowClaimLimit <= 0) { + schedulesExhausted = true + } - if (dueSchedules.length < WORKFLOW_CHUNK_SIZE) schedulesExhausted = true - if (dueJobs.length < JOB_CHUNK_SIZE) jobsExhausted = true + const [dueSchedules, dueJobs] = await Promise.all([ + schedulesExhausted ? [] : claimWorkflowSchedules(queuedAt, workflowClaimLimit), + jobsExhausted ? [] : claimJobSchedules(queuedAt, JOB_CHUNK_SIZE), + ]) - if (dueSchedules.length === 0 && dueJobs.length === 0) break + remainingWorkflowBudget -= dueSchedules.length + if (dueSchedules.length < workflowClaimLimit || remainingWorkflowBudget <= 0) { + schedulesExhausted = true + } + if (dueJobs.length < JOB_CHUNK_SIZE) jobsExhausted = true - iterations += 1 - totalSchedules += dueSchedules.length - totalJobs += dueJobs.length + if (dueSchedules.length === 0 && dueJobs.length === 0 && resumedPendingSchedules === 0) break - logger.info( - `[${requestId}] Iteration ${iterations}: claimed ${dueSchedules.length} schedules, ${dueJobs.length} jobs` - ) + iterations += 1 + totalSchedules += dueSchedules.length + resumedPendingSchedules + totalJobs += dueJobs.length - if (dueSchedules.length > 0 && !workflowUtils) { - workflowUtils = await import('@/lib/workflows/utils') + logger.info( + `[${requestId}] Iteration ${iterations}: claimed ${dueSchedules.length} schedules, resumed ${resumedPendingSchedules} pending schedule jobs, ${dueJobs.length} jobs`, + { + remainingWorkflowBudget, + scheduleConcurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + databaseScheduleSlots, } + ) - const loadedWorkflowUtils = workflowUtils - const schedulePromises = - loadedWorkflowUtils && dueSchedules.length > 0 - ? dueSchedules.map((schedule) => - processScheduleItem(schedule, queuedAt, requestId, jobQueue, loadedWorkflowUtils) - ) - : [] - - await Promise.allSettled([ - ...schedulePromises, - ...dueJobs.map((job) => processJobItem(job, queuedAt, requestId)), - ]) + const schedulePromises = + dueSchedules.length > 0 + ? dueSchedules.map((schedule) => + processScheduleItem(schedule, queuedAt, requestId, jobQueue, useDatabaseFallback) + ) + : [] + + await Promise.allSettled([ + ...schedulePromises, + ...dueJobs.map((job) => processJobItem(job, queuedAt, requestId)), + ]) + } + + const totalCount = totalSchedules + totalJobs + const durationMs = Date.now() - tickStart + logger.info( + `[${requestId}] Processed ${totalCount} items across ${iterations} iteration(s) in ${durationMs}ms (${totalSchedules} schedules, ${totalJobs} jobs)`, + { + scheduleConcurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + scheduleEnqueueBudget: SCHEDULE_WORKFLOW_ENQUEUE_LIMIT, + remainingWorkflowBudget, } + ) - const totalCount = totalSchedules + totalJobs - const durationMs = Date.now() - tickStart - logger.info( - `[${requestId}] Processed ${totalCount} items across ${iterations} iteration(s) in ${durationMs}ms (${totalSchedules} schedules, ${totalJobs} jobs)` - ) + return { processedCount: totalCount, totalSchedules, totalJobs } +} - return NextResponse.json({ - message: 'Scheduled workflow executions processed', - executedCount: totalCount, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error in scheduled execution handler`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) + + const authError = verifyCronAuth(request, 'Schedule execution') + if (authError) { + return authError } + + runDetached('schedule-execution-tick', () => runScheduleTick(requestId)) + + const response = { + message: 'Scheduled execution started', + status: 'started', + } satisfies ExecuteSchedulesResponse + + return NextResponse.json(response, { status: 202 }) }) diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts new file mode 100644 index 00000000000..e8d5fc91da4 --- /dev/null +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for the webhook polling cron route. + * + * @vitest-environment node + */ +import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyCronAuth, mockPollProvider } = vi.hoisted(() => ({ + mockVerifyCronAuth: vi.fn().mockReturnValue(null), + mockPollProvider: vi.fn().mockResolvedValue({ processed: 0 }), +})) + +vi.mock('@/lib/auth/internal', () => ({ + verifyCronAuth: mockVerifyCronAuth, +})) + +vi.mock('@/lib/core/config/redis', () => redisConfigMock) + +vi.mock('@/lib/webhooks/polling', () => ({ + pollProvider: mockPollProvider, + VALID_POLLING_PROVIDERS: new Set(['gmail', 'outlook', 'rss']), +})) + +import { GET } from './route' + +function createRequest() { + return createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/webhooks/poll/gmail') +} + +function createContext(provider: string) { + return { params: Promise.resolve({ provider }) } +} + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('webhook polling route (fire-and-forget)', () => { + beforeEach(() => { + vi.clearAllMocks() + redisConfigMockFns.mockAcquireLock.mockResolvedValue(true) + redisConfigMockFns.mockReleaseLock.mockResolvedValue(true) + mockVerifyCronAuth.mockReturnValue(null) + mockPollProvider.mockResolvedValue({ processed: 0 }) + }) + + it('returns the auth error when cron auth fails', async () => { + mockVerifyCronAuth.mockReturnValueOnce( + new Response(null, { status: 401 }) as unknown as Response + ) + + const response = await GET(createRequest(), createContext('gmail')) + + expect(response.status).toBe(401) + expect(mockPollProvider).not.toHaveBeenCalled() + }) + + it('returns 404 for an unknown provider', async () => { + const response = await GET(createRequest(), createContext('unknown')) + + expect(response.status).toBe(404) + expect(redisConfigMockFns.mockAcquireLock).not.toHaveBeenCalled() + }) + + it('acknowledges with 202 and polls in the background after acquiring the lock', async () => { + const response = await GET(createRequest(), createContext('gmail')) + + expect(response.status).toBe(202) + const data = await response.json() + expect(data).toMatchObject({ status: 'started' }) + expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledWith( + 'gmail-polling-lock', + expect.any(String), + expect.any(Number) + ) + + await flushMicrotasks() + expect(mockPollProvider).toHaveBeenCalledWith('gmail') + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith( + 'gmail-polling-lock', + expect.any(String) + ) + }) + + it('skips with 202 when the lock is already held', async () => { + redisConfigMockFns.mockAcquireLock.mockResolvedValueOnce(false) + + const response = await GET(createRequest(), createContext('gmail')) + + expect(response.status).toBe(202) + const data = await response.json() + expect(data).toMatchObject({ status: 'skip' }) + expect(mockPollProvider).not.toHaveBeenCalled() + }) + + it('releases the lock even when polling throws', async () => { + mockPollProvider.mockRejectedValueOnce(new Error('poll failed')) + + const response = await GET(createRequest(), createContext('gmail')) + + expect(response.status).toBe(202) + await flushMicrotasks() + expect(redisConfigMockFns.mockReleaseLock).toHaveBeenCalledWith( + 'gmail-polling-lock', + expect.any(String) + ) + }) +}) diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.ts index 3c8415ea343..a55c1082724 100644 --- a/apps/sim/app/api/webhooks/poll/[provider]/route.ts +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.ts @@ -6,6 +6,7 @@ import { webhookPollingContract } from '@/lib/api/contracts/webhooks' import { parseRequest } from '@/lib/api/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { runDetached } from '@/lib/core/utils/background' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { pollProvider, VALID_POLLING_PROVIDERS } from '@/lib/webhooks/polling' @@ -38,37 +39,38 @@ export const GET = withRouteHandler( } const LOCK_KEY = `${provider}-polling-lock` - let lockValue: string | undefined + const lockValue = requestId + const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS) + if (!locked) { + return NextResponse.json( + { + success: true, + message: 'Polling already in progress – skipped', + requestId, + status: 'skip', + }, + { status: 202 } + ) + } - try { - lockValue = requestId - const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS) - if (!locked) { - return NextResponse.json( - { - success: true, - message: 'Polling already in progress – skipped', - requestId, - status: 'skip', - }, - { status: 202 } - ) + const pollingProvider = provider + runDetached(`${pollingProvider}-polling`, async () => { + try { + await pollProvider(pollingProvider) + } finally { + await releaseLock(LOCK_KEY, lockValue).catch(() => {}) } + }) - const results = await pollProvider(provider) - - return NextResponse.json({ + return NextResponse.json( + { success: true, - message: `${provider} polling completed`, + message: `${provider} polling started`, requestId, - status: 'completed', - ...results, - }) - } finally { - if (lockValue) { - await releaseLock(LOCK_KEY, lockValue).catch(() => {}) - } - } + status: 'started', + }, + { status: 202 } + ) } catch (error) { const providerLabel = provider ?? 'webhook' logger.error(`Error during ${providerLabel} polling (${requestId}):`, error) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 5b8debdc366..2c7b9aa7064 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -10,14 +10,21 @@ import { loggingSessionMock, requestUtilsMockFns, workflowAuthzMockFns, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' +import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnqueue } = vi.hoisted(() => ({ - mockEnqueue: vi.fn().mockResolvedValue('job-123'), -})) +const { mockEnqueue, mockExecuteWorkflowCore, mockHandlePostExecutionPauseState } = vi.hoisted( + () => ({ + mockEnqueue: vi.fn().mockResolvedValue('job-123'), + mockExecuteWorkflowCore: vi.fn(), + mockHandlePostExecutionPauseState: vi.fn(), + }) +) const mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution @@ -29,6 +36,26 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) + +vi.mock('@/lib/workflows/executor/execution-core', () => ({ + executeWorkflowCore: mockExecuteWorkflowCore, +})) + +vi.mock('@/lib/workflows/executor/pause-persistence', () => ({ + handlePostExecutionPauseState: mockHandlePostExecutionPauseState, +})) + +vi.mock('@/lib/execution/payloads/store', () => ({ + storeLargeValue: vi.fn(async (_value, _json, size: number) => ({ + __simLargeValueRef: true, + version: 1, + id: 'lv_abcdefghijkl', + kind: 'string', + size, + })), +})) + vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ enqueue: mockEnqueue, @@ -65,6 +92,7 @@ vi.mock('@sim/utils/id', () => ({ ), })) +import { storeLargeValue } from '@/lib/execution/payloads/store' import { POST } from './route' describe('workflow execute async route', () => { @@ -99,6 +127,19 @@ describe('workflow execute async route', () => { workspaceId: 'workspace-1', }, }) + workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState.mockResolvedValue(null) + workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) + mockExecuteWorkflowCore.mockResolvedValue({ + success: true, + status: 'completed', + output: { ok: true }, + metadata: { + duration: 100, + startTime: '2026-01-01T00:00:00Z', + endTime: '2026-01-01T00:00:01Z', + }, + }) + mockHandlePostExecutionPauseState.mockResolvedValue(undefined) }) it('queues async execution with matching correlation metadata', async () => { @@ -112,7 +153,7 @@ describe('workflow execute async route', () => { ) const params = Promise.resolve({ id: 'workflow-1' }) - const response = await POST(req as any, { params }) + const response = await POST(req, { params }) const body = await response.json() expect(response.status).toBe(202) @@ -143,4 +184,243 @@ describe('workflow execute async route', () => { }) ) }) + + it('rejects oversized request bodies before authorization work', async () => { + const req = createMockRequest( + 'POST', + { input: { hello: 'world' } }, + { + 'Content-Type': 'application/json', + 'Content-Length': String(10 * 1024 * 1024 + 1), + } + ) + const params = Promise.resolve({ id: 'workflow-1' }) + + const response = await POST(req, { params }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error).toContain('Workflow execution request body') + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('authenticates before rejecting oversized request bodies', async () => { + mockCheckHybridAuth.mockResolvedValueOnce({ + success: false, + error: 'Unauthorized', + authType: 'api_key', + }) + const req = createMockRequest( + 'POST', + { input: { hello: 'world' } }, + { + 'Content-Type': 'application/json', + 'Content-Length': String(10 * 1024 * 1024 + 1), + 'X-API-Key': 'invalid', + } + ) + const params = Promise.resolve({ id: 'workflow-1' }) + + const response = await POST(req, { params }) + const body = await response.json() + + expect(response.status).toBe(401) + expect(body.error).toBe('Unauthorized') + expect(mockCheckHybridAuth).toHaveBeenCalled() + }) + + it('returns 499 when a non-SSE execution is cancelled by client disconnect', async () => { + const abortController = new AbortController() + mockExecuteWorkflowCore.mockImplementationOnce( + async ({ abortSignal }: { abortSignal: AbortSignal }) => { + abortController.abort() + expect(abortSignal.aborted).toBe(true) + return { + success: false, + status: 'cancelled', + output: { partial: true }, + metadata: { + duration: 100, + startTime: '2026-01-01T00:00:00Z', + endTime: '2026-01-01T00:00:01Z', + }, + } + } + ) + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-1/execute', { + method: 'POST', + body: JSON.stringify({ input: { hello: 'world' } }), + signal: abortController.signal, + }) + const params = Promise.resolve({ id: 'workflow-1' }) + + const response = await POST(req, { params }) + const body = await response.json() + + expect(response.status).toBe(499) + expect(body.error).toBe('Client cancelled request') + }) + + it('rejects large MCP bridge outputs instead of returning large-value refs', async () => { + mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'internal-user-1', + authType: 'internal_jwt', + }) + mockExecuteWorkflowCore.mockResolvedValueOnce({ + success: true, + status: 'completed', + output: 'x'.repeat(10 * 1024 * 1024 + 1), + metadata: { + duration: 100, + startTime: '2026-01-01T00:00:00Z', + endTime: '2026-01-01T00:00:01Z', + }, + }) + const req = createMockRequest( + 'POST', + { input: { hello: 'world' } }, + { + 'Content-Type': 'application/json', + 'X-Sim-MCP-Tool-Call': 'true', + } + ) + const params = Promise.resolve({ id: 'workflow-1' }) + + const response = await POST(req, { params }) + const body = await response.json() + + expect(response.status).toBe(413) + expect(body.error).toContain('Workflow execution response') + expect(storeLargeValue).not.toHaveBeenCalled() + }) + + it('does not trust client-spoofed MCP bridge headers on API key executions', async () => { + mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'api-user-1', + authType: 'api_key', + apiKeyType: 'personal', + }) + workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValueOnce(true) + workflowsUtilsMockFns.mockCreateHttpResponseFromBlock.mockResolvedValueOnce( + Response.json({ response: 'plain text body' }) + ) + mockExecuteWorkflowCore.mockResolvedValueOnce({ + success: true, + status: 'completed', + output: { response: 'plain text body' }, + metadata: { + duration: 100, + startTime: '2026-01-01T00:00:00Z', + endTime: '2026-01-01T00:00:01Z', + }, + }) + const req = createMockRequest( + 'POST', + { input: { hello: 'world' } }, + { + 'Content-Type': 'application/json', + 'X-API-Key': 'valid', + 'X-Sim-MCP-Tool-Call': 'true', + } + ) + const params = Promise.resolve({ id: 'workflow-1' }) + + const response = await POST(req, { params }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ response: 'plain text body' }) + expect(workflowsUtilsMockFns.mockCreateHttpResponseFromBlock).toHaveBeenCalled() + }) + + it('keeps trusted internal MCP bridge executions on the JSON envelope path', async () => { + mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'internal-user-1', + authType: 'internal_jwt', + }) + workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValueOnce(true) + mockExecuteWorkflowCore.mockResolvedValueOnce({ + success: true, + status: 'completed', + output: { response: 'plain text body' }, + metadata: { + duration: 100, + startTime: '2026-01-01T00:00:00Z', + endTime: '2026-01-01T00:00:01Z', + }, + }) + const req = createMockRequest( + 'POST', + { input: { hello: 'world' } }, + { + 'Content-Type': 'application/json', + 'X-Sim-MCP-Tool-Call': 'true', + } + ) + const params = Promise.resolve({ id: 'workflow-1' }) + + const response = await POST(req, { params }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toMatchObject({ + success: true, + output: { response: 'plain text body' }, + }) + expect(workflowsUtilsMockFns.mockCreateHttpResponseFromBlock).not.toHaveBeenCalled() + expect(mockExecuteWorkflowCore).toHaveBeenCalledWith( + expect.objectContaining({ + snapshot: expect.objectContaining({ + input: { hello: 'world' }, + }), + }) + ) + }) + + it('preserves authenticated-user actor semantics for trusted MCP bridge calls', async () => { + mockCheckHybridAuth.mockResolvedValueOnce({ + success: true, + userId: 'api-user-1', + authType: 'internal_jwt', + }) + mockExecuteWorkflowCore.mockResolvedValueOnce({ + success: true, + status: 'completed', + output: { ok: true }, + metadata: { + duration: 100, + startTime: '2026-01-01T00:00:00Z', + endTime: '2026-01-01T00:00:01Z', + }, + }) + const req = createMockRequest( + 'POST', + { input: { hello: 'world' } }, + { + 'Content-Type': 'application/json', + 'X-Sim-MCP-Tool-Call': 'true', + 'X-Sim-MCP-Tool-Actor': 'authenticated-user', + } + ) + const params = Promise.resolve({ id: 'workflow-1' }) + + const response = await POST(req, { params }) + + expect(response.status).toBe(200) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'api-user-1', + useAuthenticatedUserAsActor: true, + }) + ) + const executionCall = mockExecuteWorkflowCore.mock.calls[0][0] + const snapshot = + typeof executionCall.snapshot === 'string' + ? JSON.parse(executionCall.snapshot) + : executionCall.snapshot + expect(snapshot.metadata.enforceCredentialAccess).toBe(true) + }) }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index dc35f7b6a93..7a110caa13a 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -17,6 +17,12 @@ import { } from '@/lib/core/execution-limits' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + PayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -36,9 +42,15 @@ import { registerManualExecutionAborter, unregisterManualExecutionAborter, } from '@/lib/execution/manual-cancellation' +import { containsLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { + MAX_MCP_WORKFLOW_RESPONSE_BYTES, + MCP_TOOL_BRIDGE_ACTOR_HEADER, + MCP_TOOL_BRIDGE_HEADER, +} from '@/lib/mcp/constants' import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, @@ -72,6 +84,7 @@ import { Serializer } from '@/serializer' import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types' const logger = createLogger('WorkflowExecuteAPI') +const MAX_WORKFLOW_EXECUTE_BODY_BYTES = 10 * 1024 * 1024 export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -85,11 +98,73 @@ async function compactRoutePayload( userId?: string preserveUserFileBase64?: boolean preserveRoot?: boolean + rejectLargeValues?: boolean + rejectLargeValueLabel?: string + thresholdBytes?: number } ): Promise { return compactExecutionPayload(value, { ...context, requireDurable: true }) } +async function compactWorkflowResponseOutput( + value: T, + context: { + workspaceId?: string + workflowId?: string + executionId?: string + userId?: string + rejectLargeInlineOutput: boolean + } +): Promise { + const compacted = await compactRoutePayload(value, { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + executionId: context.executionId, + userId: context.userId, + preserveUserFileBase64: true, + preserveRoot: !context.rejectLargeInlineOutput, + rejectLargeValues: context.rejectLargeInlineOutput, + rejectLargeValueLabel: 'Workflow execution response', + thresholdBytes: context.rejectLargeInlineOutput ? MAX_MCP_WORKFLOW_RESPONSE_BYTES : undefined, + }) + + if (context.rejectLargeInlineOutput && containsLargeValueRef(compacted)) { + throw new PayloadSizeLimitError({ + label: 'Workflow execution response', + maxBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES, + observedBytes: MAX_MCP_WORKFLOW_RESPONSE_BYTES + 1, + }) + } + + return compacted +} + +async function readExecuteRequestBody(req: NextRequest): Promise { + assertContentLengthWithinLimit( + req.headers, + MAX_WORKFLOW_EXECUTE_BODY_BYTES, + 'Workflow execution request body' + ) + const buffer = await readStreamToBufferWithLimit(req.body, { + maxBytes: MAX_WORKFLOW_EXECUTE_BODY_BYTES, + label: 'Workflow execution request body', + signal: req.signal, + }) + if (buffer.byteLength === 0) return {} + return JSON.parse(buffer.toString('utf-8')) +} + +function clientCancelledResponse(): NextResponse { + return NextResponse.json({ success: false, error: 'Client cancelled request' }, { status: 499 }) +} + +function payloadTooLargeResponse(message = 'Workflow execution response exceeds maximum size') { + return NextResponse.json( + { success: false, error: message, code: 'workflow_response_too_large' }, + { status: 413 } + ) +} + function resolveOutputIds( selectedOutputs: string[] | undefined, blocks: Record @@ -143,6 +218,28 @@ function resolveOutputIds( }) } +function bindRequestAbort( + requestSignal: AbortSignal, + timeoutController: ReturnType +): { isRequestAborted: () => boolean; cleanup: () => void } { + let requestAborted = false + const abortFromRequest = () => { + requestAborted = true + timeoutController.abort() + } + + if (requestSignal.aborted) { + abortFromRequest() + } else { + requestSignal.addEventListener('abort', abortFromRequest, { once: true }) + } + + return { + isRequestAborted: () => requestAborted || requestSignal.aborted, + cleanup: () => requestSignal.removeEventListener('abort', abortFromRequest), + } +} + type AsyncExecutionParams = { requestId: string workflowId: string @@ -279,6 +376,10 @@ async function handleExecutePost( try { const auth = await checkHybridAuth(req, { requireWorkflowId: false }) + const isMcpBridgeRequest = + auth.authType === AuthType.INTERNAL_JWT && req.headers.get(MCP_TOOL_BRIDGE_HEADER) === 'true' + const useMcpBridgeAuthenticatedUserAsActor = + isMcpBridgeRequest && req.headers.get(MCP_TOOL_BRIDGE_ACTOR_HEADER) === 'authenticated-user' let userId: string let isPublicApiAccess = false @@ -321,14 +422,24 @@ async function handleExecutePost( } let body: any = {} - const text = await req.text() - if (text) { - try { - body = JSON.parse(text) - } catch (error) { - reqLogger.warn('Failed to parse request body', { error: toError(error).message }) - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + try { + body = await readExecuteRequestBody(req) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + reqLogger.warn('Workflow execution request body exceeded size limit', { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }) + return NextResponse.json( + { error: 'Workflow execution request body exceeds maximum size' }, + { status: 413 } + ) } + if (req.signal.aborted) { + return clientCancelledResponse() + } + reqLogger.warn('Failed to parse request body', { error: toError(error).message }) + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) } const validation = executeWorkflowBodySchema.safeParse(body) @@ -461,10 +572,11 @@ async function handleExecutePost( // For API key and internal JWT auth, the entire body is the input (except for our control fields) // For session auth, the input is explicitly provided in the input field - const input = - isPublicApiAccess || - auth.authType === AuthType.API_KEY || - auth.authType === AuthType.INTERNAL_JWT + const input = isMcpBridgeRequest + ? validatedInput + : isPublicApiAccess || + auth.authType === AuthType.API_KEY || + auth.authType === AuthType.INTERNAL_JWT ? (() => { const { selectedOutputs, @@ -500,6 +612,10 @@ async function handleExecutePost( useDraftState || workflowStateOverride || rawRunFromBlock ) + if (req.signal.aborted) { + return clientCancelledResponse() + } + if ( isAsyncMode && (body.useDraftState !== undefined || @@ -544,7 +660,9 @@ async function handleExecutePost( // Client-side sessions and personal API keys bill/permission-check the // authenticated user, not the workspace billed account. const useAuthenticatedUserAsActor = - isClientSession || (auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal') + isClientSession || + (auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal') || + useMcpBridgeAuthenticatedUserAsActor // Authorization fetches the full workflow record and checks workspace permissions. // Run it first so we can pass the record to preprocessing (eliminates a duplicate DB query). @@ -560,6 +678,10 @@ async function handleExecutePost( ) } + if (req.signal.aborted) { + return clientCancelledResponse() + } + // Pass the pre-fetched workflow record to skip the redundant Step 1 DB query in preprocessing. const preprocessResult = await preprocessExecution({ workflowId, @@ -581,6 +703,10 @@ async function handleExecutePost( ) } + if (req.signal.aborted) { + return clientCancelledResponse() + } + const actorUserId = preprocessResult.actorUserId! const workflow = preprocessResult.workflowRecord! @@ -624,10 +750,17 @@ async function handleExecutePost( let processedInput = input try { + if (req.signal.aborted) { + return clientCancelledResponse() + } const workflowData = shouldUseDraftState ? await loadWorkflowFromNormalizedTables(workflowId) : await loadDeployedWorkflowState(workflowId, workspaceId) + if (req.signal.aborted) { + return clientCancelledResponse() + } + if (workflowData) { const deployedVariables = !shouldUseDraftState && 'variables' in workflowData @@ -732,6 +865,15 @@ async function handleExecutePost( const timeoutController = createTimeoutAbortController( preprocessResult.executionTimeout?.sync ) + const requestAbort = bindRequestAbort(req.signal, timeoutController) + const shouldRejectLargeInlineOutput = isMcpBridgeRequest + const workflowResponseCompaction = { + workspaceId, + workflowId, + executionId, + userId: actorUserId, + rejectLargeInlineOutput: shouldRejectLargeInlineOutput, + } try { const snapshot = new ExecutionSnapshot( @@ -754,14 +896,16 @@ async function handleExecutePost( }) await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) - const compactResultOutput = await compactRoutePayload(result.output, { - workspaceId, - workflowId, - executionId, - userId: actorUserId, - preserveUserFileBase64: true, - preserveRoot: true, - }) + + if ( + result.status === 'cancelled' && + requestAbort.isRequestAborted() && + !timeoutController.isTimedOut() + ) { + reqLogger.info('Non-SSE execution cancelled by client disconnect') + await loggingSession.markAsFailed('Client cancelled request') + return clientCancelledResponse() + } if ( result.status === 'cancelled' && @@ -773,6 +917,10 @@ async function handleExecutePost( timeoutMs: timeoutController.timeoutMs, }) await loggingSession.markAsFailed(timeoutErrorMessage) + const compactResultOutput = await compactWorkflowResponseOutput( + result.output, + workflowResponseCompaction + ) return NextResponse.json( { @@ -794,31 +942,32 @@ async function handleExecutePost( const outputLargeValueKeys = result.metadata?.largeValueKeys ?? largeValueKeys const outputFileKeys = result.metadata?.fileKeys ?? fileKeys - const outputWithBase64 = includeFileBase64 - ? ((await hydrateUserFilesWithBase64(result.output, { - requestId, - workspaceId, - workflowId, - executionId, - largeValueExecutionIds, - largeValueKeys: outputLargeValueKeys, - fileKeys: outputFileKeys, - allowLargeValueWorkflowScope, - userId: actorUserId, - maxBytes: base64MaxBytes, - preserveLargeValueMetadata: true, - })) as NormalizedBlockOutput) - : result.output + const outputWithBase64 = + includeFileBase64 && !shouldRejectLargeInlineOutput + ? ((await hydrateUserFilesWithBase64(result.output, { + requestId, + workspaceId, + workflowId, + executionId, + largeValueExecutionIds, + largeValueKeys: outputLargeValueKeys, + fileKeys: outputFileKeys, + allowLargeValueWorkflowScope, + userId: actorUserId, + maxBytes: base64MaxBytes, + preserveLargeValueMetadata: true, + })) as NormalizedBlockOutput) + : result.output - if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(result)) { - const compactResponseBlockOutput = await compactRoutePayload(outputWithBase64, { - workspaceId, - workflowId, - executionId, - userId: actorUserId, - preserveUserFileBase64: true, - preserveRoot: true, - }) + if ( + !isMcpBridgeRequest && + auth.authType !== AuthType.INTERNAL_JWT && + workflowHasResponseBlock(result) + ) { + const compactResponseBlockOutput = await compactWorkflowResponseOutput( + outputWithBase64, + workflowResponseCompaction + ) return await createHttpResponseFromBlock( { ...result, output: compactResponseBlockOutput }, { @@ -834,14 +983,10 @@ async function handleExecutePost( ) } - const compactOutput = await compactRoutePayload(outputWithBase64, { - workspaceId, - workflowId, - executionId, - userId: actorUserId, - preserveUserFileBase64: true, - preserveRoot: true, - }) + const compactOutput = await compactWorkflowResponseOutput( + outputWithBase64, + workflowResponseCompaction + ) const filteredResult = { success: result.success, @@ -861,21 +1006,40 @@ async function handleExecutePost( } catch (error: unknown) { const errorMessage = getErrorMessage(error, 'Unknown error') + if (requestAbort.isRequestAborted() && !timeoutController.isTimedOut()) { + reqLogger.info('Non-SSE execution aborted after client disconnect') + return clientCancelledResponse() + } + if ( + isPayloadSizeLimitError(error) && + shouldRejectLargeInlineOutput && + error.label === 'Workflow execution response' + ) { + return payloadTooLargeResponse() + } + reqLogger.error(`Non-SSE execution failed: ${errorMessage}`) const executionResult = hasExecutionResult(error) ? error.executionResult : undefined const status = getExecutionErrorStatus(error) - const compactErrorOutput = executionResult?.output - ? await compactRoutePayload(executionResult.output, { - workspaceId, - workflowId, - executionId, - userId: actorUserId, - preserveUserFileBase64: true, - preserveRoot: true, - }) - : undefined - + let compactErrorOutput: NormalizedBlockOutput | undefined + if (executionResult && Object.hasOwn(executionResult, 'output')) { + try { + compactErrorOutput = await compactWorkflowResponseOutput( + executionResult.output, + workflowResponseCompaction + ) + } catch (compactError) { + if ( + isPayloadSizeLimitError(compactError) && + shouldRejectLargeInlineOutput && + compactError.label === 'Workflow execution response' + ) { + return payloadTooLargeResponse() + } + throw compactError + } + } return NextResponse.json( { success: false, @@ -892,6 +1056,7 @@ async function handleExecutePost( { status } ) } finally { + requestAbort.cleanup() timeoutController.cleanup() if (executionId) { void cleanupExecutionBase64Cache(executionId).catch((error) => { @@ -1188,10 +1353,18 @@ async function handleExecutePost( const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() + const cancelReader = () => { + void reader.cancel(timeoutController.signal.reason).catch(() => {}) + } try { + if (timeoutController.signal.aborted || isStreamClosed) return + timeoutController.signal.addEventListener('abort', cancelReader, { once: true }) + while (true) { + if (timeoutController.signal.aborted || isStreamClosed) break const { done, value } = await reader.read() + if (timeoutController.signal.aborted || isStreamClosed) break if (done) break const chunk = decoder.decode(value, { stream: true }) @@ -1204,16 +1377,21 @@ async function handleExecutePost( }) } - await sendEvent({ - type: 'stream:done', - timestamp: new Date().toISOString(), - executionId, - workflowId, - data: { blockId }, - }) + if (!timeoutController.signal.aborted && !isStreamClosed) { + await sendEvent({ + type: 'stream:done', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { blockId }, + }) + } } catch (error) { - reqLogger.error('Error streaming block content:', error) + if (!timeoutController.signal.aborted && !isStreamClosed) { + reqLogger.error('Error streaming block content:', error) + } } finally { + timeoutController.signal.removeEventListener('abort', cancelReader) try { await reader.cancel().catch(() => {}) } catch {} @@ -1518,6 +1696,7 @@ async function handleExecutePost( }, cancel() { isStreamClosed = true + timeoutController.abort() reqLogger.info('Client disconnected from SSE stream') }, }) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 23bd4bd18f1..47c6f4a1e59 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -18,7 +19,10 @@ import { createWorkspaceEnvCredentials, deleteWorkspaceEnvCredentials, } from '@/lib/credentials/environment' -import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { + getPersonalAndWorkspaceEnv, + invalidateEffectiveDecryptedEnvCache, +} from '@/lib/environment/utils' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceEnvironmentAPI') @@ -64,10 +68,10 @@ export const GET = withRouteHandler( }, { status: 200 } ) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Workspace env GET error`, error) return NextResponse.json( - { error: error.message || 'Failed to load environment' }, + { error: getErrorMessage(error, 'Failed to load environment') }, { status: 500 } ) } @@ -135,6 +139,7 @@ export const PUT = withRouteHandler( return { existingEncrypted: existing, merged: mergedVars } }) + invalidateEffectiveDecryptedEnvCache({ workspaceId }) const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted)) await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId: userId }) @@ -156,10 +161,10 @@ export const PUT = withRouteHandler( }) return NextResponse.json({ success: true }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Workspace env PUT error`, error) return NextResponse.json( - { error: error.message || 'Failed to update environment' }, + { error: getErrorMessage(error, 'Failed to update environment') }, { status: 500 } ) } @@ -223,6 +228,7 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }) } + invalidateEffectiveDecryptedEnvCache({ workspaceId }) await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys }) recordAudit({ @@ -242,10 +248,10 @@ export const DELETE = withRouteHandler( }) return NextResponse.json({ success: true }) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Workspace env DELETE error`, error) return NextResponse.json( - { error: error.message || 'Failed to remove environment keys' }, + { error: getErrorMessage(error, 'Failed to remove environment keys') }, { status: 500 } ) } diff --git a/apps/sim/background/async-execution-correlation.test.ts b/apps/sim/background/async-execution-correlation.test.ts index 4b2f8fb097f..78c840f2fe0 100644 --- a/apps/sim/background/async-execution-correlation.test.ts +++ b/apps/sim/background/async-execution-correlation.test.ts @@ -3,9 +3,16 @@ */ import { describe, expect, it } from 'vitest' -import { buildScheduleCorrelation } from './schedule-execution' -import { buildWebhookCorrelation } from './webhook-execution' -import { buildWorkflowCorrelation } from './workflow-execution' +import { + describeRetryableInfrastructureError, + isRetryableInfrastructureError, +} from '@/lib/core/errors/retryable-infrastructure' +import { + buildScheduleCorrelation, + scheduleExecutionTaskOptions, +} from '@/background/schedule-execution' +import { buildWebhookCorrelation } from '@/background/webhook-execution' +import { buildWorkflowCorrelation } from '@/background/workflow-execution' describe('async execution correlation fallbacks', () => { it('falls back for legacy workflow payloads missing correlation fields', () => { @@ -45,6 +52,41 @@ describe('async execution correlation fallbacks', () => { }) }) + it('caps schedule execution concurrency at the task queue', () => { + expect(scheduleExecutionTaskOptions).toMatchObject({ + queue: { + name: 'schedule-execution', + concurrencyLimit: 50, + }, + }) + }) + + it('classifies retryable driver causes without treating every failed query as retryable', () => { + const driverError = Object.assign(new Error('remaining connection slots are reserved'), { + code: '53300', + }) + const drizzleError = new Error('Failed query: select * from "environment"', { + cause: driverError, + }) + + expect(isRetryableInfrastructureError(drizzleError)).toBe(true) + expect(describeRetryableInfrastructureError(drizzleError)).toEqual( + expect.objectContaining({ + code: '53300', + message: 'remaining connection slots are reserved', + }) + ) + expect( + isRetryableInfrastructureError(new Error('remaining connection slots are reserved')) + ).toBe(false) + expect( + isRetryableInfrastructureError( + Object.assign(new Error('connect failed'), { code: 'ETIMEDOUT' }) + ) + ).toBe(true) + expect(isRetryableInfrastructureError(new Error('Failed query: syntax error'))).toBe(false) + }) + it('falls back for legacy webhook payloads missing preassigned fields', () => { const correlation = buildWebhookCorrelation({ webhookId: 'webhook-1', diff --git a/apps/sim/background/async-preprocessing-correlation.test.ts b/apps/sim/background/async-preprocessing-correlation.test.ts index 7bf96d70519..50432b83e7d 100644 --- a/apps/sim/background/async-preprocessing-correlation.test.ts +++ b/apps/sim/background/async-preprocessing-correlation.test.ts @@ -100,6 +100,7 @@ describe('async preprocessing correlation threading', () => { workflowId: 'workflow-1', status: 'active', archivedAt: null, + lastQueuedAt: new Date('2025-01-01T00:00:00.000Z'), }, ]) mockLoadDeployedWorkflowState.mockResolvedValue({ @@ -254,4 +255,65 @@ describe('async preprocessing correlation threading', () => { }) ) }) + + it('increments infrastructure retry count for retryable schedule preprocessing failures', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { + message: 'database unavailable', + statusCode: 500, + logCreated: true, + retryable: true, + cause: { code: '53300' }, + }, + }) + + await executeScheduleJob({ + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + executionId: 'execution-retry', + requestId: 'request-retry', + now: '2025-01-01T00:00:00.000Z', + scheduledFor: '2025-01-01T00:00:00.000Z', + infraRetryCount: 2, + }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastQueuedAt: null, + infraRetryCount: 3, + }) + ) + }) + + it('moves exhausted infrastructure retries onto the normal failure path', async () => { + mockPreprocessExecution.mockResolvedValueOnce({ + success: false, + error: { + message: 'database unavailable', + statusCode: 500, + logCreated: true, + retryable: true, + cause: { code: '53300' }, + }, + }) + + await executeScheduleJob({ + scheduleId: 'schedule-1', + workflowId: 'workflow-1', + executionId: 'execution-retry-exhausted', + requestId: 'request-retry-exhausted', + now: '2025-01-01T00:00:00.000Z', + scheduledFor: '2025-01-01T00:00:00.000Z', + infraRetryCount: 10, + }) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastQueuedAt: null, + lastFailedAt: expect.any(Date), + infraRetryCount: 0, + }) + ) + }) }) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 5844dd3164d..4c240e5f1e7 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -8,11 +8,16 @@ import { import { createLogger, runWithRequestContext } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { backoffWithJitter } from '@sim/utils/retry' import { task } from '@trigger.dev/sdk' import { Cron } from 'croner' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, isNull, type SQL, sql } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' +import { + describeRetryableInfrastructureError, + isRetryableInfrastructureError, +} from '@/lib/core/errors/retryable-infrastructure' import { createTimeoutAbortController, getExecutionTimeout, @@ -28,6 +33,13 @@ import { } from '@/lib/workflows/executor/execution-core' import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' +import { + SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + SCHEDULE_EXECUTION_QUEUE_NAME, + SCHEDULE_INFRA_RETRY_BASE_MS, + SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS, + SCHEDULE_INFRA_RETRY_MAX_MS, +} from '@/lib/workflows/schedules/execution-limits' import { type BlockState, calculateNextRunTime as calculateNextTime, @@ -42,20 +54,41 @@ import { hasExecutionResult } from '@/executor/utils/errors' import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants' -const logger = createLogger('TriggerScheduleExecution') +const logger = createLogger('ScheduleExecution') type WorkflowRecord = typeof workflow.$inferSelect -type WorkflowScheduleUpdate = Partial +type WorkflowScheduleInsert = typeof workflowSchedule.$inferInsert +type WorkflowScheduleUpdate = Partial> & { + failedCount?: WorkflowScheduleInsert['failedCount'] | SQL + status?: WorkflowScheduleInsert['status'] | SQL +} type ExecutionCoreResult = Awaited> +function incrementScheduleFailedCount(): SQL { + return sql`COALESCE(${workflowSchedule.failedCount}, 0) + 1` +} + +function scheduleStatusAfterFailedCountIncrement(): SQL { + return sql`CASE WHEN COALESCE(${workflowSchedule.failedCount}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN 'disabled' ELSE 'active' END` +} + +function resetScheduleInfraRetryCount(): Pick { + return { infraRetryCount: 0 } +} + type RunWorkflowResult = | { status: 'skip' - reason: 'stale_deployment' | 'invalid_schedule' + reason: 'stale_deployment' | 'invalid_schedule' | 'stale_claim' blocks: Record } | { status: 'success'; blocks: Record; executionResult: ExecutionCoreResult } | { status: 'failure'; blocks: Record; executionResult: ExecutionCoreResult } + | { + status: 'retryable_setup_failure' + error: unknown + cause?: Record + } export function buildScheduleCorrelation( payload: ScheduleExecutionPayload @@ -78,15 +111,29 @@ async function applyScheduleUpdate( scheduleId: string, updates: WorkflowScheduleUpdate, requestId: string, - context: string -) { + context: string, + options: { expectedLastQueuedAt?: Date | null } = {} +): Promise { try { - await db + const claimGuard = + options.expectedLastQueuedAt === undefined + ? undefined + : options.expectedLastQueuedAt === null + ? isNull(workflowSchedule.lastQueuedAt) + : eq(workflowSchedule.lastQueuedAt, options.expectedLastQueuedAt) + + const updatedRows = await db .update(workflowSchedule) .set(updates) - .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) + .where( + and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt), claimGuard) + ) + .returning({ id: workflowSchedule.id }) + + return updatedRows.length > 0 } catch (error) { logger.error(`[${requestId}] ${context}`, error) + throw error } } @@ -95,8 +142,9 @@ export async function releaseScheduleLock( requestId: string, now: Date, context: string, - nextRunAt?: Date | null -) { + nextRunAt?: Date | null, + options: { expectedLastQueuedAt?: Date | null } = {} +): Promise { const updates: WorkflowScheduleUpdate = { updatedAt: now, lastQueuedAt: null, @@ -106,7 +154,94 @@ export async function releaseScheduleLock( updates.nextRunAt = nextRunAt } - await applyScheduleUpdate(scheduleId, updates, requestId, context) + return applyScheduleUpdate(scheduleId, updates, requestId, context, options) +} + +function getScheduleClaimedAt(payload: ScheduleExecutionPayload): Date | null { + const claimedAt = new Date(payload.now) + return Number.isNaN(claimedAt.getTime()) ? null : claimedAt +} + +async function retryScheduleAfterInfraFailure({ + payload, + requestId, + claimedAt, + error, + message, + cause, +}: { + payload: ScheduleExecutionPayload + requestId: string + claimedAt: Date | null + error?: unknown + message?: string + cause?: Record +}) { + const now = new Date() + const retryAttempt = (payload.infraRetryCount || 0) + 1 + if (retryAttempt > SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS) { + logger.error(`[${requestId}] Retryable infrastructure failures exhausted for schedule`, { + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + retryAttempt, + maxAttempts: SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS, + cause: cause ?? describeRetryableInfrastructureError(error), + }) + + const nextRunAt = await determineNextRunAfterError(payload, now, requestId) + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + nextRunAt, + lastQueuedAt: null, + failedCount: incrementScheduleFailedCount(), + lastFailedAt: now, + status: scheduleStatusAfterFailedCountIncrement(), + ...resetScheduleInfraRetryCount(), + }, + requestId, + `Error updating schedule ${payload.scheduleId} after exhausted infrastructure retries`, + { expectedLastQueuedAt: claimedAt } + ) + return + } + + const retryDelayMs = Math.min( + SCHEDULE_INFRA_RETRY_MAX_MS, + Math.round( + backoffWithJitter(retryAttempt, null, { + baseMs: SCHEDULE_INFRA_RETRY_BASE_MS, + maxMs: SCHEDULE_INFRA_RETRY_MAX_MS, + }) + ) + ) + const nextRetryAt = new Date(now.getTime() + retryDelayMs) + const failureCause = cause ?? describeRetryableInfrastructureError(error) + const errorMessage = message ?? (error ? toError(error).message : undefined) + + logger.warn(`[${requestId}] Retryable infrastructure failure during scheduled setup`, { + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + retryAttempt, + error: errorMessage, + retryDelayMs, + nextRetryAt: nextRetryAt.toISOString(), + cause: failureCause, + }) + + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + nextRunAt: nextRetryAt, + lastQueuedAt: null, + infraRetryCount: retryAttempt, + }, + requestId, + `Error updating schedule ${payload.scheduleId} after retryable infrastructure failure`, + { expectedLastQueuedAt: claimedAt } + ) } async function calculateNextRunFromDeployment( @@ -169,6 +304,21 @@ async function isScheduleDeploymentVersionActive( return Boolean(activeDeployment) } +async function isScheduleClaimCurrent( + scheduleId: string, + claimedAt: Date | null +): Promise { + if (!claimedAt) return true + + const [scheduleRecord] = await db + .select({ lastQueuedAt: workflowSchedule.lastQueuedAt }) + .from(workflowSchedule) + .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) + .limit(1) + + return scheduleRecord?.lastQueuedAt?.getTime() === claimedAt.getTime() +} + async function runWorkflowExecution({ payload, correlation, @@ -188,6 +338,7 @@ async function runWorkflowExecution({ executionId: string asyncTimeout?: number }): Promise { + let workflowCoreStarted = false try { const deployedData = await loadDeployedWorkflowState( payload.workflowId, @@ -287,6 +438,24 @@ async function runWorkflowExecution({ } } + const claimedAt = getScheduleClaimedAt(payload) + if (!(await isScheduleClaimCurrent(payload.scheduleId, claimedAt))) { + logger.info( + `[${requestId}] Schedule claim changed before workflow core started, skipping`, + { + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + claimedAt: claimedAt?.toISOString(), + } + ) + return { + status: 'skip', + reason: 'stale_claim', + blocks: {} as Record, + } + } + + workflowCoreStarted = true executionResult = await executeWorkflowCore({ snapshot, callbacks: {}, @@ -331,6 +500,20 @@ async function runWorkflowExecution({ return { status: 'failure', blocks, executionResult } } catch (error: unknown) { + if (!workflowCoreStarted && isRetryableInfrastructureError(error)) { + const cause = describeRetryableInfrastructureError(error) + logger.warn(`[${requestId}] Retryable setup failure before scheduled workflow started`, { + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + cause, + }) + return { + status: 'retryable_setup_failure', + error, + cause, + } + } + logger.error(`[${requestId}] Early failure in scheduled workflow ${payload.workflowId}`, error) if (wasExecutionFinalizedByCore(error, executionId)) { @@ -364,8 +547,10 @@ export type ScheduleExecutionPayload = { blockId?: string deploymentVersionId?: string cronExpression?: string + timezone?: string lastRanAt?: string failedCount?: number + infraRetryCount?: number now: string scheduledFor?: string } @@ -399,7 +584,8 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { const correlation = buildScheduleCorrelation(payload) const executionId = correlation.executionId const requestId = correlation.requestId - const now = new Date(payload.now) + const claimedAt = getScheduleClaimedAt(payload) + const now = new Date() const scheduledFor = payload.scheduledFor ? new Date(payload.scheduledFor) : null return runWithRequestContext({ requestId }, async () => { @@ -407,8 +593,27 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { scheduleId: payload.scheduleId, workflowId: payload.workflowId, executionId, + scheduledFor: scheduledFor?.toISOString(), + claimedAt: claimedAt?.toISOString(), }) + const releaseClaim = ( + releaseNow: Date, + context: string, + nextRunAt?: Date | null + ): Promise => + releaseScheduleLock(payload.scheduleId, requestId, releaseNow, context, nextRunAt, { + expectedLastQueuedAt: claimedAt, + }) + + const updateClaimedSchedule = ( + updates: WorkflowScheduleUpdate, + context: string + ): Promise => + applyScheduleUpdate(payload.scheduleId, updates, requestId, context, { + expectedLastQueuedAt: claimedAt, + }) + try { const [scheduleRecord] = await db .select({ @@ -417,6 +622,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { deploymentVersionId: workflowSchedule.deploymentVersionId, status: workflowSchedule.status, archivedAt: workflowSchedule.archivedAt, + lastQueuedAt: workflowSchedule.lastQueuedAt, }) .from(workflowSchedule) .where(eq(workflowSchedule.id, payload.scheduleId)) @@ -429,13 +635,24 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { return } + if ( + claimedAt && + (!scheduleRecord.lastQueuedAt || + scheduleRecord.lastQueuedAt.getTime() !== claimedAt.getTime()) + ) { + logger.info(`[${requestId}] Schedule claim no longer matches payload, skipping execution`, { + scheduleId: payload.scheduleId, + claimedAt: claimedAt.toISOString(), + currentLastQueuedAt: scheduleRecord.lastQueuedAt?.toISOString(), + }) + return + } + if (scheduleRecord.archivedAt || scheduleRecord.status === 'disabled') { logger.info(`[${requestId}] Schedule is archived or disabled, skipping execution`, { scheduleId: payload.scheduleId, }) - await releaseScheduleLock( - payload.scheduleId, - requestId, + await releaseClaim( now, `Failed to release schedule ${payload.scheduleId} after archive/disabled check` ) @@ -463,9 +680,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { workflowId: payload.workflowId, deploymentVersionId: expectedDeploymentVersionId, }) - await releaseScheduleLock( - payload.scheduleId, - requestId, + await releaseClaim( now, `Failed to release stale deployment schedule ${payload.scheduleId}` ) @@ -500,15 +715,14 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { logger.warn( `[${requestId}] Authentication error during preprocessing, disabling schedule` ) - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, lastQueuedAt: null, lastFailedAt: now, status: 'disabled', + ...resetScheduleInfraRetryCount(), }, - requestId, `Failed to disable schedule ${payload.scheduleId} after authentication error` ) return @@ -518,15 +732,14 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { logger.warn( `[${requestId}] Authorization error during preprocessing, disabling schedule: ${preprocessResult.error?.message}` ) - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, lastQueuedAt: null, lastFailedAt: now, status: 'disabled', + ...resetScheduleInfraRetryCount(), }, - requestId, `Failed to disable schedule ${payload.scheduleId} after authorization error` ) return @@ -534,14 +747,13 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { case 404: { logger.warn(`[${requestId}] Workflow not found, disabling schedule`) - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, lastQueuedAt: null, status: 'disabled', + ...resetScheduleInfraRetryCount(), }, - requestId, `Failed to disable schedule ${payload.scheduleId} after missing workflow` ) return @@ -552,14 +764,13 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { const retryDelay = 5 * 60 * 1000 const nextRetryAt = new Date(now.getTime() + retryDelay) - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, nextRunAt: nextRetryAt, lastQueuedAt: null, + ...resetScheduleInfraRetryCount(), }, - requestId, `Error updating schedule ${payload.scheduleId} for rate limit` ) return @@ -570,42 +781,43 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { const nextRunAt = (await calculateNextRunFromDeployment(payload, requestId)) ?? new Date(now.getTime() + 60 * 60 * 1000) - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, lastQueuedAt: null, nextRunAt, + ...resetScheduleInfraRetryCount(), }, - requestId, `Error updating schedule ${payload.scheduleId} after usage limit check` ) return } default: { + if (statusCode >= 500 && preprocessResult.error?.retryable) { + await retryScheduleAfterInfraFailure({ + payload, + requestId, + claimedAt, + message: preprocessResult.error.message, + cause: preprocessResult.error.cause, + }) + return + } + logger.error(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) const nextRunAt = await determineNextRunAfterError(payload, now, requestId) - const newFailedCount = (payload.failedCount || 0) + 1 - const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES - if (shouldDisable) { - logger.warn( - `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` - ) - } - - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, lastQueuedAt: null, nextRunAt, - failedCount: newFailedCount, + failedCount: incrementScheduleFailedCount(), lastFailedAt: now, - status: shouldDisable ? 'disabled' : 'active', + status: scheduleStatusAfterFailedCountIncrement(), + ...resetScheduleInfraRetryCount(), }, - requestId, `Error updating schedule ${payload.scheduleId} after preprocessing failure` ) return @@ -616,9 +828,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { const { actorUserId, workflowRecord } = preprocessResult if (!actorUserId || !workflowRecord) { logger.error(`[${requestId}] Missing required preprocessing data`) - await releaseScheduleLock( - payload.scheduleId, - requestId, + await releaseClaim( now, `Failed to release schedule ${payload.scheduleId} after missing preprocessing data` ) @@ -643,27 +853,38 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { asyncTimeout: preprocessResult.executionTimeout?.async, }) + if (executionResult.status === 'retryable_setup_failure') { + await retryScheduleAfterInfraFailure({ + payload, + requestId, + claimedAt, + error: executionResult.error, + cause: executionResult.cause, + }) + return + } + if (executionResult.status === 'skip') { if (executionResult.reason === 'stale_deployment') { - await releaseScheduleLock( - payload.scheduleId, - requestId, + await releaseClaim( now, `Failed to release stale schedule ${payload.scheduleId} after deployment version changed` ) return } + if (executionResult.reason === 'stale_claim') { + return + } - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, lastQueuedAt: null, lastFailedAt: now, status: 'disabled', nextRunAt: null, + ...resetScheduleInfraRetryCount(), }, - requestId, `Failed to disable schedule ${payload.scheduleId} after skip` ) return @@ -674,16 +895,15 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { lastRanAt: now, updatedAt: now, nextRunAt, failedCount: 0, lastQueuedAt: null, + ...resetScheduleInfraRetryCount(), }, - requestId, `Error updating schedule ${payload.scheduleId} after success` ) return @@ -691,85 +911,49 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { logger.warn(`[${requestId}] Workflow ${payload.workflowId} execution failed`) - const newFailedCount = (payload.failedCount || 0) + 1 - const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES - if (shouldDisable) { - logger.warn( - `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` - ) - } - const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, lastQueuedAt: null, nextRunAt, - failedCount: newFailedCount, + failedCount: incrementScheduleFailedCount(), lastFailedAt: now, - status: shouldDisable ? 'disabled' : 'active', + status: scheduleStatusAfterFailedCountIncrement(), + ...resetScheduleInfraRetryCount(), }, - requestId, `Error updating schedule ${payload.scheduleId} after failure` ) } catch (error: unknown) { - const errorMessage = toError(error).message - - if (errorMessage.includes('Service overloaded')) { - logger.warn(`[${requestId}] Service overloaded, retrying schedule in 5 minutes`) - - const retryDelay = 5 * 60 * 1000 - const nextRetryAt = new Date(now.getTime() + retryDelay) - - await applyScheduleUpdate( - payload.scheduleId, - { - updatedAt: now, - lastQueuedAt: null, - nextRunAt: nextRetryAt, - }, - requestId, - `Error updating schedule ${payload.scheduleId} for service overload` - ) - return - } - logger.error( `[${requestId}] Error executing scheduled workflow ${payload.workflowId}`, error ) const nextRunAt = await determineNextRunAfterError(payload, now, requestId) - const newFailedCount = (payload.failedCount || 0) + 1 - const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES - if (shouldDisable) { - logger.warn( - `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` - ) - } - - await applyScheduleUpdate( - payload.scheduleId, + await updateClaimedSchedule( { updatedAt: now, lastQueuedAt: null, nextRunAt, - failedCount: newFailedCount, + failedCount: incrementScheduleFailedCount(), lastFailedAt: now, - status: shouldDisable ? 'disabled' : 'active', + status: scheduleStatusAfterFailedCountIncrement(), + ...resetScheduleInfraRetryCount(), }, - requestId, `Error updating schedule ${payload.scheduleId} after execution error` ) } } catch (error: unknown) { + if (isRetryableInfrastructureError(error)) { + await retryScheduleAfterInfraFailure({ payload, requestId, claimedAt, error }) + return + } + logger.error(`[${requestId}] Error processing schedule ${payload.scheduleId}`, error) - await releaseScheduleLock( - payload.scheduleId, - requestId, + await releaseClaim( now, `Failed to release schedule ${payload.scheduleId} after unhandled error` ) @@ -980,11 +1164,22 @@ export async function executeJobInline(payload: JobExecutionPayload) { payload.scheduleId, requestId, now, - `Failed to release job ${payload.scheduleId} after missing fields` + `Failed to release job ${payload.scheduleId} after missing fields`, + undefined, + { expectedLastQueuedAt: now } ) return } + if (!jobRecord.lastQueuedAt || jobRecord.lastQueuedAt.getTime() !== now.getTime()) { + logger.info(`[${requestId}] Job claim no longer matches payload, skipping execution`, { + scheduleId: payload.scheduleId, + claimedAt: now.toISOString(), + currentLastQueuedAt: jobRecord.lastQueuedAt?.toISOString(), + }) + return + } + const activeWorkspace = await getWorkspaceById(jobRecord.sourceWorkspaceId) if (!activeWorkspace || jobRecord.status === 'disabled') { logger.info(`[${requestId}] Job is archived, disabled, or workspace is inactive`, { @@ -994,7 +1189,9 @@ export async function executeJobInline(payload: JobExecutionPayload) { payload.scheduleId, requestId, now, - `Failed to release job ${payload.scheduleId} after archive/disabled check` + `Failed to release job ${payload.scheduleId} after archive/disabled check`, + undefined, + { expectedLastQueuedAt: now } ) return } @@ -1007,7 +1204,9 @@ export async function executeJobInline(payload: JobExecutionPayload) { payload.scheduleId, requestId, now, - `Failed to release job ${payload.scheduleId} after completed skip` + `Failed to release job ${payload.scheduleId} after completed skip`, + undefined, + { expectedLastQueuedAt: now } ) return } @@ -1105,7 +1304,8 @@ export async function executeJobInline(payload: JobExecutionPayload) { lastQueuedAt: null, }, requestId, - `Error updating job ${payload.scheduleId} after completion` + `Error updating job ${payload.scheduleId} after completion`, + { expectedLastQueuedAt: now } ) return } @@ -1142,7 +1342,8 @@ export async function executeJobInline(payload: JobExecutionPayload) { status: isOneTime || maxRunsReached ? 'completed' : 'active', }, requestId, - `Error updating job ${payload.scheduleId} after success` + `Error updating job ${payload.scheduleId} after success`, + { expectedLastQueuedAt: now } ) } finally { timeoutController.cleanup() @@ -1178,16 +1379,23 @@ export async function executeJobInline(payload: JobExecutionPayload) { status: shouldDisable ? 'disabled' : 'active', }, requestId, - `Error updating job ${payload.scheduleId} after failure` + `Error updating job ${payload.scheduleId} after failure`, + { expectedLastQueuedAt: now } ) } } -export const scheduleExecution = task({ +export const scheduleExecutionTaskOptions = { id: 'schedule-execution', - machine: 'medium-1x', + machine: 'medium-1x' as const, retry: { maxAttempts: 1, }, + queue: { + name: SCHEDULE_EXECUTION_QUEUE_NAME, + concurrencyLimit: SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + }, run: async (payload: ScheduleExecutionPayload) => executeScheduleJob(payload), -}) +} + +export const scheduleExecution = task(scheduleExecutionTaskOptions) diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 5e9a1d301de..98490e32281 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -70,6 +70,8 @@ export async function executeWorkflowGroupCellJob( ...currentPayload, groupId: next.id, workflowId: next.workflowId, + // Re-derive so a workflow group after an enrichment group doesn't keep a stale enrichmentId. + enrichmentId: next.enrichmentId, executionId: generateId(), } } diff --git a/apps/sim/blocks/blocks/agentmail.ts b/apps/sim/blocks/blocks/agentmail.ts index cecf84f3907..83f1c6d1cf1 100644 --- a/apps/sim/blocks/blocks/agentmail.ts +++ b/apps/sim/blocks/blocks/agentmail.ts @@ -193,9 +193,9 @@ export const AgentMailBlock: BlockConfig = { }, { id: 'replyTo', - title: 'Override To', + title: 'Override Recipients', type: 'short-input', - placeholder: 'Override recipient (optional)', + placeholder: 'Override recipient (comma-separated)', condition: { field: 'operation', value: 'reply_message' }, mode: 'advanced', }, @@ -349,6 +349,7 @@ export const AgentMailBlock: BlockConfig = { type: 'short-input', placeholder: 'Optional username for email address', condition: { field: 'operation', value: 'create_inbox' }, + mode: 'advanced', }, { id: 'domain', @@ -564,7 +565,7 @@ export const AgentMailBlock: BlockConfig = { cc: { type: 'string', description: 'CC email addresses' }, bcc: { type: 'string', description: 'BCC email addresses' }, replyMessageId: { type: 'string', description: 'Message ID to reply to' }, - replyTo: { type: 'string', description: 'Override recipient for reply' }, + replyTo: { type: 'string', description: 'Override recipients for reply (comma-separated)' }, replyAll: { type: 'string', description: 'Reply to all recipients' }, forwardMessageId: { type: 'string', description: 'Message ID to forward' }, updateMessageId: { type: 'string', description: 'Message ID to update labels on' }, @@ -603,7 +604,8 @@ export const AgentMailBlock: BlockConfig = { preview: { type: 'string', description: 'Message or draft preview text' }, senders: { type: 'json', description: 'List of sender email addresses' }, recipients: { type: 'json', description: 'List of recipient email addresses' }, - labels: { type: 'json', description: 'Thread or draft labels' }, + labels: { type: 'json', description: 'Thread, message, or draft labels' }, + timestamp: { type: 'string', description: 'Time the message was sent or drafted' }, messages: { type: 'json', description: 'List of messages' }, threads: { type: 'json', description: 'List of threads' }, inboxes: { type: 'json', description: 'List of inboxes' }, diff --git a/apps/sim/blocks/blocks/agentphone.ts b/apps/sim/blocks/blocks/agentphone.ts index c693ffbec78..fa1ab6efdb3 100644 --- a/apps/sim/blocks/blocks/agentphone.ts +++ b/apps/sim/blocks/blocks/agentphone.ts @@ -34,7 +34,7 @@ export const AgentPhoneBlock: BlockConfig = { category: 'tools', integrationType: IntegrationType.Communication, tags: ['messaging', 'automation'], - bgColor: 'linear-gradient(135deg, #1a1a1a 0%, #0a2a14 100%)', + bgColor: '#000000', icon: AgentPhoneIcon, authMode: AuthMode.ApiKey, @@ -700,6 +700,7 @@ export const AgentPhoneBlock: BlockConfig = { from_: { type: 'string', description: 'Sender phone number on a number message' }, body: { type: 'string', description: 'Message body text' }, mediaUrl: { type: 'string', description: 'Attached media URL' }, + mediaUrls: { type: 'json', description: 'Attached media URLs (array)' }, receivedAt: { type: 'string', description: 'ISO 8601 timestamp' }, participant: { type: 'string', description: 'External participant phone number' }, lastMessageAt: { type: 'string', description: 'ISO 8601 timestamp' }, @@ -712,7 +713,7 @@ export const AgentPhoneBlock: BlockConfig = { messages: { type: 'json', description: - 'Conversation messages: [{id, body, fromNumber, toNumber, direction, channel, mediaUrl, receivedAt}]', + 'Conversation messages: [{id, body, fromNumber, toNumber, direction, channel, mediaUrl, mediaUrls, receivedAt}]', }, reactionType: { type: 'string', description: 'Reaction type applied' }, messageId: { type: 'string', description: 'Message ID' }, diff --git a/apps/sim/blocks/blocks/instantly.ts b/apps/sim/blocks/blocks/instantly.ts new file mode 100644 index 00000000000..76c8d0872c6 --- /dev/null +++ b/apps/sim/blocks/blocks/instantly.ts @@ -0,0 +1,1255 @@ +import { InstantlyIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { InstantlyResponse } from '@/tools/instantly/types' +import { getTrigger } from '@/triggers' + +const LEAD_LIST_OPERATIONS = ['list_leads'] as const +const LEAD_ID_OPERATIONS = ['get_lead'] as const +const LEAD_CREATE_OPERATIONS = ['create_lead'] as const +const LEAD_DELETE_OPERATIONS = ['delete_leads'] as const +const LEAD_INTEREST_OPERATIONS = ['update_lead_interest_status'] as const +const CAMPAIGN_LIST_OPERATIONS = ['list_campaigns'] as const +const CAMPAIGN_MUTATION_OPERATIONS = ['create_campaign', 'patch_campaign'] as const +const CAMPAIGN_ID_OPERATIONS = ['patch_campaign', 'activate_campaign'] as const +const EMAIL_LIST_OPERATIONS = ['list_emails'] as const +const EMAIL_REPLY_OPERATIONS = ['reply_to_email'] as const +const LEAD_LIST_LIST_OPERATIONS = ['list_lead_lists'] as const +const LEAD_LIST_CREATE_OPERATIONS = ['create_lead_list'] as const +const PAGINATED_OPERATIONS = [ + 'list_leads', + 'list_campaigns', + 'list_emails', + 'list_lead_lists', +] as const +const INSTANTLY_TRIGGER_IDS = [ + 'instantly_webhook', + 'instantly_email_sent', + 'instantly_email_opened', + 'instantly_reply_received', + 'instantly_auto_reply_received', + 'instantly_link_clicked', + 'instantly_email_bounced', + 'instantly_lead_unsubscribed', + 'instantly_account_error', + 'instantly_campaign_completed', + 'instantly_lead_neutral', + 'instantly_lead_interested', + 'instantly_lead_not_interested', + 'instantly_lead_meeting_booked', + 'instantly_lead_meeting_completed', + 'instantly_lead_closed', + 'instantly_lead_out_of_office', + 'instantly_lead_wrong_person', + 'instantly_lead_no_show', + 'instantly_supersearch_enrichment_completed', +] as const + +export const InstantlyBlock: BlockConfig = { + type: 'instantly', + name: 'Instantly', + description: 'Manage Instantly leads, campaigns, emails, and lead lists', + longDescription: + 'Integrate Instantly API V2 into workflows. Create and list leads, manage lead interest status, delete leads in bulk, list and create campaigns, reply to emails, and manage lead lists.', + docsLink: 'https://docs.sim.ai/tools/instantly', + category: 'tools', + integrationType: IntegrationType.Email, + tags: ['sales-engagement', 'email-marketing', 'automation'], + bgColor: '#FFFFFF', + icon: InstantlyIcon, + authMode: AuthMode.ApiKey, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Leads', id: 'list_leads' }, + { label: 'Get Lead', id: 'get_lead' }, + { label: 'Create Lead', id: 'create_lead' }, + { label: 'Delete Leads', id: 'delete_leads' }, + { label: 'Update Lead Interest Status', id: 'update_lead_interest_status' }, + { label: 'List Campaigns', id: 'list_campaigns' }, + { label: 'Create Campaign', id: 'create_campaign' }, + { label: 'Patch Campaign', id: 'patch_campaign' }, + { label: 'Activate Campaign', id: 'activate_campaign' }, + { label: 'List Emails', id: 'list_emails' }, + { label: 'Reply To Email', id: 'reply_to_email' }, + { label: 'List Lead Lists', id: 'list_lead_lists' }, + { label: 'Create Lead List', id: 'create_lead_list' }, + ], + value: () => 'list_leads', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Instantly API key', + password: true, + required: true, + paramVisibility: 'user-only', + }, + { + id: 'leadId', + title: 'Lead ID', + type: 'short-input', + placeholder: '019e3bd1-b5d9-7b0a-9823-d5382bc9d72b', + required: { field: 'operation', value: [...LEAD_ID_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_ID_OPERATIONS] }, + }, + { + id: 'leadDestination', + title: 'Add Lead To', + type: 'dropdown', + options: [ + { label: 'Campaign', id: 'campaign' }, + { label: 'Lead List', id: 'list' }, + ], + value: () => 'campaign', + required: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + }, + { + id: 'leadDestinationId', + title: 'Campaign or Lead List ID', + type: 'short-input', + placeholder: 'Destination UUID', + required: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + }, + { + id: 'email', + title: 'Lead Email', + type: 'short-input', + placeholder: 'jane@example.com', + required: { + field: 'operation', + value: [...LEAD_CREATE_OPERATIONS], + and: { field: 'leadDestination', value: 'campaign' }, + }, + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + }, + { + id: 'firstName', + title: 'First Name', + type: 'short-input', + placeholder: 'Jane', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + }, + { + id: 'lastName', + title: 'Last Name', + type: 'short-input', + placeholder: 'Doe', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + }, + { + id: 'companyName', + title: 'Company Name', + type: 'short-input', + placeholder: 'Acme Inc.', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + }, + { + id: 'jobTitle', + title: 'Job Title', + type: 'short-input', + placeholder: 'Head of Growth', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'phone', + title: 'Phone', + type: 'short-input', + placeholder: '+1234567890', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'website', + title: 'Website', + type: 'short-input', + placeholder: 'https://example.com', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'personalization', + title: 'Personalization', + type: 'long-input', + placeholder: 'Personalized opening line', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'customVariables', + title: 'Custom Variables', + type: 'long-input', + placeholder: '{"past_customer": true, "industry": "SaaS"}', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON object of Instantly custom variables. Values must be strings, numbers, booleans, or null. Return ONLY the JSON object - no explanations, no extra text.', + generationType: 'json-object', + }, + mode: 'advanced', + }, + { + id: 'skipIfInWorkspace', + title: 'Skip If In Workspace', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'skipIfInCampaign', + title: 'Skip If In Campaign', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'skipIfInList', + title: 'Skip If In List', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'verifyLeadsForLeadFinder', + title: 'Verify Leads For Lead Finder', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'verifyLeadsOnImport', + title: 'Verify Leads On Import', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'leadInterestStatus', + title: 'Lead Interest Status', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'potentialLeadValue', + title: 'Potential Lead Value', + type: 'short-input', + placeholder: 'High', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'assignedTo', + title: 'Assigned To', + type: 'short-input', + placeholder: 'Organization user UUID', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'blocklistId', + title: 'Blocklist ID', + type: 'short-input', + placeholder: 'Blocklist UUID', + condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'search', + title: 'Search', + type: 'short-input', + placeholder: 'Search term', + condition: { + field: 'operation', + value: [...LEAD_LIST_OPERATIONS, ...CAMPAIGN_LIST_OPERATIONS, ...LEAD_LIST_LIST_OPERATIONS], + }, + }, + { + id: 'leadFilter', + title: 'Lead Filter', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Contacted', id: 'FILTER_VAL_CONTACTED' }, + { label: 'Not Contacted', id: 'FILTER_VAL_NOT_CONTACTED' }, + { label: 'Completed', id: 'FILTER_VAL_COMPLETED' }, + { label: 'Unsubscribed', id: 'FILTER_VAL_UNSUBSCRIBED' }, + { label: 'Active', id: 'FILTER_VAL_ACTIVE' }, + { label: 'Interested', id: 'FILTER_LEAD_INTERESTED' }, + { label: 'Not Interested', id: 'FILTER_LEAD_NOT_INTERESTED' }, + { label: 'Meeting Booked', id: 'FILTER_LEAD_MEETING_BOOKED' }, + { label: 'Replied', id: 'FILTER_VAL_REPLIED' }, + { label: 'Link Clicked', id: 'FILTER_VAL_LINK_CLICKED' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'campaignId', + title: 'Campaign ID', + type: 'short-input', + placeholder: 'Campaign UUID', + required: { field: 'operation', value: [...CAMPAIGN_ID_OPERATIONS] }, + condition: { + field: 'operation', + value: [ + ...LEAD_LIST_OPERATIONS, + ...LEAD_INTEREST_OPERATIONS, + ...CAMPAIGN_ID_OPERATIONS, + ...EMAIL_LIST_OPERATIONS, + ], + }, + }, + { + id: 'listId', + title: 'Lead List ID', + type: 'short-input', + placeholder: 'Lead list UUID', + condition: { + field: 'operation', + value: [...LEAD_LIST_OPERATIONS, ...LEAD_INTEREST_OPERATIONS, ...EMAIL_LIST_OPERATIONS], + }, + }, + { + id: 'leadIds', + title: 'Lead IDs', + type: 'long-input', + placeholder: 'lead-id-1, lead-id-2', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'excludedLeadIds', + title: 'Excluded Lead IDs', + type: 'long-input', + placeholder: 'lead-id-1, lead-id-2', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'contacts', + title: 'Contacts', + type: 'long-input', + placeholder: 'jane@example.com, john@example.com', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'inCampaign', + title: 'In Campaign', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'inList', + title: 'In List', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'organizationUserIds', + title: 'Organization User IDs', + type: 'long-input', + placeholder: 'user-id-1, user-id-2', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'smartViewId', + title: 'Smart View ID', + type: 'short-input', + placeholder: 'Smart view UUID', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'websiteVisitor', + title: 'Website Visitor', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'distinctContacts', + title: 'Distinct Contacts', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'enrichmentStatus', + title: 'Enrichment Status', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'esgCode', + title: 'ESG Code', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: [...LEAD_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'deleteSource', + title: 'Delete From', + type: 'dropdown', + options: [ + { label: 'Campaign', id: 'campaign' }, + { label: 'Lead List', id: 'list' }, + ], + value: () => 'campaign', + required: { field: 'operation', value: [...LEAD_DELETE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_DELETE_OPERATIONS] }, + }, + { + id: 'deleteSourceId', + title: 'Campaign or Lead List ID', + type: 'short-input', + placeholder: 'Source UUID', + required: { field: 'operation', value: [...LEAD_DELETE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_DELETE_OPERATIONS] }, + }, + { + id: 'deleteStatus', + title: 'Delete Status Filter', + type: 'short-input', + placeholder: '3', + condition: { field: 'operation', value: [...LEAD_DELETE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'deleteLeadIds', + title: 'Delete Lead IDs', + type: 'long-input', + placeholder: 'lead-id-1, lead-id-2', + condition: { field: 'operation', value: [...LEAD_DELETE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'deleteLimit', + title: 'Delete Limit', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: [...LEAD_DELETE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'leadEmail', + title: 'Lead Email', + type: 'short-input', + placeholder: 'jane@example.com', + required: { field: 'operation', value: [...LEAD_INTEREST_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_INTEREST_OPERATIONS] }, + }, + { + id: 'interestValue', + title: 'Interest Value', + type: 'short-input', + placeholder: '1 or null', + condition: { field: 'operation', value: [...LEAD_INTEREST_OPERATIONS] }, + }, + { + id: 'disableAutoInterest', + title: 'Disable Auto Interest', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...LEAD_INTEREST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'aiInterestValue', + title: 'AI Interest Value', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: [...LEAD_INTEREST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'campaignName', + title: 'Campaign Name', + type: 'short-input', + placeholder: 'My First Campaign', + required: { field: 'operation', value: 'create_campaign' }, + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + }, + { + id: 'campaignSchedule', + title: 'Campaign Schedule', + type: 'long-input', + placeholder: + '{"schedules":[{"name":"Weekdays","timing":{"from":"09:00","to":"17:00"},"days":{"1":true,"2":true,"3":true,"4":true,"5":true},"timezone":"America/Los_Angeles"}]}', + required: { field: 'operation', value: 'create_campaign' }, + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + wandConfig: { + enabled: true, + prompt: + 'Generate an Instantly API V2 campaign_schedule JSON object with schedules containing name, timing.from, timing.to, days, and timezone. Return ONLY the JSON object - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'sequences', + title: 'Sequences', + type: 'long-input', + placeholder: + '[{"steps":[{"type":"email","delay":2,"variants":[{"subject":"Hello {{firstName}}","body":"Hey {{firstName}},\\n\\nI hope you are doing well."}]}]}]', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + wandConfig: { + enabled: true, + prompt: + 'Generate an Instantly API V2 sequences JSON array. Use one sequence with steps; each step must have type "email", delay, and variants with subject and body. Return ONLY the JSON array - no explanations, no extra text.', + generationType: 'json-object', + }, + mode: 'advanced', + }, + { + id: 'emailList', + title: 'Sending Accounts', + type: 'long-input', + placeholder: 'sender@example.com, sender2@example.com', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'dailyLimit', + title: 'Daily Limit', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'dailyMaxLeads', + title: 'Daily Max Leads', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'openTracking', + title: 'Open Tracking', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'stopOnReply', + title: 'Stop On Reply', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'positiveLeadValue', + title: 'Positive Lead Value', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'emailGap', + title: 'Email Gap', + type: 'short-input', + placeholder: '10', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'linkTracking', + title: 'Link Tracking', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'textOnly', + title: 'Text Only', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...CAMPAIGN_MUTATION_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'tagIds', + title: 'Tag IDs', + type: 'short-input', + placeholder: 'id1,id2', + condition: { field: 'operation', value: [...CAMPAIGN_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'aiSalesAgentId', + title: 'AI Sales Agent ID', + type: 'short-input', + placeholder: 'AI Sales Agent UUID', + condition: { field: 'operation', value: [...CAMPAIGN_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'campaignStatus', + title: 'Campaign Status', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: [...CAMPAIGN_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'emailAccount', + title: 'Email Account', + type: 'short-input', + placeholder: 'sender@example.com', + condition: { + field: 'operation', + value: [...EMAIL_LIST_OPERATIONS, ...EMAIL_REPLY_OPERATIONS], + }, + required: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + }, + { + id: 'emailSearch', + title: 'Email Search', + type: 'short-input', + placeholder: 'lead@example.com or thread:uuid', + condition: { field: 'operation', value: [...EMAIL_LIST_OPERATIONS] }, + }, + { + id: 'emailStatus', + title: 'Email Status', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: [...EMAIL_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'emailLead', + title: 'Lead Email Filter', + type: 'short-input', + placeholder: 'lead@example.com', + condition: { field: 'operation', value: [...EMAIL_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'emailIsUnread', + title: 'Unread', + type: 'dropdown', + options: [ + { label: 'Any', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: [...EMAIL_LIST_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'replyToUuid', + title: 'Reply To Email ID', + type: 'short-input', + placeholder: 'Email UUID', + required: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + condition: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + }, + { + id: 'subject', + title: 'Subject', + type: 'short-input', + placeholder: 'Re: Your inquiry', + required: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + condition: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + }, + { + id: 'bodyText', + title: 'Body Text', + type: 'long-input', + placeholder: 'Plain text reply body', + required: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + condition: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + }, + { + id: 'bodyHtml', + title: 'Body HTML', + type: 'long-input', + placeholder: '

HTML reply body

', + condition: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'ccRecipients', + title: 'CC Recipients', + type: 'short-input', + placeholder: 'cc@example.com', + condition: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'bccRecipients', + title: 'BCC Recipients', + type: 'short-input', + placeholder: 'bcc@example.com', + condition: { field: 'operation', value: [...EMAIL_REPLY_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'leadListName', + title: 'Lead List Name', + type: 'short-input', + placeholder: 'My Lead List', + required: { field: 'operation', value: [...LEAD_LIST_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_LIST_CREATE_OPERATIONS] }, + }, + { + id: 'hasEnrichmentTask', + title: 'Has Enrichment Task', + type: 'dropdown', + options: [ + { label: 'Unspecified', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => '', + condition: { + field: 'operation', + value: [...LEAD_LIST_LIST_OPERATIONS, ...LEAD_LIST_CREATE_OPERATIONS], + }, + mode: 'advanced', + }, + { + id: 'ownedBy', + title: 'Owned By', + type: 'short-input', + placeholder: 'User UUID', + condition: { field: 'operation', value: [...LEAD_LIST_CREATE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '10', + condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'startingAfter', + title: 'Starting After', + type: 'short-input', + placeholder: 'Cursor from next_starting_after', + condition: { field: 'operation', value: [...PAGINATED_OPERATIONS] }, + mode: 'advanced', + }, + ...INSTANTLY_TRIGGER_IDS.flatMap((triggerId) => getTrigger(triggerId).subBlocks), + ], + tools: { + access: [ + 'instantly_list_leads', + 'instantly_get_lead', + 'instantly_create_lead', + 'instantly_delete_leads', + 'instantly_update_lead_interest_status', + 'instantly_list_campaigns', + 'instantly_create_campaign', + 'instantly_patch_campaign', + 'instantly_activate_campaign', + 'instantly_list_emails', + 'instantly_reply_to_email', + 'instantly_list_lead_lists', + 'instantly_create_lead_list', + ], + config: { + tool: (params) => `instantly_${params.operation}`, + params: (params) => ({ + campaign: mapCampaignParam(params), + list_id: mapListIdParam(params), + campaign_id: mapCampaignIdParam(params), + leadId: params.leadId, + email: emptyToUndefined(params.email), + first_name: emptyToUndefined(params.firstName), + last_name: emptyToUndefined(params.lastName), + company_name: emptyToUndefined(params.companyName), + job_title: emptyToUndefined(params.jobTitle), + phone: emptyToUndefined(params.phone), + website: emptyToUndefined(params.website), + personalization: emptyToUndefined(params.personalization), + custom_variables: parseJsonObject(params.customVariables), + lt_interest_status: toNumberParam(params.leadInterestStatus), + pl_value_lead: emptyToUndefined(params.potentialLeadValue), + assigned_to: optionalIdParam(params.assignedTo), + blocklist_id: optionalIdParam(params.blocklistId), + skip_if_in_workspace: toBooleanParam(params.skipIfInWorkspace), + skip_if_in_campaign: toBooleanParam(params.skipIfInCampaign), + skip_if_in_list: toBooleanParam(params.skipIfInList), + verify_leads_for_lead_finder: toBooleanParam(params.verifyLeadsForLeadFinder), + verify_leads_on_import: toBooleanParam(params.verifyLeadsOnImport), + filter: emptyToUndefined(params.leadFilter), + ids: mapIdsParam(params), + excluded_ids: parseStringList(params.excludedLeadIds), + contacts: parseStringList(params.contacts), + organization_user_ids: parseStringList(params.organizationUserIds), + smart_view_id: optionalIdParam(params.smartViewId), + is_website_visitor: toBooleanParam(params.websiteVisitor), + distinct_contacts: toBooleanParam(params.distinctContacts), + enrichment_status: toNumberParam(params.enrichmentStatus), + esg_code: emptyToUndefined(params.esgCode), + in_campaign: toBooleanParam(params.inCampaign), + in_list: toBooleanParam(params.inList), + status: mapStatusParam(params), + limit: mapLimitParam(params), + starting_after: mapStartingAfterParam(params), + lead_email: emptyToUndefined(params.leadEmail), + interest_value: + params.operation === 'update_lead_interest_status' + ? toNullableNumberParam(params.interestValue, true) + : undefined, + ai_interest_value: toNumberParam(params.aiInterestValue), + disable_auto_interest: toBooleanParam(params.disableAutoInterest), + name: mapNameParam(params), + campaign_schedule: parseJsonObject(params.campaignSchedule), + sequences: parseJsonArray(params.sequences), + email_list: parseStringList(params.emailList), + daily_limit: toNumberParam(params.dailyLimit), + daily_max_leads: toNumberParam(params.dailyMaxLeads), + open_tracking: toBooleanParam(params.openTracking), + stop_on_reply: toBooleanParam(params.stopOnReply), + pl_value: toNumberParam(params.positiveLeadValue), + email_gap: toNumberParam(params.emailGap), + link_tracking: toBooleanParam(params.linkTracking), + text_only: toBooleanParam(params.textOnly), + tag_ids: emptyToUndefined(params.tagIds), + ai_sales_agent_id: optionalIdParam(params.aiSalesAgentId), + search: mapSearchParam(params), + eaccount: emptyToUndefined(params.emailAccount), + i_status: toNumberParam(params.emailStatus), + lead: emptyToUndefined(params.emailLead), + is_unread: toBooleanParam(params.emailIsUnread), + reply_to_uuid: emptyToUndefined(params.replyToUuid), + subject: emptyToUndefined(params.subject), + body: { + text: emptyToUndefined(params.bodyText), + html: emptyToUndefined(params.bodyHtml), + }, + cc_address_email_list: emptyToUndefined(params.ccRecipients), + bcc_address_email_list: emptyToUndefined(params.bccRecipients), + has_enrichment_task: toBooleanParam(params.hasEnrichmentTask), + owned_by: optionalIdParam(params.ownedBy), + }), + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Instantly API key' }, + leadId: { type: 'string', description: 'Lead ID' }, + leadDestination: { type: 'string', description: 'Create lead destination type' }, + leadDestinationId: { type: 'string', description: 'Create lead destination ID' }, + email: { type: 'string', description: 'Lead email' }, + firstName: { type: 'string', description: 'Lead first name' }, + lastName: { type: 'string', description: 'Lead last name' }, + companyName: { type: 'string', description: 'Company name' }, + leadInterestStatus: { type: 'number', description: 'Lead interest status value' }, + potentialLeadValue: { type: 'string', description: 'Potential value of the lead' }, + assignedTo: { type: 'string', description: 'Organization user ID assigned to the lead' }, + blocklistId: { type: 'string', description: 'Blocklist ID' }, + verifyLeadsForLeadFinder: { + type: 'boolean', + description: 'Whether to verify leads imported from Lead Finder', + }, + verifyLeadsOnImport: { type: 'boolean', description: 'Whether to verify leads on import' }, + search: { type: 'string', description: 'Search query' }, + excludedLeadIds: { type: 'string', description: 'Lead IDs to exclude' }, + contacts: { type: 'string', description: 'Lead email addresses to include' }, + organizationUserIds: { type: 'string', description: 'Organization user IDs to filter leads' }, + smartViewId: { type: 'string', description: 'Smart view ID to filter leads' }, + websiteVisitor: { type: 'boolean', description: 'Whether the lead is a website visitor' }, + distinctContacts: { type: 'boolean', description: 'Whether to return distinct contacts' }, + enrichmentStatus: { type: 'number', description: 'Enrichment status filter' }, + esgCode: { type: 'string', description: 'Email security gateway code filter' }, + campaignId: { type: 'string', description: 'Campaign ID' }, + listId: { type: 'string', description: 'Lead list ID' }, + leadIds: { type: 'string', description: 'Lead IDs' }, + inCampaign: { type: 'boolean', description: 'Whether the lead is in a campaign' }, + inList: { type: 'boolean', description: 'Whether the lead is in a list' }, + deleteSource: { type: 'string', description: 'Delete source type' }, + deleteSourceId: { type: 'string', description: 'Delete source ID' }, + deleteStatus: { type: 'number', description: 'Delete status filter' }, + deleteLeadIds: { type: 'string', description: 'Lead IDs to delete' }, + deleteLimit: { type: 'number', description: 'Maximum number of leads to delete' }, + leadEmail: { type: 'string', description: 'Lead email for interest update' }, + interestValue: { type: 'number', description: 'Interest status value' }, + disableAutoInterest: { type: 'boolean', description: 'Whether to disable auto interest' }, + aiInterestValue: { type: 'number', description: 'AI interest value' }, + campaignName: { type: 'string', description: 'Campaign name' }, + campaignSchedule: { type: 'json', description: 'Campaign schedule object' }, + sequences: { type: 'array', description: 'Campaign sequences' }, + emailList: { type: 'string', description: 'Sending email accounts' }, + dailyLimit: { type: 'number', description: 'Daily sending limit' }, + dailyMaxLeads: { type: 'number', description: 'Daily maximum new leads' }, + openTracking: { type: 'boolean', description: 'Whether to track opens' }, + stopOnReply: { type: 'boolean', description: 'Whether to stop on replies' }, + positiveLeadValue: { type: 'number', description: 'Value of every positive lead' }, + emailGap: { type: 'number', description: 'Gap between emails in minutes' }, + linkTracking: { type: 'boolean', description: 'Whether to track links' }, + textOnly: { type: 'boolean', description: 'Whether the campaign is text only' }, + tagIds: { type: 'string', description: 'Campaign tag IDs' }, + aiSalesAgentId: { type: 'string', description: 'AI Sales Agent ID' }, + campaignStatus: { type: 'number', description: 'Campaign status filter' }, + emailAccount: { type: 'string', description: 'Email account' }, + emailSearch: { type: 'string', description: 'Email search query' }, + emailStatus: { type: 'number', description: 'Email interest status filter' }, + emailLead: { type: 'string', description: 'Lead email filter' }, + emailIsUnread: { type: 'boolean', description: 'Whether the email is unread' }, + replyToUuid: { type: 'string', description: 'Email ID to reply to' }, + subject: { type: 'string', description: 'Reply subject' }, + bodyText: { type: 'string', description: 'Reply body text' }, + bodyHtml: { type: 'string', description: 'Reply body HTML' }, + ccRecipients: { type: 'string', description: 'CC email recipients' }, + bccRecipients: { type: 'string', description: 'BCC email recipients' }, + leadListName: { type: 'string', description: 'Lead list name' }, + hasEnrichmentTask: { type: 'boolean', description: 'Whether the lead list has enrichment' }, + ownedBy: { type: 'string', description: 'Owner user ID' }, + limit: { type: 'number', description: 'Page size' }, + startingAfter: { type: 'string', description: 'Pagination cursor' }, + }, + outputs: { + leads: { + type: 'array', + description: 'List of leads (id, email, first_name, last_name, campaign, status)', + }, + lead: { + type: 'json', + description: + 'Lead details (id, email, first_name, last_name, company_name, job_title, campaign, status, payload)', + }, + campaigns: { type: 'array', description: 'List of campaigns (id, name, status, daily_limit)' }, + campaign: { + type: 'json', + description: + 'Campaign details (id, name, status, daily_limit, daily_max_leads, open_tracking)', + }, + emails: { + type: 'array', + description: 'List of emails (id, subject, from_address_email, lead, thread_id)', + }, + email: { + type: 'json', + description: + 'Email details (id, subject, from_address_email, to_address_email_list, thread_id, content_preview)', + }, + lead_lists: { + type: 'array', + description: 'List of lead lists (id, name, has_enrichment_task, timestamp_created)', + }, + lead_list: { + type: 'json', + description: + 'Lead list details (id, organization_id, has_enrichment_task, owned_by, name, timestamp_created)', + }, + count: { type: 'number', description: 'Returned or affected record count' }, + next_starting_after: { type: 'string', description: 'Cursor for the next page' }, + id: { type: 'string', description: 'Record ID' }, + name: { type: 'string', description: 'Record name' }, + email_address: { type: 'string', description: 'Lead email address' }, + first_name: { type: 'string', description: 'Lead first name' }, + last_name: { type: 'string', description: 'Lead last name' }, + status: { type: 'number', description: 'Lead or campaign status' }, + subject: { type: 'string', description: 'Email subject' }, + thread_id: { type: 'string', description: 'Email thread ID' }, + message: { type: 'string', description: 'Operation message' }, + }, + triggers: { + enabled: true, + available: [ + 'instantly_webhook', + 'instantly_email_sent', + 'instantly_email_opened', + 'instantly_reply_received', + 'instantly_auto_reply_received', + 'instantly_link_clicked', + 'instantly_email_bounced', + 'instantly_lead_unsubscribed', + 'instantly_account_error', + 'instantly_campaign_completed', + 'instantly_lead_neutral', + 'instantly_lead_interested', + 'instantly_lead_not_interested', + 'instantly_lead_meeting_booked', + 'instantly_lead_meeting_completed', + 'instantly_lead_closed', + 'instantly_lead_out_of_office', + 'instantly_lead_wrong_person', + 'instantly_lead_no_show', + 'instantly_supersearch_enrichment_completed', + ], + }, +} + +function parseStringList(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + const strings = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item !== '' && item !== '-') + return strings.length > 0 ? strings : undefined + } + + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + if (trimmed === '' || trimmed === '-') return undefined + + const strings = trimmed + .split(/[\s,]+/) + .map((item) => item.trim()) + .filter((item) => item !== '' && item !== '-') + + return strings.length > 0 ? strings : undefined +} + +function parseJsonObject(value: unknown): Record | undefined { + if (isPlainObject(value)) return value + if (typeof value !== 'string' || value.trim() === '') return undefined + + try { + const parsed: unknown = JSON.parse(value) + return isPlainObject(parsed) ? parsed : undefined + } catch { + return undefined + } +} + +function parseJsonArray(value: unknown): unknown[] | undefined { + if (Array.isArray(value)) return value + if (typeof value !== 'string' || value.trim() === '') return undefined + + try { + const parsed: unknown = JSON.parse(value) + return Array.isArray(parsed) ? parsed : undefined + } catch { + return undefined + } +} + +function toNumberParam(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined + if (typeof value !== 'string' || value.trim() === '') return undefined + + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +function toNullableNumberParam(value: unknown, emptyAsNull = false): number | null | undefined { + if (value === null) return null + if (emptyAsNull && value === undefined) return null + if (emptyAsNull && typeof value === 'string' && value.trim() === '-') return null + if (typeof value === 'string' && value.trim().toLowerCase() === 'null') return null + if (emptyAsNull && typeof value === 'string' && value.trim() === '') return null + return toNumberParam(value) +} + +function toBooleanParam(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value + if (typeof value !== 'string' || value.trim() === '') return undefined + if (value === 'true') return true + if (value === 'false') return false + return undefined +} + +function emptyToUndefined(value: unknown): unknown { + if (typeof value !== 'string') return value + const trimmed = value.trim() + return trimmed === '' || trimmed === '-' ? undefined : trimmed +} + +function mapCampaignParam(params: Record): string | undefined { + if (params.operation === 'list_leads') return optionalIdParam(params.campaignId) + if (params.operation !== 'create_lead' || params.leadDestination !== 'campaign') return undefined + return optionalIdParam(params.leadDestinationId) +} + +function mapListIdParam(params: Record): string | undefined { + switch (params.operation) { + case 'delete_leads': + return params.deleteSource === 'list' ? optionalIdParam(params.deleteSourceId) : undefined + case 'create_lead': + return params.leadDestination === 'list' + ? optionalIdParam(params.leadDestinationId) + : undefined + case 'list_leads': + case 'update_lead_interest_status': + case 'list_emails': + return optionalIdParam(params.listId) + default: + return undefined + } +} + +function mapCampaignIdParam(params: Record): string | undefined { + if (params.operation === 'delete_leads') { + return params.deleteSource === 'campaign' ? optionalIdParam(params.deleteSourceId) : undefined + } + + if (params.operation === 'update_lead_interest_status' || params.operation === 'list_emails') { + return optionalIdParam(params.campaignId) + } + + return undefined +} + +function mapIdsParam(params: Record): string[] | undefined { + if (params.operation === 'delete_leads') return parseStringList(params.deleteLeadIds) + if (params.operation === 'list_leads') return parseStringList(params.leadIds) + return undefined +} + +function mapStatusParam(params: Record): number | undefined { + if (params.operation === 'delete_leads') return toNumberParam(params.deleteStatus) + if (params.operation === 'list_campaigns') return toNumberParam(params.campaignStatus) + return undefined +} + +function mapLimitParam(params: Record): number | undefined { + if (params.operation === 'delete_leads') return toNumberParam(params.deleteLimit) + if (isPaginatedOperation(params.operation)) return toNumberParam(params.limit) + return undefined +} + +function mapStartingAfterParam(params: Record): unknown { + return isPaginatedOperation(params.operation) ? emptyToUndefined(params.startingAfter) : undefined +} + +function mapNameParam(params: Record): unknown { + switch (params.operation) { + case 'create_lead_list': + return emptyToUndefined(params.leadListName) + case 'create_campaign': + case 'patch_campaign': + return emptyToUndefined(params.campaignName) + default: + return undefined + } +} + +function mapSearchParam(params: Record): unknown { + if (params.operation === 'list_emails') return emptyToUndefined(params.emailSearch) + if (isSearchOperation(params.operation)) return emptyToUndefined(params.search) + return undefined +} + +function isPaginatedOperation(value: unknown): boolean { + return ( + value === 'list_leads' || + value === 'list_campaigns' || + value === 'list_emails' || + value === 'list_lead_lists' + ) +} + +function isSearchOperation(value: unknown): boolean { + return value === 'list_leads' || value === 'list_campaigns' || value === 'list_lead_lists' +} + +function optionalIdParam(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + if (trimmed === '' || trimmed === '-') return undefined + return trimmed +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/apps/sim/blocks/blocks/sixtyfour.ts b/apps/sim/blocks/blocks/sixtyfour.ts index bb740677565..37d0b14665b 100644 --- a/apps/sim/blocks/blocks/sixtyfour.ts +++ b/apps/sim/blocks/blocks/sixtyfour.ts @@ -265,11 +265,13 @@ export const SixtyfourBlock: BlockConfig = { }, emails: { type: 'json', - description: 'Email addresses found with validation status and type (find_email)', + description: + 'Email addresses found (find_email): [{address, status (OK|UNKNOWN|NOT_FOUND), type (COMPANY|PERSONAL)}]', }, personalEmails: { type: 'json', - description: 'Personal email addresses found in PERSONAL mode (find_email)', + description: + 'Personal email addresses found in PERSONAL mode (find_email): [{address, status, type}]', }, notes: { type: 'string', @@ -288,5 +290,9 @@ export const SixtyfourBlock: BlockConfig = { type: 'number', description: 'Quality score for the returned data, 0-10 (enrich_lead, enrich_company)', }, + orgChart: { + type: 'json', + description: 'Org chart returned when fullOrgChart is enabled (enrich_company)', + }, }, } diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 5beb6e12088..79e8191546f 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -103,6 +103,7 @@ import { ImapBlock } from '@/blocks/blocks/imap' import { IncidentioBlock } from '@/blocks/blocks/incidentio' import { InfisicalBlock } from '@/blocks/blocks/infisical' import { InputTriggerBlock } from '@/blocks/blocks/input_trigger' +import { InstantlyBlock } from '@/blocks/blocks/instantly' import { IntercomBlock, IntercomV2Block } from '@/blocks/blocks/intercom' import { JinaBlock } from '@/blocks/blocks/jina' import { JiraBlock } from '@/blocks/blocks/jira' @@ -364,6 +365,7 @@ export const registry: Record = { incidentio: IncidentioBlock, infisical: InfisicalBlock, input_trigger: InputTriggerBlock, + instantly: InstantlyBlock, intercom: IntercomBlock, intercom_v2: IntercomV2Block, jina: JinaBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 0e10935d999..a35727905dd 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -30,29 +30,44 @@ export function AgentMailIcon(props: SVGProps) { export function AgentPhoneIcon(props: SVGProps) { return ( - + + + + ) @@ -415,6 +430,18 @@ export function MailIcon(props: SVGProps) { ) } +export function InstantlyIcon(props: SVGProps) { + return ( + + + + + ) +} + export function EmailBisonIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/api/contracts/mcp.ts b/apps/sim/lib/api/contracts/mcp.ts index 2a6fc9b0888..dfcddac85ad 100644 --- a/apps/sim/lib/api/contracts/mcp.ts +++ b/apps/sim/lib/api/contracts/mcp.ts @@ -2,6 +2,8 @@ import { z } from 'zod' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import type { McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types' +const MAX_MCP_REFRESH_SERVER_IDS = 100 + const dateStringSchema = z.preprocess( (value) => (value instanceof Date ? value.toISOString() : value), z.string() @@ -160,7 +162,17 @@ export const discoverMcpToolsQuerySchema = mcpWorkspaceQuerySchema.extend({ }) export const refreshMcpToolsBodySchema = z.object({ - serverIds: z.array(z.string()), + serverIds: z + .array(z.string().min(1)) + .transform((serverIds) => [...new Set(serverIds)]) + .pipe( + z + .array(z.string()) + .max( + MAX_MCP_REFRESH_SERVER_IDS, + `At most ${MAX_MCP_REFRESH_SERVER_IDS} MCP servers can be refreshed at once` + ) + ), }) export const mcpEventsQuerySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts index af222119e4d..28e938d30d1 100644 --- a/apps/sim/lib/api/contracts/schedules.ts +++ b/apps/sim/lib/api/contracts/schedules.ts @@ -43,6 +43,7 @@ export const workflowScheduleRowSchema = z.object({ triggerType: z.string(), timezone: z.string(), failedCount: z.number(), + infraRetryCount: z.number(), status: scheduleStatusSchema, lastFailedAt: z.string().nullable(), /** @@ -131,6 +132,13 @@ const messageResponseSchema = z.object({ nextRunAt: z.string().optional(), }) +export const executeSchedulesResponseSchema = z.object({ + message: z.string(), + status: z.literal('started'), +}) + +export type ExecuteSchedulesResponse = z.output + export const getScheduleContract = defineRouteContract({ method: 'GET', path: '/api/schedules', @@ -228,3 +236,12 @@ export const deleteScheduleContract = defineRouteContract({ schema: messageResponseSchema, }, }) + +export const executeSchedulesContract = defineRouteContract({ + method: 'GET', + path: '/api/schedules/execute', + response: { + mode: 'json', + schema: executeSchedulesResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/workflow-mcp-servers.ts b/apps/sim/lib/api/contracts/workflow-mcp-servers.ts index 3c51d875862..b1c18344489 100644 --- a/apps/sim/lib/api/contracts/workflow-mcp-servers.ts +++ b/apps/sim/lib/api/contracts/workflow-mcp-servers.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { MAX_MCP_TOOLS_PER_SERVER } from '@/lib/mcp/constants' const dateStringSchema = z.preprocess( (value) => (value instanceof Date ? value.toISOString() : value), @@ -67,7 +68,16 @@ export const createWorkflowMcpServerBodySchema = z name: z.string().min(1), description: z.string().optional(), isPublic: z.boolean().optional(), - workflowIds: z.array(z.string()).optional(), + workflowIds: z + .array(z.string()) + .max( + MAX_MCP_TOOLS_PER_SERVER, + `Workflow MCP servers can include at most ${MAX_MCP_TOOLS_PER_SERVER} tools` + ) + .refine((workflowIds) => new Set(workflowIds).size === workflowIds.length, { + message: 'workflowIds must be unique', + }) + .optional(), }) .passthrough() diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index ab94e1b4719..691be015c9d 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -57,11 +57,12 @@ vi.mock('@/tools/params', () => ({ createUserToolSchema: mockCreateUserToolSchema, })) -import { buildIntegrationToolSchemas } from './payload' +import { buildIntegrationToolSchemas, clearIntegrationToolSchemaCacheForTests } from './payload' describe('buildIntegrationToolSchemas', () => { beforeEach(() => { vi.clearAllMocks() + clearIntegrationToolSchemaCacheForTests() mockCreateUserToolSchema.mockReturnValue({ type: 'object', properties: {} }) }) @@ -122,4 +123,16 @@ describe('buildIntegrationToolSchemas', () => { { surface: 'copilot' } ) }) + + it('briefly reuses built schemas for the same user and surface', async () => { + mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) + + const first = await buildIntegrationToolSchemas('user-cache') + first[0].input_schema.mutated = true + const second = await buildIntegrationToolSchemas('user-cache') + + expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(1) + expect(mockCreateUserToolSchema).toHaveBeenCalledTimes(3) + expect(second[0].input_schema).not.toHaveProperty('mutated') + }) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 4e3fdf34da7..7f3de96918e 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -6,21 +6,14 @@ import { isPaid } from '@/lib/billing/plan-helpers' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/feature-flags' -import { registerCache } from '@/lib/monitoring/cache-registry' import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { tools } from '@/tools/registry' import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' const logger = createLogger('CopilotChatPayload') -const TOOL_SCHEMA_CACHE_TTL_MS = 30_000 - -const toolSchemaCache = new LRUCache>({ - max: 200, - ttl: TOOL_SCHEMA_CACHE_TTL_MS, -}) - -registerCache('toolSchemaCache', () => toolSchemaCache.size) +const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000 +const INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES = 500 interface BuildPayloadParams { message: string @@ -58,6 +51,39 @@ interface BuildIntegrationToolSchemasOptions { schemaSurface?: 'default' | 'copilot' } +interface IntegrationToolSchemaCacheEntry { + promise: Promise +} + +const integrationToolSchemaCache = new LRUCache({ + max: INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES, + ttl: INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS, +}) + +function getIntegrationToolSchemaCacheKey( + userId: string, + workspaceId: string | undefined, + schemaSurface: string +): string { + return JSON.stringify([userId, workspaceId ?? null, schemaSurface]) +} + +function cloneToolSchemas(toolSchemas: ToolSchema[]): ToolSchema[] { + return toolSchemas.map((tool) => { + const cloned: ToolSchema = { + ...tool, + input_schema: { ...tool.input_schema }, + } + if (tool.params) cloned.params = { ...tool.params } + if (tool.oauth) cloned.oauth = { ...tool.oauth } + return cloned + }) +} + +export function clearIntegrationToolSchemaCacheForTests(): void { + integrationToolSchemaCache.clear() +} + /** * Build deferred integration tool schemas from the Sim tool registry. * Shared by the interactive chat payload builder and the non-interactive @@ -65,8 +91,7 @@ interface BuildIntegrationToolSchemasOptions { * * 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 resulting list is cached - * per `(userId, workspaceId, surface)` key so copilot turns reuse the filter. + * workspace's `allowedIntegrations` allowlist. */ export async function buildIntegrationToolSchemas( userId: string, @@ -74,124 +99,139 @@ export async function buildIntegrationToolSchemas( options: BuildIntegrationToolSchemasOptions = { schemaSurface: 'copilot' }, workspaceId?: string ): Promise { - const cacheKey = `${userId}:${workspaceId ?? ''}:${options.schemaSurface ?? 'copilot'}` - - const cached = toolSchemaCache.get(cacheKey) + const schemaSurface = options.schemaSurface ?? 'copilot' + const cacheKey = getIntegrationToolSchemaCacheKey(userId, workspaceId, schemaSurface) + const cached = integrationToolSchemaCache.get(cacheKey) if (cached) { - const tools = await cached - return tools.map((tool) => ({ ...tool, input_schema: { ...tool.input_schema } })) + return cloneToolSchemas(await cached.promise) } + const promise = buildIntegrationToolSchemasUncached( + userId, + messageId, + { schemaSurface }, + workspaceId + ).catch((error) => { + integrationToolSchemaCache.delete(cacheKey) + throw error + }) + + integrationToolSchemaCache.set(cacheKey, { + promise, + }) + + return cloneToolSchemas(await promise) +} + +async function buildIntegrationToolSchemasUncached( + userId: string, + messageId: string | undefined, + options: Required, + workspaceId?: string +): Promise { const reqLogger = logger.withMetadata({ messageId }) - const promise = (async () => { - const integrationTools: ToolSchema[] = [] + const integrationTools: ToolSchema[] = [] + try { + const { createUserToolSchema } = await import('@/tools/params') + const latestTools = getLatestVersionTools(tools) + let shouldAppendEmailTagline = false + try { - const { createUserToolSchema } = await import('@/tools/params') - const latestTools = getLatestVersionTools(tools) - let shouldAppendEmailTagline = false + const subscription = await getHighestPrioritySubscription(userId) + shouldAppendEmailTagline = !subscription || !isPaid(subscription.plan) + } catch (error) { + reqLogger.warn('Failed to load subscription for copilot tool descriptions', { + userId, + error: toError(error).message, + }) + } + let allowedIntegrations: Set | null = null + let toolIdToBlockType: Map | null = null + if (workspaceId) { try { - const subscription = await getHighestPrioritySubscription(userId) - shouldAppendEmailTagline = !subscription || !isPaid(subscription.plan) + const [{ getUserPermissionConfig }, { registry: blockRegistry }] = await Promise.all([ + import('@/ee/access-control/utils/permission-check'), + import('@/blocks/registry'), + ]) + const permissionConfig = await getUserPermissionConfig(userId, workspaceId) + if (permissionConfig?.allowedIntegrations) { + allowedIntegrations = new Set( + permissionConfig.allowedIntegrations.map((i) => i.toLowerCase()) + ) + toolIdToBlockType = new Map() + for (const [blockType, blockConfig] of Object.entries(blockRegistry)) { + const access = (blockConfig as { tools?: { access?: string[] } }).tools?.access + if (!access) continue + for (const toolId of access) { + toolIdToBlockType.set(stripVersionSuffix(toolId), blockType.toLowerCase()) + } + } + } } catch (error) { - reqLogger.warn('Failed to load subscription for copilot tool descriptions', { + reqLogger.warn('Failed to load permission config for tool schema filter', { userId, + workspaceId, error: toError(error).message, }) } + } - let allowedIntegrations: Set | null = null - let toolIdToBlockType: Map | null = null - if (workspaceId) { - try { - const [{ getUserPermissionConfig }, { registry: blockRegistry }] = await Promise.all([ - import('@/ee/access-control/utils/permission-check'), - import('@/blocks/registry'), - ]) - const permissionConfig = await getUserPermissionConfig(userId, workspaceId) - if (permissionConfig?.allowedIntegrations) { - allowedIntegrations = new Set( - permissionConfig.allowedIntegrations.map((i) => i.toLowerCase()) - ) - toolIdToBlockType = new Map() - for (const [blockType, blockConfig] of Object.entries(blockRegistry)) { - const access = (blockConfig as { tools?: { access?: string[] } }).tools?.access - if (!access) continue - for (const toolId of access) { - toolIdToBlockType.set(stripVersionSuffix(toolId), blockType.toLowerCase()) - } - } + for (const [toolId, toolConfig] of Object.entries(latestTools)) { + try { + const strippedName = stripVersionSuffix(toolId) + if (allowedIntegrations && toolIdToBlockType) { + const owningBlock = toolIdToBlockType.get(strippedName) + if (owningBlock && !allowedIntegrations.has(owningBlock)) { + continue } - } catch (error) { - reqLogger.warn('Failed to load permission config for tool schema filter', { - userId, - workspaceId, - error: toError(error).message, - }) } - } - - for (const [toolId, toolConfig] of Object.entries(latestTools)) { - try { - const strippedName = stripVersionSuffix(toolId) - if (allowedIntegrations && toolIdToBlockType) { - const owningBlock = toolIdToBlockType.get(strippedName) - if (owningBlock && !allowedIntegrations.has(owningBlock)) { - continue - } + const userSchema = createUserToolSchema(toolConfig, { + surface: options.schemaSurface, + }) + const catalogEntry = getToolEntry(strippedName) + integrationTools.push({ + name: strippedName, + description: getCopilotToolDescription(toolConfig, { + isHosted, + fallbackName: strippedName, + appendEmailTagline: shouldAppendEmailTagline, + }), + input_schema: { ...userSchema }, + defer_loading: true, + executeLocally: + catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client', + ...(toolConfig.oauth?.required && { + oauth: { + required: true, + provider: toolConfig.oauth.provider, + }, + }), + }) + } catch (toolError) { + logger.warn( + messageId + ? `Failed to build schema for tool, skipping [messageId:${messageId}]` + : 'Failed to build schema for tool, skipping', + { + toolId, + error: toError(toolError).message, } - const userSchema = createUserToolSchema(toolConfig, { - surface: options.schemaSurface ?? 'copilot', - }) - const catalogEntry = getToolEntry(strippedName) - integrationTools.push({ - name: strippedName, - description: getCopilotToolDescription(toolConfig, { - isHosted, - fallbackName: strippedName, - appendEmailTagline: shouldAppendEmailTagline, - }), - input_schema: { ...userSchema }, - defer_loading: true, - executeLocally: - catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client', - ...(toolConfig.oauth?.required && { - oauth: { - required: true, - provider: toolConfig.oauth.provider, - }, - }), - }) - } catch (toolError) { - logger.warn( - messageId - ? `Failed to build schema for tool, skipping [messageId:${messageId}]` - : 'Failed to build schema for tool, skipping', - { - toolId, - error: toError(toolError).message, - } - ) - } + ) } - } catch (error) { - logger.warn( - messageId - ? `Failed to build tool schemas [messageId:${messageId}]` - : 'Failed to build tool schemas', - { - error: toError(error).message, - } - ) } + } catch (error) { + logger.warn( + messageId + ? `Failed to build tool schemas [messageId:${messageId}]` + : 'Failed to build tool schemas', + { + error: toError(error).message, + } + ) + } - return integrationTools - })() - - toolSchemaCache.set(cacheKey, promise) - - const integrationTools = await promise - return integrationTools.map((tool) => ({ ...tool, input_schema: { ...tool.input_schema } })) + return integrationTools } /** diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index 1770c464c82..ace2bf0a8c7 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -96,6 +96,8 @@ export class DatabaseJobQueue implements JobQueueBackend { payload: payload as Record, status: JOB_STATUS.PENDING, createdAt: now, + runAt: + options?.delayMs && options.delayMs > 0 ? new Date(now.getTime() + options.delayMs) : now, attempts: 0, maxAttempts: options?.maxAttempts ?? 3, metadata: (options?.metadata ?? {}) as Record, diff --git a/apps/sim/lib/core/config/env.test.ts b/apps/sim/lib/core/config/env.test.ts new file mode 100644 index 00000000000..905b4b0b207 --- /dev/null +++ b/apps/sim/lib/core/config/env.test.ts @@ -0,0 +1,13 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { envNumber } from '@/lib/core/config/env' + +describe('envNumber', () => { + it('can require integer env values for count-like settings', () => { + expect(envNumber('5', 1, { min: 1, integer: true })).toBe(5) + expect(envNumber('5.5', 1, { min: 1, integer: true })).toBe(1) + expect(envNumber(5.5, 1, { min: 1, integer: true })).toBe(1) + }) +}) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index ecad5d3bb02..a3189f679d0 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -194,6 +194,12 @@ export const env = createEnv({ TRIGGER_DEV_ENABLED: z.boolean().optional(), // Toggle to enable/disable Trigger.dev for async jobs CRON_SECRET: z.string().optional(), // Secret for authenticating cron job requests JOB_RETENTION_DAYS: z.string().optional().default('1'), // Days to retain job logs/data + SCHEDULE_EXECUTION_CONCURRENCY_LIMIT: z.string().optional().default('50'), + SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER: z.string().optional().default('2'), + SCHEDULE_JITTER_MAX_MS: z.string().optional().default('30000'), + SCHEDULE_INFRA_RETRY_BASE_MS: z.string().optional().default('60000'), + SCHEDULE_INFRA_RETRY_MAX_MS: z.string().optional().default('300000'), + SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS: z.string().optional().default('10'), // Cloud Storage - AWS S3 AWS_REGION: z.string().optional(), // AWS region for S3 buckets @@ -555,13 +561,22 @@ export { getEnv } export function envNumber( value: number | string | undefined | null, fallback: number, - options: { min?: number } = {} + options: { min?: number; integer?: boolean } = {} ): number { const min = options.min ?? 0 - if (typeof value === 'number' && Number.isFinite(value) && value >= min) return value + if ( + typeof value === 'number' && + Number.isFinite(value) && + value >= min && + (!options.integer || Number.isInteger(value)) + ) { + return value + } if (value === undefined || value === null || value === '') return fallback const parsed = Number(value) - return Number.isFinite(parsed) && parsed >= min ? parsed : fallback + return Number.isFinite(parsed) && parsed >= min && (!options.integer || Number.isInteger(parsed)) + ? parsed + : fallback } /** diff --git a/apps/sim/lib/core/errors/retryable-infrastructure.ts b/apps/sim/lib/core/errors/retryable-infrastructure.ts new file mode 100644 index 00000000000..4eed69c6425 --- /dev/null +++ b/apps/sim/lib/core/errors/retryable-infrastructure.ts @@ -0,0 +1,78 @@ +const RETRYABLE_DB_ERROR_CODES = new Set([ + '08000', + '08001', + '08003', + '08004', + '08006', + '08007', + '53300', + '53400', + '57014', + '57P01', + '57P02', + '57P03', + '58000', + '58030', +]) + +const RETRYABLE_NETWORK_ERROR_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'ENETDOWN', + 'ENETRESET', + 'ENETUNREACH', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_SOCKET', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT', +]) + +const RETRYABLE_APP_ERROR_CODES = new Set([ + 'SERVICE_OVERLOADED', + 'RESOURCE_EXHAUSTED', + 'CONNECTION_POOL_EXHAUSTED', +]) + +function getErrorChain(error: unknown): Array> { + const chain: Array> = [] + let current: unknown = error + for (let depth = 0; depth < 10 && current instanceof Error; depth++) { + const candidate = current as Error & Record + chain.push(candidate) + current = candidate.cause + } + return chain +} + +export function describeRetryableInfrastructureError( + error: unknown +): Record | undefined { + for (const candidate of getErrorChain(error)) { + const code = typeof candidate.code === 'string' ? candidate.code : undefined + const errno = typeof candidate.errno === 'string' ? candidate.errno : undefined + const syscall = typeof candidate.syscall === 'string' ? candidate.syscall : undefined + + if ( + (code && RETRYABLE_DB_ERROR_CODES.has(code)) || + (code && RETRYABLE_NETWORK_ERROR_CODES.has(code)) || + (code && RETRYABLE_APP_ERROR_CODES.has(code)) || + (errno && RETRYABLE_NETWORK_ERROR_CODES.has(errno)) + ) { + return { + name: candidate.name, + message: candidate.message, + code, + errno, + syscall, + } + } + } + + return undefined +} + +export function isRetryableInfrastructureError(error: unknown): boolean { + return Boolean(describeRetryableInfrastructureError(error)) +} diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts index 10046696561..d199ed9e2c2 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts @@ -65,6 +65,7 @@ function interruptibleSleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve) => { const onAbort = () => { clearTimeout(timer) + signal.removeEventListener('abort', onAbort) resolve() } const timer = setTimeout(() => { @@ -72,6 +73,8 @@ function interruptibleSleep(ms: number, signal?: AbortSignal): Promise { resolve() }, ms) signal.addEventListener('abort', onAbort, { once: true }) + // Catch an abort that fired between the guard above and addEventListener. + if (signal.aborted) onAbort() }) } diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts b/apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts index 7405c1c5e61..d3d997d215a 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/queue.test.ts @@ -170,24 +170,32 @@ describe('HostedKeyQueue', () => { }) describe('refreshHeartbeat', () => { - it('writes the heartbeat key with TTL', async () => { - mockRedis.set.mockResolvedValueOnce('OK') + it('writes the heartbeat key with TTL and re-extends the queue list TTL', async () => { + mockRedis.pipeline.exec.mockResolvedValueOnce([ + [null, 'OK'], + [null, 1], + ]) await queue.refreshHeartbeat(provider, workspaceId, ticketId) - expect(mockRedis.set).toHaveBeenCalledWith( + expect(mockRedis.pipeline.set).toHaveBeenCalledWith( 'hosted-queue-tkt:exa:workspace-1:ticket-1', '1', 'EX', expect.any(Number) ) + expect(mockRedis.pipeline.expire).toHaveBeenCalledWith( + 'hosted-queue:exa:workspace-1', + expect.any(Number) + ) + expect(mockRedis.pipeline.exec).toHaveBeenCalledTimes(1) }) it('is a no-op when Redis is unavailable', async () => { redisConfigMockFns.mockGetRedisClient.mockReturnValueOnce(null) await expect(queue.refreshHeartbeat(provider, workspaceId, ticketId)).resolves.toBeUndefined() - expect(mockRedis.set).not.toHaveBeenCalled() + expect(mockRedis.multi).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/queue.ts b/apps/sim/lib/core/rate-limiter/hosted-key/queue.ts index 4a7ecd5aed1..a0803d1ae61 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/queue.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/queue.ts @@ -15,8 +15,9 @@ const TICKET_HEARTBEAT_TTL_SECONDS = 30 export const HEARTBEAT_REFRESH_INTERVAL_MS = 10_000 /** - * TTL on the queue list itself. Set on every enqueue. Prevents abandoned queues - * (whole workspace went silent) from sticking around forever in Redis. + * TTL on the queue list itself. Set on enqueue and re-extended by the head's heartbeat, + * so a long-waiting head can't let the list expire out from under the waiters behind it. + * Prevents abandoned queues from sticking around forever in Redis. */ const QUEUE_LIST_TTL_SECONDS = 600 @@ -147,8 +148,9 @@ export class HostedKeyQueue { } /** - * Refresh the ticket's heartbeat. Called periodically by the head while it's - * waiting on the bucket so it doesn't get reaped as dead. + * Refresh the ticket's heartbeat so the head isn't reaped as dead while waiting on the + * bucket. Also re-extends the queue list TTL so a wait outliving {@link QUEUE_LIST_TTL_SECONDS} + * doesn't let the list expire and collapse FIFO ordering. */ async refreshHeartbeat( provider: string, @@ -158,9 +160,13 @@ export class HostedKeyQueue { const redis = getRedisClient() if (!redis) return + const listKey = queueListKey(provider, billingActorId) const hbKey = heartbeatKey(provider, billingActorId, ticketId) try { - await redis.set(hbKey, '1', 'EX', TICKET_HEARTBEAT_TTL_SECONDS) + const pipeline = redis.multi() + pipeline.set(hbKey, '1', 'EX', TICKET_HEARTBEAT_TTL_SECONDS) + pipeline.expire(listKey, QUEUE_LIST_TTL_SECONDS) + await pipeline.exec() } catch (error) { logger.warn(`Queue heartbeat refresh failed for ${hbKey}`, { error: toError(error).message, diff --git a/apps/sim/lib/core/utils/background.test.ts b/apps/sim/lib/core/utils/background.test.ts new file mode 100644 index 00000000000..dd4fad698ba --- /dev/null +++ b/apps/sim/lib/core/utils/background.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { runDetached } from '@/lib/core/utils/background' + +const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('runDetached', () => { + it('runs the work without the caller awaiting it', async () => { + const work = vi.fn().mockResolvedValue(undefined) + + runDetached('test', work) + + await flushMicrotasks() + expect(work).toHaveBeenCalledTimes(1) + }) + + it('swallows rejections so they do not surface as unhandled', async () => { + const work = vi.fn().mockRejectedValue(new Error('boom')) + + expect(() => runDetached('test', work)).not.toThrow() + await flushMicrotasks() + expect(work).toHaveBeenCalledTimes(1) + }) + + it('swallows synchronous throws from work', async () => { + const work = vi.fn(() => { + throw new Error('sync boom') + }) + + expect(() => runDetached('test', work)).not.toThrow() + await flushMicrotasks() + }) +}) diff --git a/apps/sim/lib/core/utils/background.ts b/apps/sim/lib/core/utils/background.ts new file mode 100644 index 00000000000..5f3d4f7d6c4 --- /dev/null +++ b/apps/sim/lib/core/utils/background.ts @@ -0,0 +1,26 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' + +const logger = createLogger('BackgroundTask') + +/** + * Runs work detached from the HTTP response so a caller (e.g. a cron job with a + * short request timeout) receives an immediate response while processing + * continues on the long-lived server process. + * + * `withRouteHandler` only wraps awaited work in its try/catch, so a detached + * promise must catch its own rejection or it surfaces as an `unhandledRejection`. + * The request-scoped AsyncLocalStorage context (request ID) is captured when the + * work is scheduled and preserved across the detached continuation, so loggers + * inside `work` keep the originating request ID. + * + * @param label - Identifier used in the failure log line. + * @param work - The async work to run in the background. + */ +export function runDetached(label: string, work: () => Promise): void { + void Promise.resolve() + .then(work) + .catch((error) => { + logger.error(`Background task failed: ${label}`, toError(error)) + }) +} diff --git a/apps/sim/lib/core/utils/stream-limits.ts b/apps/sim/lib/core/utils/stream-limits.ts index 06bd7c0f650..d48fbb7083a 100644 --- a/apps/sim/lib/core/utils/stream-limits.ts +++ b/apps/sim/lib/core/utils/stream-limits.ts @@ -99,8 +99,17 @@ export async function readStreamToBufferWithLimit( const reader = stream.getReader() const chunks: Buffer[] = [] let totalBytes = 0 + const abortFromSignal = () => { + void reader.cancel(options.signal?.reason).catch(() => {}) + } try { + if (options.signal?.aborted) { + await reader.cancel(options.signal.reason).catch(() => {}) + throw toError(options.signal.reason ?? new Error('Aborted')) + } + options.signal?.addEventListener('abort', abortFromSignal, { once: true }) + while (true) { if (options.signal?.aborted) { await reader.cancel(options.signal.reason).catch(() => {}) @@ -108,6 +117,9 @@ export async function readStreamToBufferWithLimit( } const { done, value } = await reader.read() + if (options.signal?.aborted) { + throw toError(options.signal.reason ?? new Error('Aborted')) + } if (done) break if (!value) continue @@ -125,6 +137,7 @@ export async function readStreamToBufferWithLimit( chunks.push(Buffer.from(value)) } } finally { + options.signal?.removeEventListener('abort', abortFromSignal) reader.releaseLock() } diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 6013af53497..7a3db6cec2b 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -287,7 +287,7 @@ export async function deleteWorkspaceEnvCredentials(params: { export async function syncPersonalEnvCredentialsForUser(params: { userId: string envKeys: string[] -}) { +}): Promise { const { userId, envKeys } = params const workspaceIds = await getUserWorkspaceIds(userId) if (!workspaceIds.length) return diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index e511bbd9002..20a5e4e95ae 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -11,21 +11,47 @@ import { getAccessibleEnvCredentials, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' -import { registerCache } from '@/lib/monitoring/cache-registry' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('EnvironmentUtils') -const EFFECTIVE_ENV_CACHE_TTL_MS = 15_000 +const EFFECTIVE_DECRYPTED_ENV_CACHE_TTL_MS = 2_000 +const EFFECTIVE_DECRYPTED_ENV_CACHE_MAX_ENTRIES = 1_000 -const effectiveEnvCache = new LRUCache>>({ - max: 500, - ttl: EFFECTIVE_ENV_CACHE_TTL_MS, +interface EffectiveDecryptedEnvCacheEntry { + userId: string + workspaceId?: string + promise: Promise> +} + +const effectiveDecryptedEnvCache = new LRUCache({ + max: EFFECTIVE_DECRYPTED_ENV_CACHE_MAX_ENTRIES, + ttl: EFFECTIVE_DECRYPTED_ENV_CACHE_TTL_MS, }) -registerCache('effectiveEnvCache', () => effectiveEnvCache.size) +function getEffectiveDecryptedEnvCacheKey(userId: string, workspaceId?: string): string { + return JSON.stringify([userId, workspaceId ?? null]) +} -function getEffectiveEnvCacheKey(userId: string, workspaceId?: string) { - return `${userId}:${workspaceId ?? ''}` +function cloneEnvVars(envVars: Record): Record { + return { ...envVars } +} + +export function invalidateEffectiveDecryptedEnvCache(input: { + userId?: string + workspaceId?: string +}): void { + const { userId, workspaceId } = input + if (!userId && !workspaceId) return + + effectiveDecryptedEnvCache.forEach((entry, cacheKey) => { + if (userId && entry.userId === userId) { + effectiveDecryptedEnvCache.delete(cacheKey) + return + } + if (workspaceId && entry.workspaceId === workspaceId) { + effectiveDecryptedEnvCache.delete(cacheKey) + } + }) } /** @@ -269,7 +295,11 @@ export async function upsertPersonalEnvVars( set: { variables: finalEncrypted, updatedAt: new Date() }, }) - await syncPersonalEnvCredentialsForUser({ userId, envKeys: Object.keys(finalEncrypted) }) + invalidateEffectiveDecryptedEnvCache({ userId }) + await syncPersonalEnvCredentialsForUser({ + userId, + envKeys: Object.keys(finalEncrypted), + }) return { added, updated } } @@ -315,22 +345,24 @@ export async function upsertWorkspaceEnvVars( set: { variables: merged, updatedAt: new Date() }, }) + invalidateEffectiveDecryptedEnvCache({ workspaceId }) const newKeys = Object.keys(newVars).filter((k) => !(k in existingWsEncrypted)) await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId }) return updatedKeys } +/** + * Returns a merged decrypted env map for webhook/copilot/MCP config resolution. + */ export async function getEffectiveDecryptedEnv( userId: string, workspaceId?: string ): Promise> { - const cacheKey = getEffectiveEnvCacheKey(userId, workspaceId) - - const cached = effectiveEnvCache.get(cacheKey) + const cacheKey = getEffectiveDecryptedEnvCacheKey(userId, workspaceId) + const cached = effectiveDecryptedEnvCache.get(cacheKey) if (cached) { - const value = await cached - return { ...value } + return cloneEnvVars(await cached.promise) } const promise = getPersonalAndWorkspaceEnv(userId, workspaceId) @@ -339,11 +371,15 @@ export async function getEffectiveDecryptedEnv( ...workspaceDecrypted, })) .catch((error) => { - effectiveEnvCache.delete(cacheKey) + effectiveDecryptedEnvCache.delete(cacheKey) throw error }) - effectiveEnvCache.set(cacheKey, promise) - const value = await promise - return { ...value } + effectiveDecryptedEnvCache.set(cacheKey, { + userId, + workspaceId, + promise, + }) + + return cloneEnvVars(await promise) } diff --git a/apps/sim/lib/execution/payloads/serializer.test.ts b/apps/sim/lib/execution/payloads/serializer.test.ts index e76f500b4a1..3b77f37981d 100644 --- a/apps/sim/lib/execution/payloads/serializer.test.ts +++ b/apps/sim/lib/execution/payloads/serializer.test.ts @@ -91,6 +91,24 @@ describe('compactExecutionPayload', () => { expect(isLargeValueRef(compacted.metadata)).toBe(true) }) + it('rejects oversized values before preserving or spilling them when requested', async () => { + await expect( + compactExecutionPayload( + { root: Object.fromEntries(Array.from({ length: 100 }, (_, index) => [`k${index}`, 'x'])) }, + { + thresholdBytes: 256, + preserveRoot: true, + rejectLargeValues: true, + rejectLargeValueLabel: 'Workflow execution response', + ...TEST_EXECUTION_CONTEXT, + } + ) + ).rejects.toMatchObject({ + name: 'PayloadSizeLimitError', + label: 'Workflow execution response', + }) + }) + it('does not double-spill existing refs', async () => { const compacted = await compactExecutionPayload( { results: [[{ payload: 'x'.repeat(2048) }]] }, diff --git a/apps/sim/lib/execution/payloads/serializer.ts b/apps/sim/lib/execution/payloads/serializer.ts index 7698a7c2b5f..c3d0079defb 100644 --- a/apps/sim/lib/execution/payloads/serializer.ts +++ b/apps/sim/lib/execution/payloads/serializer.ts @@ -1,3 +1,4 @@ +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import { createLargeArrayManifest, @@ -14,6 +15,8 @@ export interface CompactExecutionPayloadOptions extends LargeValueStoreContext { thresholdBytes?: number preserveUserFileBase64?: boolean preserveRoot?: boolean + rejectLargeValues?: boolean + rejectLargeValueLabel?: string } interface CompactState { @@ -44,6 +47,24 @@ function canPersistDurably(options: CompactExecutionPayloadOptions): boolean { return Boolean(options.workspaceId && options.workflowId && options.executionId) } +function largeValueLimitError( + options: CompactExecutionPayloadOptions, + observedBytes: number +): PayloadSizeLimitError { + return new PayloadSizeLimitError({ + label: options.rejectLargeValueLabel ?? 'Large execution value', + maxBytes: options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES, + observedBytes, + }) +} + +function assertRejectSize(observedBytes: number, options: CompactExecutionPayloadOptions): void { + if (!options.rejectLargeValues) return + if (observedBytes > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) { + throw largeValueLimitError(options, observedBytes) + } +} + async function compactValue( value: unknown, options: CompactExecutionPayloadOptions, @@ -53,6 +74,9 @@ async function compactValue( if (!value || typeof value !== 'object') { const measured = getJsonAndSize(value) if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) { + if (options.rejectLargeValues) { + throw largeValueLimitError(options, measured.size) + } return options.preserveRoot && depth === 0 ? value : storeLargeValue(value, measured.json, measured.size, options) @@ -67,6 +91,9 @@ async function compactValue( if (isLargeArrayManifest(value)) { const measured = getJsonAndSize(value) if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) { + if (options.rejectLargeValues) { + throw largeValueLimitError(options, measured.size) + } return storeLargeValue(value, measured.json, measured.size, options) } return value @@ -81,21 +108,14 @@ async function compactValue( } state.seen.add(value) - const compacted = Array.isArray(value) - ? await Promise.all(value.map((item) => compactValue(item, options, state, depth + 1))) - : Object.fromEntries( - await Promise.all( - Object.entries(value).map(async ([key, entryValue]) => [ - key, - key === 'finalBlockLogs' && Array.isArray(entryValue) - ? await compactBlockLogs(entryValue as BlockLog[], options) - : await compactValue(entryValue, options, state, depth + 1), - ]) - ) - ) + const compacted = await compactEntries(value, options, state, depth) const measured = getJsonAndSize(compacted) if (measured && measured.size > (options.thresholdBytes ?? LARGE_VALUE_THRESHOLD_BYTES)) { + if (options.rejectLargeValues) { + throw largeValueLimitError(options, measured.size) + } + if (Array.isArray(compacted) && (canPersistDurably(options) || options.requireDurable)) { return createLargeArrayManifest(compacted, { ...options, requireDurable: true }) } @@ -110,6 +130,76 @@ async function compactValue( return compacted } +async function compactEntries( + value: object, + options: CompactExecutionPayloadOptions, + state: CompactState, + depth: number +): Promise { + if (options.rejectLargeValues) { + return compactEntriesWithEarlyReject(value, options, state, depth) + } + + if (Array.isArray(value)) { + return Promise.all(value.map((item) => compactValue(item, options, state, depth + 1))) + } + + return Object.fromEntries( + await Promise.all( + Object.entries(value).map(async ([key, entryValue]) => [ + key, + key === 'finalBlockLogs' && Array.isArray(entryValue) + ? await compactBlockLogs(entryValue as BlockLog[], options) + : await compactValue(entryValue, options, state, depth + 1), + ]) + ) + ) +} + +async function compactEntriesWithEarlyReject( + value: object, + options: CompactExecutionPayloadOptions, + state: CompactState, + depth: number +): Promise { + if (Array.isArray(value)) { + const compacted: unknown[] = [] + let estimatedBytes = 2 + for (const item of value) { + const compactedItem = await compactValue(item, options, state, depth + 1) + compacted.push(compactedItem) + const measured = getJsonAndSize(compactedItem) + estimatedBytes += (compacted.length > 1 ? 1 : 0) + (measured?.size ?? 4) + assertRejectSize(estimatedBytes, options) + } + return compacted + } + + const compacted: Record = {} + let estimatedBytes = 2 + let serializedPropertyCount = 0 + for (const [key, entryValue] of Object.entries(value)) { + const compactedEntry = + key === 'finalBlockLogs' && Array.isArray(entryValue) + ? await compactBlockLogs(entryValue as BlockLog[], options) + : await compactValue(entryValue, options, state, depth + 1) + compacted[key] = compactedEntry + + const measured = getJsonAndSize(compactedEntry) + if (measured) { + const keyJson = JSON.stringify(key) + estimatedBytes += + (serializedPropertyCount > 0 ? 1 : 0) + + Buffer.byteLength(keyJson, 'utf8') + + 1 + + measured.size + serializedPropertyCount += 1 + assertRejectSize(estimatedBytes, options) + } + } + return compacted +} + async function forceStoreValue( value: unknown, options: CompactExecutionPayloadOptions diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 567dbede2a8..d65e0331c43 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -4,6 +4,10 @@ import { getActiveWorkflowRecord } from '@sim/workflow-authz' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { + describeRetryableInfrastructureError, + isRetryableInfrastructureError, +} from '@/lib/core/errors/retryable-infrastructure' import { getExecutionTimeout } from '@/lib/core/execution-limits' import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' @@ -40,7 +44,7 @@ export interface PreprocessExecutionOptions { useAuthenticatedUserAsActor?: boolean // If true, use the authenticated userId as actorUserId (for client-side executions and personal API keys) /** @deprecated No longer used - background/async executions always use deployed state */ useDraftState?: boolean - /** Pre-fetched workflow record to skip the Step 1 DB query. Must be a full workflow table row. */ + /** Pre-fetched workflow row for caller context; preprocessing still re-checks active state. */ workflowRecord?: WorkflowRecord } @@ -53,6 +57,8 @@ export interface PreprocessExecutionResult { message: string statusCode: number logCreated: boolean + retryable?: boolean + cause?: Record } actorUserId?: string workflowRecord?: WorkflowRecord @@ -159,6 +165,8 @@ export async function preprocessExecution( message: 'Internal error while fetching workflow', statusCode: 500, logCreated: true, + retryable: isRetryableInfrastructureError(error), + cause: describeRetryableInfrastructureError(error), }, } } @@ -287,6 +295,8 @@ export async function preprocessExecution( message: 'Error resolving billing account', statusCode: 500, logCreated: true, + retryable: isRetryableInfrastructureError(error), + cause: describeRetryableInfrastructureError(error), }, } } @@ -358,6 +368,8 @@ export async function preprocessExecution( message: 'Unable to determine usage limits. Execution blocked for security.', statusCode: 500, logCreated: true, + retryable: isRetryableInfrastructureError(error), + cause: describeRetryableInfrastructureError(error), }, } } @@ -425,6 +437,8 @@ export async function preprocessExecution( message: 'Error checking rate limits', statusCode: 500, logCreated: true, + retryable: isRetryableInfrastructureError(error), + cause: describeRetryableInfrastructureError(error), }, } } diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index ca2b26724fa..bef88182c9e 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -32,6 +32,10 @@ import { MCP_CLIENT_CONSTANTS } from '@/lib/mcp/utils' const logger = createLogger('McpClient') +interface McpClientConnectOptions { + isCancelled?: () => boolean +} + export class McpClient { private client: Client private transport: StreamableHTTPClientTransport @@ -85,11 +89,17 @@ export class McpClient { * If an `onToolsChanged` callback was provided, registers a notification handler * for `notifications/tools/list_changed` after connecting. */ - async connect(): Promise { + async connect(options: McpClientConnectOptions = {}): Promise { logger.info(`Connecting to MCP server: ${this.config.name} (${this.config.transport})`) try { await this.client.connect(this.transport) + if (options.isCancelled?.()) { + await this.client.close().catch((error) => { + logger.warn(`Error closing cancelled connection to ${this.config.name}:`, error) + }) + throw new McpConnectionError('Connection attempt cancelled', this.config.name) + } this.isConnected = true this.connectionStatus.connected = true diff --git a/apps/sim/lib/mcp/connection-manager.test.ts b/apps/sim/lib/mcp/connection-manager.test.ts index ee880b968ba..83e32d50088 100644 --- a/apps/sim/lib/mcp/connection-manager.test.ts +++ b/apps/sim/lib/mcp/connection-manager.test.ts @@ -79,6 +79,7 @@ describe('McpConnectionManager', () => { afterEach(() => { manager?.dispose() manager = null + vi.useRealTimers() }) function createFreshManager(): McpConnectionManager { @@ -206,6 +207,36 @@ describe('McpConnectionManager', () => { expect(r2.supportsListChanged).toBe(true) expect(instances).toHaveLength(2) }) + + it('marks timed-out connect attempts as cancelled for late completions', async () => { + vi.useFakeTimers() + const deferred = createDeferred() + const instances: MockMcpClient[] = [] + + MockMcpClientConstructor.mockImplementation(() => { + const instance: MockMcpClient = { + connect: vi.fn().mockImplementation(() => deferred.promise), + disconnect: vi.fn().mockResolvedValue(undefined), + hasListChangedCapability: vi.fn().mockReturnValue(true), + onClose: vi.fn(), + } + instances.push(instance) + return instance + }) + + const mgr = createFreshManager() + const resultPromise = mgr.connect(serverConfig('server-timeout'), 'user-1', 'ws-1') + + await vi.advanceTimersByTimeAsync(15_000) + const result = await resultPromise + const connectOptions = instances[0].connect.mock.calls[0][0] + + expect(result.supportsListChanged).toBe(false) + expect(connectOptions.isCancelled()).toBe(true) + expect(instances[0].disconnect).toHaveBeenCalled() + + deferred.resolve() + }) }) describe('dispose', () => { @@ -225,4 +256,94 @@ describe('McpConnectionManager', () => { expect(result.supportsListChanged).toBe(false) }) }) + + describe('intentional disconnect cleanup', () => { + it('does not reconnect when disconnectServer closes a managed client', async () => { + vi.useFakeTimers() + let closeHandler: (() => void) | undefined + const instances: MockMcpClient[] = [] + + MockMcpClientConstructor.mockImplementation(() => { + const instance: MockMcpClient = { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockImplementation(async () => { + closeHandler?.() + }), + hasListChangedCapability: vi.fn().mockReturnValue(true), + onClose: vi.fn().mockImplementation((handler: () => void) => { + closeHandler = handler + }), + } + instances.push(instance) + return instance + }) + + const mgr = createFreshManager() + await mgr.connect(serverConfig('server-5'), 'user-1', 'ws-1') + + await mgr.disconnectServer('server-5') + await vi.advanceTimersByTimeAsync(2_000) + + expect(instances).toHaveLength(1) + expect(mgr.hasConnection('server-5')).toBe(false) + }) + + it('does not reconnect when close fires after disconnect resolves', async () => { + vi.useFakeTimers() + let closeHandler: (() => void) | undefined + const instances: MockMcpClient[] = [] + + MockMcpClientConstructor.mockImplementation(() => { + const instance: MockMcpClient = { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + hasListChangedCapability: vi.fn().mockReturnValue(true), + onClose: vi.fn().mockImplementation((handler: () => void) => { + closeHandler = handler + }), + } + instances.push(instance) + return instance + }) + + const mgr = createFreshManager() + await mgr.connect(serverConfig('server-7'), 'user-1', 'ws-1') + + await mgr.disconnectServer('server-7') + closeHandler?.() + await vi.advanceTimersByTimeAsync(2_000) + + expect(instances).toHaveLength(1) + expect(mgr.hasConnection('server-7')).toBe(false) + }) + + it('does not reconnect idle connections after cleanup disconnects them', async () => { + vi.useFakeTimers() + const closeHandlers: Array<() => void> = [] + const instances: MockMcpClient[] = [] + + MockMcpClientConstructor.mockImplementation(() => { + const instance: MockMcpClient = { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockImplementation(async () => { + closeHandlers.at(-1)?.() + }), + hasListChangedCapability: vi.fn().mockReturnValue(true), + onClose: vi.fn().mockImplementation((handler: () => void) => { + closeHandlers.push(handler) + }), + } + instances.push(instance) + return instance + }) + + const mgr = createFreshManager() + await mgr.connect(serverConfig('server-6'), 'user-1', 'ws-1') + + await vi.advanceTimersByTimeAsync(35 * 60 * 1000) + + expect(instances).toHaveLength(1) + expect(mgr.hasConnection('server-6')).toBe(false) + }) + }) }) diff --git a/apps/sim/lib/mcp/connection-manager.ts b/apps/sim/lib/mcp/connection-manager.ts index 6b80f3c06a6..178a3f54564 100644 --- a/apps/sim/lib/mcp/connection-manager.ts +++ b/apps/sim/lib/mcp/connection-manager.ts @@ -11,6 +11,7 @@ */ import { createLogger } from '@sim/logger' +import { backoffWithJitter } from '@sim/utils/retry' import { isTest } from '@/lib/core/config/feature-flags' import { McpClient } from '@/lib/mcp/client' import { getOrCreateOauthRow, loadPreregisteredClient, SimMcpOauthProvider } from '@/lib/mcp/oauth' @@ -28,11 +29,37 @@ const logger = createLogger('McpConnectionManager') const MAX_CONNECTIONS = 50 const MAX_RECONNECT_ATTEMPTS = 10 const BASE_RECONNECT_DELAY_MS = 1000 +const CONNECT_TIMEOUT_MS = 15_000 const IDLE_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes type ToolsChangedListener = (event: ToolsChangedEvent) => void +async function withConnectTimeout(client: McpClient, serverName: string): Promise { + let timeoutId: ReturnType | undefined + let timedOut = false + const connectPromise = client.connect({ isCancelled: () => timedOut }) + try { + await Promise.race([ + connectPromise, + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + timedOut = true + reject(new Error(`Timed out connecting to MCP server ${serverName}`)) + }, CONNECT_TIMEOUT_MS) + }), + ]) + } catch (error) { + if (timedOut) { + void connectPromise.catch(() => {}) + } + await client.disconnect().catch(() => {}) + throw error + } finally { + if (timeoutId) clearTimeout(timeoutId) + } +} + /** * Cache key for managed connections. * MCP servers are workspace-owned, so OAuth/header/no-auth connections are @@ -140,7 +167,7 @@ export class McpConnectionManager { }) try { - await client.connect() + await withConnectTimeout(client, config.name) } catch (error) { logger.error(`[${config.name}] Failed to connect for persistent monitoring:`, error) return { supportsListChanged: false } @@ -191,15 +218,17 @@ export class McpConnectionManager { const client = this.connections.get(key) if (client) { + this.connections.delete(key) + this.states.delete(key) try { await client.disconnect() } catch (error) { logger.warn(`Error disconnecting managed client ${key}:`, error) } - this.connections.delete(key) + } else { + this.states.delete(key) } - this.states.delete(key) logger.info(`Managed connection removed: ${key}`) } @@ -331,8 +360,11 @@ export class McpConnectionManager { return } - const delay = Math.min(BASE_RECONNECT_DELAY_MS * 2 ** state.reconnectAttempts, 60_000) state.reconnectAttempts++ + const delay = backoffWithJitter(state.reconnectAttempts, null, { + baseMs: BASE_RECONNECT_DELAY_MS, + maxMs: 60_000, + }) logger.info( `[${config.name}] Reconnecting in ${delay}ms (attempt ${state.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})` diff --git a/apps/sim/lib/mcp/constants.ts b/apps/sim/lib/mcp/constants.ts new file mode 100644 index 00000000000..2b583d569ca --- /dev/null +++ b/apps/sim/lib/mcp/constants.ts @@ -0,0 +1,11 @@ +export const MAX_MCP_TOOLS_PER_SERVER = 100 +export const MAX_MCP_SERVERS_PER_WORKFLOW = 100 +export const MCP_TOOL_BRIDGE_HEADER = 'X-Sim-MCP-Tool-Call' +export const MCP_TOOL_BRIDGE_ACTOR_HEADER = 'X-Sim-MCP-Tool-Actor' +export const MAX_MCP_PARAMETER_SCHEMA_BYTES = 2 * 1024 * 1024 +export const MAX_MCP_TOOL_DESCRIPTION_BYTES = 64 * 1024 +export const MAX_MCP_TOOL_NAME_BYTES = 256 +export const MAX_MCP_TOOLS_LIST_RESPONSE_BYTES = 10 * 1024 * 1024 +export const MAX_MCP_WORKFLOW_RESPONSE_BYTES = 10 * 1024 * 1024 +export const MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES = MAX_MCP_PARAMETER_SCHEMA_BYTES +export const MAX_MCP_SERVER_TOOLS_METADATA_BYTES = MAX_MCP_TOOLS_LIST_RESPONSE_BYTES diff --git a/apps/sim/lib/mcp/middleware.ts b/apps/sim/lib/mcp/middleware.ts index 6e4aa816221..90367b3cd75 100644 --- a/apps/sim/lib/mcp/middleware.ts +++ b/apps/sim/lib/mcp/middleware.ts @@ -3,10 +3,17 @@ import { toError } from '@sim/utils/errors' import type { NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' import { createMcpErrorResponse } from '@/lib/mcp/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('McpAuthMiddleware') +const MAX_MCP_MANAGEMENT_BODY_BYTES = 10 * 1024 * 1024 +const parsedBodies = new WeakMap() export type McpPermissionLevel = 'read' | 'write' | 'admin' @@ -36,6 +43,68 @@ interface AuthFailure { type AuthValidationResult = AuthResult | AuthFailure +class McpBodyReadError extends Error { + constructor( + readonly kind: 'aborted' | 'payload_too_large' | 'invalid_json', + readonly cause: unknown + ) { + super(toError(cause).message) + this.name = 'McpBodyReadError' + } +} + +export async function readMcpJsonBodyWithLimit(request: NextRequest): Promise { + const cached = parsedBodies.get(request) + if (cached !== undefined) return cached + + try { + assertContentLengthWithinLimit( + request.headers, + MAX_MCP_MANAGEMENT_BODY_BYTES, + 'MCP management request body' + ) + const buffer = await readStreamToBufferWithLimit(request.body, { + maxBytes: MAX_MCP_MANAGEMENT_BODY_BYTES, + label: 'MCP management request body', + signal: request.signal, + }) + const body = buffer.byteLength > 0 ? JSON.parse(buffer.toString('utf-8')) : {} + parsedBodies.set(request, body) + return body + } catch (error) { + if (request.signal.aborted) { + throw new McpBodyReadError('aborted', error) + } + if (isPayloadSizeLimitError(error)) { + throw new McpBodyReadError('payload_too_large', error) + } + if (error instanceof SyntaxError) { + throw new McpBodyReadError('invalid_json', error) + } + throw error + } +} + +export function mcpBodyReadErrorResponse( + error: unknown, + request?: NextRequest +): NextResponse | null { + if (!(error instanceof McpBodyReadError)) { + return null + } + if (error.kind === 'aborted' || request?.signal.aborted) { + return createMcpErrorResponse(error.cause, 'Client cancelled request', 499) + } + if (error.kind === 'payload_too_large') { + return createMcpErrorResponse( + error.cause, + 'MCP management request body exceeds maximum size', + 413 + ) + } + return createMcpErrorResponse(error.cause, 'Invalid request body', 400) +} + /** * Validates MCP authentication and authorization */ @@ -68,11 +137,17 @@ async function validateMcpAuth( try { const contentType = request.headers.get('content-type') if (contentType?.includes('application/json')) { - const body = await request.json() - workspaceId = body.workspaceId - ;(request as any)._parsedBody = body + const body = await readMcpJsonBodyWithLimit(request) + const bodyWorkspaceId = + body && typeof body === 'object' && 'workspaceId' in body + ? (body as { workspaceId?: unknown }).workspaceId + : undefined + workspaceId = typeof bodyWorkspaceId === 'string' ? bodyWorkspaceId : null } - } catch {} + } catch (error) { + const errorResponse = mcpBodyReadErrorResponse(error, request) + if (errorResponse) return { success: false, errorResponse } + } } if (!workspaceId) { @@ -190,6 +265,8 @@ export function withMcpAuth>( try { return await handler(request, (authResult as AuthResult).context, routeContext) } catch (error) { + const bodyErrorResponse = mcpBodyReadErrorResponse(error, request) + if (bodyErrorResponse) return bodyErrorResponse logger.error( `[${(authResult as AuthResult).context.requestId}] Error in MCP route handler:`, error @@ -199,10 +276,3 @@ export function withMcpAuth>( } } } - -/** - * Utility to get parsed request body - */ -export function getParsedBody(request: NextRequest): any { - return (request as any)._parsedBody -} diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts new file mode 100644 index 00000000000..d2bbe589b79 --- /dev/null +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts @@ -0,0 +1,203 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/audit', () => ({ + AuditAction: { + MCP_SERVER_UPDATED: 'mcp_server_updated', + MCP_TOOL_UPDATED: 'mcp_tool_updated', + }, + AuditResourceType: { + MCP_SERVER: 'mcp_server', + MCP_TOOL: 'mcp_tool', + }, + recordAudit: vi.fn(), +})) +vi.mock('@sim/db', () => ({ + ...dbChainMock, + workflow: schemaMock.workflow, + workflowMcpServer: schemaMock.workflowMcpServer, + workflowMcpTool: schemaMock.workflowMcpTool, +})) +vi.mock('@sim/db/schema', () => schemaMock) +vi.mock('drizzle-orm', () => ({ + and: vi.fn(), + asc: vi.fn(), + eq: vi.fn(), + inArray: vi.fn(), + isNull: vi.fn(), + ne: vi.fn(), + sql: Object.assign(vi.fn(), { raw: vi.fn((value: string) => value) }), +})) +vi.mock('@/lib/mcp/pubsub', () => ({ mcpPubSub: undefined })) +vi.mock('@/lib/workflows/triggers/trigger-utils.server', () => ({ + hasValidStartBlock: vi.fn(), +})) +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + generateParameterSchemaForWorkflow: vi.fn().mockResolvedValue({ type: 'object', properties: {} }), +})) + +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 { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' + +describe('workflow MCP lifecycle orchestration', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('rejects over-limit workflow server creation before inserting a server row', async () => { + const result = await performCreateWorkflowMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Too Many Tools', + workflowIds: Array.from( + { length: MAX_MCP_TOOLS_PER_SERVER + 1 }, + (_, index) => `wf-${index}` + ), + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('rejects duplicate workflow IDs before inserting a server row', async () => { + const result = await performCreateWorkflowMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Duplicate Tools', + workflowIds: ['wf-1', 'wf-1'], + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('rechecks deployed workflow state inside the create transaction', async () => { + 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'), + }, + ]) + vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true) + dbChainMockFns.for.mockResolvedValueOnce([ + { + id: 'wf-1', + name: 'Workflow', + description: null, + isDeployed: false, + 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).toMatchObject({ + success: false, + errorCode: 'validation', + }) + expect(dbChainMockFns.transaction).toHaveBeenCalled() + expect(dbChainMockFns.for).toHaveBeenCalledTimes(1) + 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([ + { + 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'), + }, + ]) + dbChainMockFns.for.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'), + }, + ]) + dbChainMockFns.groupBy.mockResolvedValueOnce([{ workflowId: 'wf-1', serverCount: 100 }]) + + const result = await performCreateWorkflowMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Server', + workflowIds: ['wf-1'], + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('allows updating tool metadata when an unchanged stored schema exceeds the new cap', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'server-1' }]).mockResolvedValueOnce([ + { + id: 'tool-1', + toolName: 'tool_a', + toolDescription: null, + parameterSchemaBytes: MAX_MCP_PARAMETER_SCHEMA_BYTES + 1, + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'tool-1', + serverId: 'server-1', + toolName: 'tool_a', + toolDescription: 'Updated description', + }, + ]) + + const result = await performUpdateWorkflowMcpTool({ + workspaceId: 'workspace-1', + userId: 'user-1', + serverId: 'server-1', + toolId: 'tool-1', + toolDescription: 'Updated description', + }) + + expect(result).toMatchObject({ + success: true, + tool: { + toolDescription: 'Updated description', + }, + }) + expect(dbChainMockFns.update).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts index 4d04a98189d..917ffa72522 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts @@ -2,15 +2,54 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, inArray, isNull } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, ne, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { + MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES, + MAX_MCP_SERVER_TOOLS_METADATA_BYTES, + MAX_MCP_SERVERS_PER_WORKFLOW, + MAX_MCP_TOOLS_PER_SERVER, +} from '@/lib/mcp/constants' import { mcpPubSub } from '@/lib/mcp/pubsub' +import { + acquireWorkflowMcpServerLock, + isWorkflowMcpServerLockTimeout, + setWorkflowMcpTransactionLockTimeout, +} from '@/lib/mcp/server-locks' +import { + addMcpToolMetadataUsage, + addMcpToolMetadataUsageRow, + createMcpToolMetadataUsageRow, + getMcpServerToolMetadataUsageRows, + getMcpToolDescriptionForStorage, + getMcpToolMetadataSizes, + getMcpToolMetadataUsageFromRows, + type McpToolMetadataUsage, + validateMcpServerToolMetadataBudget, + validateMcpToolMetadataForStorage, +} from '@/lib/mcp/tool-limits' import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' const logger = createLogger('WorkflowMcpOrchestration') -export type WorkflowMcpOrchestrationErrorCode = 'not_found' | 'validation' | 'internal' +export type WorkflowMcpOrchestrationErrorCode = + | 'not_found' + | 'validation' + | 'forbidden' + | 'conflict' + | 'internal' + +class WorkflowMcpExpectedError extends Error { + constructor( + message: string, + readonly errorCode: WorkflowMcpOrchestrationErrorCode + ) { + super(message) + this.name = 'WorkflowMcpExpectedError' + } +} interface ActorMetadata { actorName?: string | null @@ -77,7 +116,7 @@ export interface PerformCreateWorkflowMcpToolParams extends ActorMetadata { export interface PerformCreateWorkflowMcpToolResult { success: boolean error?: string - errorCode?: WorkflowMcpOrchestrationErrorCode | 'conflict' + errorCode?: WorkflowMcpOrchestrationErrorCode tool?: typeof workflowMcpTool.$inferSelect } @@ -112,80 +151,338 @@ export interface PerformDeleteWorkflowMcpToolResult { tool?: typeof workflowMcpTool.$inferSelect } +interface PreparedWorkflowMcpTool { + workflowId: string + toolName: string + toolDescription: string | null + parameterSchema: unknown +} + +interface WorkflowMcpToolWorkflowRecord { + id: string + name: string + description: string | null +} + +interface WorkflowMcpServerCreateWorkflowRecord extends WorkflowMcpToolWorkflowRecord { + isDeployed: boolean + workspaceId: string | null + deployedAt: Date | null + updatedAt: Date +} + +async function validateServerToolMetadataBudget( + serverId: string, + proposedTools: Array<{ + toolName: string + toolDescription: string | null + parameterSchema: unknown + }>, + tx: DbOrTx, + excludeToolId?: string +): Promise { + let usage = getMcpToolMetadataUsageFromRows( + await getMcpServerToolMetadataUsageRows(tx, serverId, excludeToolId) + ) + for (const tool of proposedTools) { + usage = addMcpToolMetadataUsage(usage, tool) + } + return validateMcpServerToolMetadataBudget(usage) +} + +function validateServerToolMetadataBudgetForUpdate( + currentUsage: McpToolMetadataUsage, + proposedUsage: McpToolMetadataUsage +): string | null { + if ( + proposedUsage.schemaBytes > MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES && + proposedUsage.schemaBytes > currentUsage.schemaBytes + ) { + return `MCP server tool schemas exceed maximum size of ${MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES} bytes` + } + if ( + proposedUsage.metadataBytes > MAX_MCP_SERVER_TOOLS_METADATA_BYTES && + proposedUsage.metadataBytes > currentUsage.metadataBytes + ) { + return `MCP server tool metadata exceeds maximum size of ${MAX_MCP_SERVER_TOOLS_METADATA_BYTES} bytes` + } + return null +} + +async function prepareWorkflowMcpTool(params: { + workflowRecord: WorkflowMcpToolWorkflowRecord + toolName?: string + toolDescription?: string | null + parameterSchema?: Record +}): Promise { + const { workflowRecord } = params + const toolName = sanitizeToolName(params.toolName?.trim() || workflowRecord.name) + const toolDescription = + params.toolDescription !== undefined + ? params.toolDescription?.trim() || `Execute ${workflowRecord.name} workflow` + : getMcpToolDescriptionForStorage(workflowRecord.description, workflowRecord.name) + const parameterSchema = + params.parameterSchema && Object.keys(params.parameterSchema).length > 0 + ? params.parameterSchema + : await generateParameterSchemaForWorkflow(workflowRecord.id) + const metadataLimitError = validateMcpToolMetadataForStorage({ + toolName, + toolDescription, + parameterSchema, + }) + if (metadataLimitError) { + throw new WorkflowMcpExpectedError(metadataLimitError, 'validation') + } + + return { + workflowId: workflowRecord.id, + toolName, + toolDescription, + parameterSchema, + } +} + +function sameNullableDate(left: Date | null, right: Date | null): boolean { + if (left === null || right === null) return left === right + return left.getTime() === right.getTime() +} + +function validateWorkflowForMcpServerCreate( + workflowRecord: WorkflowMcpServerCreateWorkflowRecord, + workspaceId: string +): void { + if (workflowRecord.workspaceId !== workspaceId) { + throw new WorkflowMcpExpectedError( + `Workflow is outside this workspace: ${workflowRecord.id}`, + 'forbidden' + ) + } + if (!workflowRecord.isDeployed) { + throw new WorkflowMcpExpectedError( + `Workflow must be deployed before adding as an MCP tool: ${workflowRecord.id}`, + 'validation' + ) + } +} + +function assertWorkflowMcpServerCreateSnapshotCurrent( + preparedWorkflow: WorkflowMcpServerCreateWorkflowRecord, + lockedWorkflow: WorkflowMcpServerCreateWorkflowRecord +): void { + if ( + preparedWorkflow.name !== lockedWorkflow.name || + preparedWorkflow.description !== lockedWorkflow.description || + !sameNullableDate(preparedWorkflow.deployedAt, lockedWorkflow.deployedAt) || + preparedWorkflow.updatedAt.getTime() !== lockedWorkflow.updatedAt.getTime() + ) { + throw new WorkflowMcpExpectedError( + `Workflow changed while creating MCP server, retry shortly: ${preparedWorkflow.id}`, + 'conflict' + ) + } +} + +async function validateWorkflowMcpServerMembershipBudget( + tx: DbOrTx, + workflowIds: string[] +): Promise { + if (workflowIds.length === 0) return null + + const rows = await tx + .select({ + workflowId: workflowMcpTool.workflowId, + serverCount: sql`count(distinct ${workflowMcpTool.serverId})`, + }) + .from(workflowMcpTool) + .where( + and(inArray(workflowMcpTool.workflowId, workflowIds), isNull(workflowMcpTool.archivedAt)) + ) + .groupBy(workflowMcpTool.workflowId) + + for (const row of rows) { + if ((Number(row.serverCount) || 0) >= MAX_MCP_SERVERS_PER_WORKFLOW) { + return `Workflow can be exposed on at most ${MAX_MCP_SERVERS_PER_WORKFLOW} MCP servers: ${row.workflowId}` + } + } + + return null +} + export async function performCreateWorkflowMcpServer( params: PerformCreateWorkflowMcpServerParams ): Promise { try { const name = params.name.trim() - const serverId = generateId() - const [server] = await db - .insert(workflowMcpServer) - .values({ - id: serverId, - workspaceId: params.workspaceId, - createdBy: params.userId, - name, - description: params.description?.trim() || null, - isPublic: params.isPublic ?? false, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - const addedTools: Array<{ workflowId: string; toolName: string }> = [] const workflowIds = params.workflowIds || [] + if (workflowIds.length > MAX_MCP_TOOLS_PER_SERVER) { + return { + success: false, + error: `Workflow MCP servers can include at most ${MAX_MCP_TOOLS_PER_SERVER} tools`, + errorCode: 'validation', + } + } + if (new Set(workflowIds).size !== workflowIds.length) { + return { + success: false, + error: 'Workflow MCP server workflowIds must be unique', + errorCode: 'validation', + } + } + + const preparedTools: PreparedWorkflowMcpTool[] = [] + const preparedToolNames = new Set() + const preparedWorkflows = new Map() + let totalUsage = { schemaBytes: 0, metadataBytes: 0 } if (workflowIds.length > 0) { - const workflows = await db + const workflowRecords = await db .select({ id: workflow.id, name: workflow.name, description: workflow.description, isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId, + deployedAt: workflow.deployedAt, + updatedAt: workflow.updatedAt, }) .from(workflow) .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) - for (const workflowRecord of workflows) { - if (workflowRecord.workspaceId !== params.workspaceId) { - logger.warn('Skipping workflow MCP tool outside workspace', { - workflowId: workflowRecord.id, - workspaceId: params.workspaceId, - }) - continue - } - if (!workflowRecord.isDeployed) { - logger.warn('Skipping undeployed workflow MCP tool', { workflowId: workflowRecord.id }) - continue + const workflowsById = new Map( + workflowRecords.map((workflowRecord) => [workflowRecord.id, workflowRecord]) + ) + + for (const workflowId of workflowIds) { + const workflowRecord = workflowsById.get(workflowId) + if (!workflowRecord) { + return { + success: false, + error: `Workflow not found or archived: ${workflowId}`, + errorCode: 'validation', + } } + + validateWorkflowForMcpServerCreate(workflowRecord, params.workspaceId) + const hasStartBlock = await hasValidStartBlock(workflowRecord.id) if (!hasStartBlock) { - logger.warn('Skipping workflow MCP tool without start block', { - workflowId: workflowRecord.id, - }) - continue + return { + success: false, + error: `Workflow must have a valid start block before adding as an MCP tool: ${workflowRecord.id}`, + errorCode: 'validation', + } } - const toolName = sanitizeToolName(workflowRecord.name) - const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id) - await db.insert(workflowMcpTool).values({ - id: generateId(), - serverId, - workflowId: workflowRecord.id, + const preparedTool = await prepareWorkflowMcpTool({ workflowRecord }) + const { toolName, toolDescription, parameterSchema } = preparedTool + if (preparedToolNames.has(toolName)) { + return { + success: false, + error: `Duplicate MCP tool name after sanitization: ${toolName}`, + errorCode: 'validation', + } + } + preparedToolNames.add(toolName) + totalUsage = addMcpToolMetadataUsage(totalUsage, { toolName, - toolDescription: workflowRecord.description || `Execute ${workflowRecord.name} workflow`, + toolDescription, parameterSchema, + }) + const budgetError = validateMcpServerToolMetadataBudget(totalUsage) + if (budgetError) { + return { success: false, error: budgetError, errorCode: 'validation' } + } + + preparedTools.push(preparedTool) + preparedWorkflows.set(workflowRecord.id, workflowRecord) + } + } + + const { server, addedTools, serverId } = await db.transaction(async (tx) => { + await setWorkflowMcpTransactionLockTimeout(tx) + + if (workflowIds.length > 0) { + const lockedWorkflows = await tx + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + isDeployed: workflow.isDeployed, + workspaceId: workflow.workspaceId, + deployedAt: workflow.deployedAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) + .orderBy(asc(workflow.id)) + .for('update') + + const lockedWorkflowsById = new Map( + lockedWorkflows.map((workflowRecord) => [workflowRecord.id, workflowRecord]) + ) + + for (const workflowId of workflowIds) { + const lockedWorkflow = lockedWorkflowsById.get(workflowId) + if (!lockedWorkflow) { + throw new WorkflowMcpExpectedError( + `Workflow not found or archived: ${workflowId}`, + 'validation' + ) + } + + validateWorkflowForMcpServerCreate(lockedWorkflow, params.workspaceId) + const preparedWorkflow = preparedWorkflows.get(workflowId) + if (!preparedWorkflow) { + throw new WorkflowMcpExpectedError( + `Workflow not found or archived: ${workflowId}`, + 'validation' + ) + } + assertWorkflowMcpServerCreateSnapshotCurrent(preparedWorkflow, lockedWorkflow) + } + } + + const membershipBudgetError = await validateWorkflowMcpServerMembershipBudget(tx, workflowIds) + if (membershipBudgetError) { + throw new WorkflowMcpExpectedError(membershipBudgetError, 'validation') + } + + const newServerId = generateId() + const [createdServer] = await tx + .insert(workflowMcpServer) + .values({ + id: newServerId, + workspaceId: params.workspaceId, + createdBy: params.userId, + name, + description: params.description?.trim() || null, + isPublic: params.isPublic ?? false, createdAt: new Date(), updatedAt: new Date(), }) + .returning() - addedTools.push({ workflowId: workflowRecord.id, toolName }) - } + const insertedTools: Array<{ workflowId: string; toolName: string }> = [] + for (const preparedTool of preparedTools) { + await tx.insert(workflowMcpTool).values({ + id: generateId(), + serverId: newServerId, + workflowId: preparedTool.workflowId, + toolName: preparedTool.toolName, + toolDescription: preparedTool.toolDescription, + parameterSchema: preparedTool.parameterSchema, + createdAt: new Date(), + updatedAt: new Date(), + }) - if (addedTools.length > 0) { - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId: params.workspaceId }) + insertedTools.push({ workflowId: preparedTool.workflowId, toolName: preparedTool.toolName }) } + + return { server: createdServer, addedTools: insertedTools, serverId: newServerId } + }) + + if (addedTools.length > 0) { + mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId: params.workspaceId }) } recordAudit({ @@ -209,6 +506,16 @@ export async function performCreateWorkflowMcpServer( return { success: true, server, addedTools } } catch (error) { + if (error instanceof WorkflowMcpExpectedError) { + return { success: false, error: error.message, errorCode: error.errorCode } + } + if (isWorkflowMcpServerLockTimeout(error)) { + return { + success: false, + error: 'Workflow MCP server is busy, retry shortly', + errorCode: 'conflict', + } + } logger.error('Failed to create workflow MCP server', { error }) return { success: false, error: 'Failed to create workflow MCP server', errorCode: 'internal' } } @@ -270,15 +577,21 @@ export async function performDeleteWorkflowMcpServer( params: PerformDeleteWorkflowMcpServerParams ): Promise { try { - const [server] = await db - .delete(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, params.serverId), - eq(workflowMcpServer.workspaceId, params.workspaceId) + const server = await db.transaction(async (tx) => { + await acquireWorkflowMcpServerLock(tx, params.serverId) + + const [deletedServer] = await tx + .delete(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, params.serverId), + eq(workflowMcpServer.workspaceId, params.workspaceId) + ) ) - ) - .returning() + .returning() + + return deletedServer + }) if (!server) { return { success: false, error: 'Server not found', errorCode: 'not_found' } @@ -304,6 +617,13 @@ export async function performDeleteWorkflowMcpServer( return { success: true, server } } catch (error) { + if (isWorkflowMcpServerLockTimeout(error)) { + return { + success: false, + error: 'Workflow MCP server is busy, retry shortly', + errorCode: 'conflict', + } + } logger.error('Failed to delete workflow MCP server', { error }) return { success: false, error: 'Failed to delete workflow MCP server', errorCode: 'internal' } } @@ -366,50 +686,136 @@ export async function performCreateWorkflowMcpTool( } } - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, params.serverId), - eq(workflowMcpTool.workflowId, params.workflowId), - isNull(workflowMcpTool.archivedAt) + const preparedTool = await prepareWorkflowMcpTool({ + workflowRecord, + toolName: params.toolName, + toolDescription: params.toolDescription, + parameterSchema: params.parameterSchema, + }) + const { toolName, toolDescription, parameterSchema } = preparedTool + + const toolId = generateId() + const tool = await db.transaction(async (tx) => { + await setWorkflowMcpTransactionLockTimeout(tx) + + const [lockedWorkflow] = await tx + .select({ + id: workflow.id, + isDeployed: workflow.isDeployed, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(and(eq(workflow.id, params.workflowId), isNull(workflow.archivedAt))) + .for('update') + .limit(1) + + if (!lockedWorkflow) { + throw new WorkflowMcpExpectedError('Workflow not found', 'not_found') + } + if (lockedWorkflow.workspaceId !== params.workspaceId) { + throw new WorkflowMcpExpectedError( + 'Workflow does not belong to this workspace', + 'validation' ) - ) - .limit(1) + } + if (!lockedWorkflow.isDeployed) { + throw new WorkflowMcpExpectedError( + 'Workflow must be deployed before adding as a tool', + 'validation' + ) + } - if (existingTool) { - return { - success: false, - error: 'This workflow is already added as a tool to this server', - errorCode: 'conflict', + await acquireWorkflowMcpServerLock(tx, params.serverId) + + const existingTools = await tx + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where( + and(eq(workflowMcpTool.serverId, params.serverId), isNull(workflowMcpTool.archivedAt)) + ) + .limit(MAX_MCP_TOOLS_PER_SERVER) + + if (existingTools.length >= MAX_MCP_TOOLS_PER_SERVER) { + throw new WorkflowMcpExpectedError( + `Workflow MCP servers can include at most ${MAX_MCP_TOOLS_PER_SERVER} tools`, + 'validation' + ) } - } - const toolName = sanitizeToolName(params.toolName?.trim() || workflowRecord.name) - const toolDescription = - params.toolDescription?.trim() || - workflowRecord.description || - `Execute ${workflowRecord.name} workflow` - const parameterSchema = - params.parameterSchema && Object.keys(params.parameterSchema).length > 0 - ? params.parameterSchema - : await generateParameterSchemaForWorkflow(params.workflowId) + const [existingTool] = await tx + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, params.serverId), + eq(workflowMcpTool.workflowId, params.workflowId), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) - const toolId = generateId() - const [tool] = await db - .insert(workflowMcpTool) - .values({ - id: toolId, - serverId: params.serverId, - workflowId: params.workflowId, - toolName, - toolDescription, - parameterSchema, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() + if (existingTool) { + throw new WorkflowMcpExpectedError( + 'This workflow is already added as a tool to this server', + 'conflict' + ) + } + + const [nameCollision] = await tx + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, params.serverId), + eq(workflowMcpTool.toolName, toolName), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) + + if (nameCollision) { + throw new WorkflowMcpExpectedError( + `MCP tool name already exists on this server: ${toolName}`, + 'conflict' + ) + } + + const membershipBudgetError = await validateWorkflowMcpServerMembershipBudget(tx, [ + params.workflowId, + ]) + if (membershipBudgetError) { + throw new WorkflowMcpExpectedError(membershipBudgetError, 'validation') + } + + const budgetError = await validateServerToolMetadataBudget( + params.serverId, + [{ toolName, toolDescription, parameterSchema }], + tx + ) + if (budgetError) { + throw new WorkflowMcpExpectedError(budgetError, 'validation') + } + + const [createdTool] = await tx + .insert(workflowMcpTool) + .values({ + id: toolId, + serverId: params.serverId, + workflowId: params.workflowId, + toolName, + toolDescription, + parameterSchema, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + return createdTool + }) + + if (!tool) { + return { success: false, error: 'Failed to add tool', errorCode: 'internal' } + } mcpPubSub?.publishWorkflowToolsChanged({ serverId: params.serverId, @@ -436,6 +842,16 @@ export async function performCreateWorkflowMcpTool( return { success: true, tool } } catch (error) { + if (error instanceof WorkflowMcpExpectedError) { + return { success: false, error: error.message, errorCode: error.errorCode } + } + if (isWorkflowMcpServerLockTimeout(error)) { + return { + success: false, + error: 'Workflow MCP server is busy, retry shortly', + errorCode: 'conflict', + } + } logger.error('Failed to create workflow MCP tool', { error }) return { success: false, error: 'Failed to add tool', errorCode: 'internal' } } @@ -465,20 +881,120 @@ export async function performUpdateWorkflowMcpTool( updateData.toolDescription = params.toolDescription?.trim() || null } if (params.parameterSchema !== undefined) updateData.parameterSchema = params.parameterSchema - const updatedFields = Object.keys(updateData).filter((key) => key !== 'updatedAt') - const [tool] = await db - .update(workflowMcpTool) - .set(updateData) - .where( - and( - eq(workflowMcpTool.id, params.toolId), - eq(workflowMcpTool.serverId, params.serverId), - isNull(workflowMcpTool.archivedAt) + const tool = await db.transaction(async (tx) => { + await acquireWorkflowMcpServerLock(tx, params.serverId) + + const [currentTool] = await tx + .select({ + id: workflowMcpTool.id, + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + parameterSchemaBytes: sql`octet_length(${workflowMcpTool.parameterSchema}::text)`, + }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.id, params.toolId), + eq(workflowMcpTool.serverId, params.serverId), + isNull(workflowMcpTool.archivedAt) + ) ) + .limit(1) + + if (!currentTool) { + throw new WorkflowMcpExpectedError('Tool not found', 'not_found') + } + + const effectiveToolName = updateData.toolName ?? currentTool.toolName + const effectiveToolDescription = + updateData.toolDescription !== undefined + ? updateData.toolDescription + : currentTool.toolDescription + const effectiveParameterSchema = + updateData.parameterSchema !== undefined ? updateData.parameterSchema : undefined + const metadataLimitError = validateMcpToolMetadataForStorage({ + toolName: effectiveToolName, + toolDescription: effectiveToolDescription, + ...(effectiveParameterSchema !== undefined && { + parameterSchema: effectiveParameterSchema, + }), + }) + if (metadataLimitError) { + throw new WorkflowMcpExpectedError(metadataLimitError, 'validation') + } + + if (params.toolName !== undefined && effectiveToolName !== currentTool.toolName) { + const [nameCollision] = await tx + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, params.serverId), + eq(workflowMcpTool.toolName, effectiveToolName), + ne(workflowMcpTool.id, params.toolId), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) + + if (nameCollision) { + throw new WorkflowMcpExpectedError( + `MCP tool name already exists on this server: ${effectiveToolName}`, + 'conflict' + ) + } + } + + const baseUsage = getMcpToolMetadataUsageFromRows( + await getMcpServerToolMetadataUsageRows(tx, params.serverId, params.toolId) ) - .returning() + const currentUsage = addMcpToolMetadataUsageRow(baseUsage, { + id: currentTool.id, + ...getMcpToolMetadataSizes({ + toolName: currentTool.toolName, + toolDescription: currentTool.toolDescription, + }), + parameterSchemaBytes: Number(currentTool.parameterSchemaBytes) || 0, + }) + const proposedUsage = addMcpToolMetadataUsageRow( + baseUsage, + effectiveParameterSchema !== undefined + ? createMcpToolMetadataUsageRow({ + id: currentTool.id, + toolName: effectiveToolName, + toolDescription: effectiveToolDescription, + parameterSchema: effectiveParameterSchema, + }) + : { + id: currentTool.id, + ...getMcpToolMetadataSizes({ + toolName: effectiveToolName, + toolDescription: effectiveToolDescription, + }), + parameterSchemaBytes: Number(currentTool.parameterSchemaBytes) || 0, + } + ) + const budgetError = validateServerToolMetadataBudgetForUpdate(currentUsage, proposedUsage) + if (budgetError) { + throw new WorkflowMcpExpectedError(budgetError, 'validation') + } + + const [updatedTool] = await tx + .update(workflowMcpTool) + .set(updateData) + .where( + and( + eq(workflowMcpTool.id, params.toolId), + eq(workflowMcpTool.serverId, params.serverId), + isNull(workflowMcpTool.archivedAt) + ) + ) + .returning() + + return updatedTool + }) if (!tool) return { success: false, error: 'Tool not found', errorCode: 'not_found' } @@ -506,6 +1022,16 @@ export async function performUpdateWorkflowMcpTool( return { success: true, tool } } catch (error) { + if (error instanceof WorkflowMcpExpectedError) { + return { success: false, error: error.message, errorCode: error.errorCode } + } + if (isWorkflowMcpServerLockTimeout(error)) { + return { + success: false, + error: 'Workflow MCP server is busy, retry shortly', + errorCode: 'conflict', + } + } logger.error('Failed to update workflow MCP tool', { error }) return { success: false, error: 'Failed to update tool', errorCode: 'internal' } } @@ -529,12 +1055,18 @@ export async function performDeleteWorkflowMcpTool( if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' } - const [tool] = await db - .delete(workflowMcpTool) - .where( - and(eq(workflowMcpTool.id, params.toolId), eq(workflowMcpTool.serverId, params.serverId)) - ) - .returning() + const tool = await db.transaction(async (tx) => { + await acquireWorkflowMcpServerLock(tx, params.serverId) + + const [deletedTool] = await tx + .delete(workflowMcpTool) + .where( + and(eq(workflowMcpTool.id, params.toolId), eq(workflowMcpTool.serverId, params.serverId)) + ) + .returning() + + return deletedTool + }) if (!tool) return { success: false, error: 'Tool not found', errorCode: 'not_found' } @@ -557,6 +1089,13 @@ export async function performDeleteWorkflowMcpTool( return { success: true, tool } } catch (error) { + if (isWorkflowMcpServerLockTimeout(error)) { + return { + success: false, + error: 'Workflow MCP server is busy, retry shortly', + errorCode: 'conflict', + } + } logger.error('Failed to delete workflow MCP tool', { error }) return { success: false, error: 'Failed to remove tool', errorCode: 'internal' } } diff --git a/apps/sim/lib/mcp/server-locks.test.ts b/apps/sim/lib/mcp/server-locks.test.ts new file mode 100644 index 00000000000..8b9572a2fe8 --- /dev/null +++ b/apps/sim/lib/mcp/server-locks.test.ts @@ -0,0 +1,15 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isWorkflowMcpServerLockTimeout } from '@/lib/mcp/server-locks' + +describe('MCP server locks', () => { + it('detects Postgres lock timeout errors', () => { + const error = Object.assign(new Error('canceling statement due to lock timeout'), { + code: '55P03', + }) + + expect(isWorkflowMcpServerLockTimeout(error)).toBe(true) + }) +}) diff --git a/apps/sim/lib/mcp/server-locks.ts b/apps/sim/lib/mcp/server-locks.ts new file mode 100644 index 00000000000..02811294699 --- /dev/null +++ b/apps/sim/lib/mcp/server-locks.ts @@ -0,0 +1,21 @@ +import { getPostgresErrorCode } from '@sim/utils/errors' +import { sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' + +const MCP_SERVER_LOCK_TIMEOUT_MS = 3_000 +const LOCK_NOT_AVAILABLE_SQLSTATE = '55P03' + +export async function setWorkflowMcpTransactionLockTimeout(tx: DbOrTx): Promise { + await tx.execute( + sql`select set_config('lock_timeout', ${`${MCP_SERVER_LOCK_TIMEOUT_MS}ms`}, true)` + ) +} + +export async function acquireWorkflowMcpServerLock(tx: DbOrTx, serverId: string): Promise { + await setWorkflowMcpTransactionLockTimeout(tx) + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${serverId}, 0))`) +} + +export function isWorkflowMcpServerLockTimeout(error: unknown): boolean { + return getPostgresErrorCode(error) === LOCK_NOT_AVAILABLE_SQLSTATE +} diff --git a/apps/sim/lib/mcp/tool-limits.ts b/apps/sim/lib/mcp/tool-limits.ts new file mode 100644 index 00000000000..3f95d69d580 --- /dev/null +++ b/apps/sim/lib/mcp/tool-limits.ts @@ -0,0 +1,196 @@ +import { workflowMcpTool } from '@sim/db' +import { and, eq, isNull, ne, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { + MAX_MCP_PARAMETER_SCHEMA_BYTES, + MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES, + MAX_MCP_SERVER_TOOLS_METADATA_BYTES, + MAX_MCP_TOOL_DESCRIPTION_BYTES, + MAX_MCP_TOOL_NAME_BYTES, +} from '@/lib/mcp/constants' + +function utf8Size(value: string): number { + return Buffer.byteLength(value, 'utf-8') +} + +function jsonSize(value: unknown): number | null { + try { + const json = JSON.stringify(value) + return typeof json === 'string' ? utf8Size(json) : null + } catch { + return null + } +} + +export interface McpToolMetadataSizes { + toolNameBytes: number + toolDescriptionBytes: number + parameterSchemaBytes: number +} + +export interface McpToolMetadataUsage { + schemaBytes: number + metadataBytes: number +} + +export interface McpToolMetadataUsageRow extends McpToolMetadataSizes { + id: string +} + +export function getMcpToolMetadataSizes(metadata: { + toolName?: string | null + toolDescription?: string | null + parameterSchema?: unknown +}): McpToolMetadataSizes { + return { + toolNameBytes: metadata.toolName ? utf8Size(metadata.toolName) : 0, + toolDescriptionBytes: metadata.toolDescription ? utf8Size(metadata.toolDescription) : 0, + parameterSchemaBytes: + metadata.parameterSchema !== undefined + ? (jsonSize(metadata.parameterSchema) ?? MAX_MCP_PARAMETER_SCHEMA_BYTES + 1) + : 0, + } +} + +export function addMcpToolMetadataUsage( + usage: McpToolMetadataUsage, + tool: { + toolName?: string | null + toolDescription?: string | null + parameterSchema?: unknown + } +): McpToolMetadataUsage { + const sizes = getMcpToolMetadataSizes(tool) + return { + schemaBytes: usage.schemaBytes + sizes.parameterSchemaBytes, + metadataBytes: + usage.metadataBytes + + sizes.toolNameBytes + + sizes.toolDescriptionBytes + + sizes.parameterSchemaBytes, + } +} + +export function addMcpToolMetadataUsageRow( + usage: McpToolMetadataUsage, + row: McpToolMetadataUsageRow +): McpToolMetadataUsage { + return { + schemaBytes: usage.schemaBytes + row.parameterSchemaBytes, + metadataBytes: + usage.metadataBytes + row.toolNameBytes + row.toolDescriptionBytes + row.parameterSchemaBytes, + } +} + +export function subtractMcpToolMetadataUsageRow( + usage: McpToolMetadataUsage, + row?: McpToolMetadataUsageRow +): McpToolMetadataUsage { + if (!row) return usage + return { + schemaBytes: usage.schemaBytes - row.parameterSchemaBytes, + metadataBytes: + usage.metadataBytes - row.toolNameBytes - row.toolDescriptionBytes - row.parameterSchemaBytes, + } +} + +export function getMcpToolMetadataUsageFromRows( + rows: McpToolMetadataUsageRow[] +): McpToolMetadataUsage { + return rows.reduce(addMcpToolMetadataUsageRow, { schemaBytes: 0, metadataBytes: 0 }) +} + +export function createMcpToolMetadataUsageRow(tool: { + id: string + toolName: string + toolDescription: string | null + parameterSchema: unknown +}): McpToolMetadataUsageRow { + return { id: tool.id, ...getMcpToolMetadataSizes(tool) } +} + +export function validateMcpServerToolMetadataBudget(usage: McpToolMetadataUsage): string | null { + if (usage.schemaBytes > MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES) { + return `MCP server tool schemas exceed maximum size of ${MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES} bytes` + } + if (usage.metadataBytes > MAX_MCP_SERVER_TOOLS_METADATA_BYTES) { + return `MCP server tool metadata exceeds maximum size of ${MAX_MCP_SERVER_TOOLS_METADATA_BYTES} bytes` + } + return null +} + +export function exceedsMcpServerToolMetadataBudget( + usage: McpToolMetadataUsage, + tool: { toolName: string; toolDescription: string | null; parameterSchema: unknown } +): boolean { + return validateMcpServerToolMetadataBudget(addMcpToolMetadataUsage(usage, tool)) !== null +} + +export async function getMcpServerToolMetadataUsageRows( + tx: DbOrTx, + serverId: string, + excludeToolId?: string +): Promise { + const rows = await tx + .select({ + id: workflowMcpTool.id, + toolNameBytes: sql`octet_length(${workflowMcpTool.toolName})`, + toolDescriptionBytes: sql`coalesce(octet_length(${workflowMcpTool.toolDescription}), 0)`, + parameterSchemaBytes: sql`octet_length(${workflowMcpTool.parameterSchema}::text)`, + }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt), + excludeToolId ? ne(workflowMcpTool.id, excludeToolId) : undefined + ) + ) + + return rows.map((row) => ({ + id: row.id, + toolNameBytes: Number(row.toolNameBytes) || 0, + toolDescriptionBytes: Number(row.toolDescriptionBytes) || 0, + parameterSchemaBytes: Number(row.parameterSchemaBytes) || 0, + })) +} + +export function getMcpToolDescriptionForStorage( + description: string | null | undefined, + workflowName: string +): string { + const trimmed = description?.trim() + if (trimmed && utf8Size(trimmed) <= MAX_MCP_TOOL_DESCRIPTION_BYTES) { + return trimmed + } + return `Execute ${workflowName} workflow` +} + +export function validateMcpToolMetadataForStorage(metadata: { + toolName?: string | null + toolDescription?: string | null + parameterSchema?: unknown +}): string | null { + if (metadata.toolName && utf8Size(metadata.toolName) > MAX_MCP_TOOL_NAME_BYTES) { + return `Tool name exceeds maximum size of ${MAX_MCP_TOOL_NAME_BYTES} bytes` + } + + if ( + metadata.toolDescription && + utf8Size(metadata.toolDescription) > MAX_MCP_TOOL_DESCRIPTION_BYTES + ) { + return `Tool description exceeds maximum size of ${MAX_MCP_TOOL_DESCRIPTION_BYTES} bytes` + } + + if (metadata.parameterSchema !== undefined) { + const parameterSchemaBytes = jsonSize(metadata.parameterSchema) + if (parameterSchemaBytes === null) { + return 'Tool parameter schema must be JSON serializable' + } + if (parameterSchemaBytes > MAX_MCP_PARAMETER_SCHEMA_BYTES) { + return `Tool parameter schema exceeds maximum size of ${MAX_MCP_PARAMETER_SCHEMA_BYTES} bytes` + } + } + + return null +} diff --git a/apps/sim/lib/mcp/workflow-mcp-sync.ts b/apps/sim/lib/mcp/workflow-mcp-sync.ts index 06b6e28e6a4..97c7032dac3 100644 --- a/apps/sim/lib/mcp/workflow-mcp-sync.ts +++ b/apps/sim/lib/mcp/workflow-mcp-sync.ts @@ -1,7 +1,20 @@ import { db, workflowMcpServer, workflowMcpTool } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull } from 'drizzle-orm' +import { and, asc, eq, gt, inArray, isNull } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' +import { MAX_MCP_SERVERS_PER_WORKFLOW } from '@/lib/mcp/constants' +import { acquireWorkflowMcpServerLock } from '@/lib/mcp/server-locks' +import { + addMcpToolMetadataUsageRow, + createMcpToolMetadataUsageRow, + exceedsMcpServerToolMetadataBudget, + getMcpServerToolMetadataUsageRows, + getMcpToolMetadataUsageFromRows, + type McpToolMetadataUsage, + type McpToolMetadataUsageRow, + subtractMcpToolMetadataUsageRow, + validateMcpToolMetadataForStorage, +} from '@/lib/mcp/tool-limits' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -11,6 +24,82 @@ import { extractInputFormatFromBlocks, generateToolInputSchema } from './workflo const logger = createLogger('WorkflowMcpSync') const EMPTY_SCHEMA: Record = Object.freeze({ type: 'object', properties: {} }) +const MCP_SYNC_TOOLS_PAGE_SIZE = 100 + +class WorkflowMcpServerFanoutError extends Error { + constructor(workflowId: string) { + super( + `Workflow ${workflowId} is exposed on more than ${MAX_MCP_SERVERS_PER_WORKFLOW} MCP servers` + ) + this.name = 'WorkflowMcpServerFanoutError' + } +} + +interface WorkflowMcpToolSyncRow { + id: string + serverId: string + toolName: string + toolDescription: string | null +} + +interface ServerMetadataUsageState { + usageByToolId: Map + serverUsage: McpToolMetadataUsage +} + +async function listWorkflowMcpToolSyncPage( + tx: DbOrTx, + workflowId: string, + afterToolId?: string, + serverIds?: string[] +): Promise { + return tx + .select({ + id: workflowMcpTool.id, + serverId: workflowMcpTool.serverId, + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.workflowId, workflowId), + isNull(workflowMcpTool.archivedAt), + serverIds && serverIds.length > 0 + ? inArray(workflowMcpTool.serverId, serverIds) + : undefined, + afterToolId ? gt(workflowMcpTool.id, afterToolId) : undefined + ) + ) + .orderBy(asc(workflowMcpTool.id)) + .limit(MCP_SYNC_TOOLS_PAGE_SIZE + 1) +} + +async function collectWorkflowMcpToolServerIds( + tx: DbOrTx, + workflowId: string +): Promise> { + const serverIds = new Set() + let afterToolId: string | undefined + + while (true) { + const page = await listWorkflowMcpToolSyncPage(tx, workflowId, afterToolId) + if (page.length === 0) break + + const pageTools = page.slice(0, MCP_SYNC_TOOLS_PAGE_SIZE) + for (const tool of pageTools) { + serverIds.add(tool.serverId) + if (serverIds.size > MAX_MCP_SERVERS_PER_WORKFLOW) { + throw new WorkflowMcpServerFanoutError(workflowId) + } + } + + if (page.length <= MCP_SYNC_TOOLS_PAGE_SIZE) break + afterToolId = pageTools.at(-1)?.id + } + + return [...serverIds].sort().map((serverId) => ({ serverId })) +} /** * Generate MCP tool parameter schema from workflow blocks. @@ -25,19 +114,14 @@ export function generateSchemaFromBlocks(blocks: Record): Recor /** * Load a workflow's active deployed state and generate its MCP parameter schema. - * Returns a proper JSON Schema derived from the start block's input format, - * or a fallback empty schema if the workflow has no inputs or no active deployment. + * Workflows with no inputs or no active deployment use an empty object schema. */ export async function generateParameterSchemaForWorkflow( workflowId: string ): Promise> { - try { - const deployed = await loadDeployedWorkflowState(workflowId) - if (!deployed?.blocks) return EMPTY_SCHEMA - return generateSchemaFromBlocks(deployed.blocks as Record) - } catch { - return EMPTY_SCHEMA - } + const deployed = await loadDeployedWorkflowState(workflowId) + if (!deployed?.blocks) return EMPTY_SCHEMA + return generateSchemaFromBlocks(deployed.blocks as Record) } interface SyncOptions { @@ -65,58 +149,153 @@ interface SyncOptions { export async function syncMcpToolsForWorkflow( options: SyncOptions ): Promise> { + if (!options.tx) { + const tools = await db.transaction((tx) => + syncMcpToolsForWorkflow({ ...options, tx, notify: false }) + ) + if (options.notify ?? true) notifyMcpToolServers(tools) + return tools + } + const { workflowId, requestId, state, context = 'sync', - tx = db, + tx, notify = true, throwOnError = false, } = options try { - const tools = await tx - .select({ id: workflowMcpTool.id, serverId: workflowMcpTool.serverId }) - .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) - - if (tools.length === 0) { - return [] - } - let workflowState: { blocks?: Record } | null = state ?? null if (!workflowState) { workflowState = await loadDeployedWorkflowState(workflowId) } if (!hasValidStartBlockInState(workflowState as WorkflowState | null)) { - await tx.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId)) - logger.info( - `[${requestId}] Removed ${tools.length} MCP tool(s) - workflow has no start block (${context}): ${workflowId}` - ) - if (notify) notifyMcpToolServers(tools) - return tools + const affectedTools = await removeMcpToolsForWorkflow(workflowId, requestId, tx, false, true) + if (notify) notifyMcpToolServers(affectedTools) + return affectedTools } - const parameterSchema = workflowState?.blocks + const generatedParameterSchema = workflowState?.blocks ? generateSchemaFromBlocks(workflowState.blocks) : EMPTY_SCHEMA + const schemaLimitError = validateMcpToolMetadataForStorage({ + parameterSchema: generatedParameterSchema, + }) + if (schemaLimitError) { + throw new Error(schemaLimitError) + } + const parameterSchema = generatedParameterSchema + + const affectedServerIds = new Set() + const lockedServers = await collectWorkflowMcpToolServerIds(tx, workflowId) + if (lockedServers.length === 0) return [] + + for (const { serverId } of lockedServers) { + await acquireWorkflowMcpServerLock(tx, serverId) + affectedServerIds.add(serverId) + } + const lockedServerIds = [...affectedServerIds] - await tx - .update(workflowMcpTool) - .set({ - parameterSchema, - updatedAt: new Date(), + const usageStateByServer = new Map() + for (const { serverId } of lockedServers) { + const rows = await getMcpServerToolMetadataUsageRows(tx, serverId) + usageStateByServer.set(serverId, { + usageByToolId: new Map(rows.map((row) => [row.id, row])), + serverUsage: getMcpToolMetadataUsageFromRows(rows), }) - .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) + } + + let syncedToolCount = 0 + let afterToolId: string | undefined + + while (true) { + const page = await listWorkflowMcpToolSyncPage(tx, workflowId, afterToolId, lockedServerIds) + if (page.length === 0) break + + const pageTools = page.slice(0, MCP_SYNC_TOOLS_PAGE_SIZE) + const toolsByServer = new Map() + for (const tool of pageTools) { + affectedServerIds.add(tool.serverId) + const serverTools = toolsByServer.get(tool.serverId) ?? [] + serverTools.push(tool) + toolsByServer.set(tool.serverId, serverTools) + } + + for (const [serverId, serverTools] of [...toolsByServer].sort(([left], [right]) => + left.localeCompare(right) + )) { + const usageState = usageStateByServer.get(serverId) + if (!usageState) { + throw new Error(`Missing locked MCP server usage state for server ${serverId}`) + } + const schemaToolIds: string[] = [] + const emptySchemaToolIds: string[] = [] + + for (const tool of serverTools) { + const existingUsage = subtractMcpToolMetadataUsageRow( + usageState.serverUsage, + usageState.usageByToolId.get(tool.id) + ) + const shouldUseEmptySchema = exceedsMcpServerToolMetadataBudget(existingUsage, { + toolName: tool.toolName, + toolDescription: tool.toolDescription, + parameterSchema, + }) + const schemaForTool = shouldUseEmptySchema ? EMPTY_SCHEMA : parameterSchema + + if (shouldUseEmptySchema) { + emptySchemaToolIds.push(tool.id) + } else { + schemaToolIds.push(tool.id) + } + + const updatedUsageRow = createMcpToolMetadataUsageRow({ + id: tool.id, + toolName: tool.toolName, + toolDescription: tool.toolDescription, + parameterSchema: schemaForTool, + }) + usageState.usageByToolId.set(tool.id, updatedUsageRow) + usageState.serverUsage = addMcpToolMetadataUsageRow(existingUsage, updatedUsageRow) + } + + if (schemaToolIds.length > 0) { + await tx + .update(workflowMcpTool) + .set({ + parameterSchema, + updatedAt: new Date(), + }) + .where(inArray(workflowMcpTool.id, schemaToolIds)) + } + + if (emptySchemaToolIds.length > 0) { + await tx + .update(workflowMcpTool) + .set({ + parameterSchema: EMPTY_SCHEMA, + updatedAt: new Date(), + }) + .where(inArray(workflowMcpTool.id, emptySchemaToolIds)) + } + } + + syncedToolCount += pageTools.length + if (page.length <= MCP_SYNC_TOOLS_PAGE_SIZE) break + afterToolId = pageTools.at(-1)?.id + } logger.info( - `[${requestId}] Synced ${tools.length} MCP tool(s) for workflow (${context}): ${workflowId}` + `[${requestId}] Synced ${syncedToolCount} MCP tool(s) for workflow (${context}): ${workflowId}` ) - if (notify) notifyMcpToolServers(tools) - return tools + const affectedTools = [...affectedServerIds].map((serverId) => ({ serverId })) + if (notify) notifyMcpToolServers(affectedTools) + return affectedTools } catch (error) { logger.error(`[${requestId}] Error syncing MCP tools (${context}):`, error) if (throwOnError) throw error @@ -131,18 +310,27 @@ export async function syncMcpToolsForWorkflow( export async function removeMcpToolsForWorkflow( workflowId: string, requestId: string, - tx: DbOrTx = db, + tx?: DbOrTx, notify = true, throwOnError = false ): Promise> { + if (!tx) { + const tools = await db.transaction((transaction) => + removeMcpToolsForWorkflow(workflowId, requestId, transaction, false, throwOnError) + ) + if (notify) notifyMcpToolServers(tools) + return tools + } + try { - const tools = await tx - .select({ id: workflowMcpTool.id, serverId: workflowMcpTool.serverId }) - .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) + const tools = await collectWorkflowMcpToolServerIds(tx, workflowId) if (tools.length === 0) return [] + for (const { serverId } of tools) { + await acquireWorkflowMcpServerLock(tx, serverId) + } + await tx.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId)) logger.info(`[${requestId}] Removed MCP tools for workflow: ${workflowId}`) diff --git a/apps/sim/lib/monitoring/cache-registry.ts b/apps/sim/lib/monitoring/cache-registry.ts deleted file mode 100644 index 19b8753b444..00000000000 --- a/apps/sim/lib/monitoring/cache-registry.ts +++ /dev/null @@ -1,13 +0,0 @@ -const registry = new Map number>() - -export function registerCache(name: string, getSize: () => number): void { - registry.set(name, getSize) -} - -export function getCacheSizes(): Record { - const sizes: Record = {} - for (const [name, getSize] of registry) { - sizes[name] = getSize() - } - return sizes -} diff --git a/apps/sim/lib/monitoring/memory-telemetry.ts b/apps/sim/lib/monitoring/memory-telemetry.ts index f80a0189dce..2845ee1def2 100644 --- a/apps/sim/lib/monitoring/memory-telemetry.ts +++ b/apps/sim/lib/monitoring/memory-telemetry.ts @@ -5,7 +5,6 @@ import v8 from 'node:v8' import { createLogger } from '@sim/logger' -import { getCacheSizes } from '@/lib/monitoring/cache-registry' const logger = createLogger('MemoryTelemetry', { logLevel: 'INFO' }) @@ -34,7 +33,6 @@ export function startMemoryTelemetry(intervalMs = 60_000) { ? process.getActiveResourcesInfo().length : -1, uptimeMin: Math.round(process.uptime() / 60), - cacheSizes: getCacheSizes(), }) }, intervalMs) timer.unref() diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index 9174d05c6b6..a09d9630edf 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -34,6 +34,8 @@ vi.mock('@/lib/table/workflow-columns', () => ({ vi.mock('@/lib/table/validation', () => ({ validateRowSize: vi.fn(() => ({ valid: true, errors: [] })), validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })), + coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })), + coerceRowValues: vi.fn(), validateTableName: vi.fn(() => ({ valid: true, errors: [] })), validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })), getUniqueColumns: vi.fn(() => []), diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index d336add784f..c86a1c92c5c 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -20,6 +20,8 @@ vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/table/validation', () => ({ validateRowSize: vi.fn(() => ({ valid: true, errors: [] })), validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })), + coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })), + coerceRowValues: vi.fn(), validateTableName: vi.fn(() => ({ valid: true, errors: [] })), validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })), getUniqueColumns: vi.fn(() => []), diff --git a/apps/sim/lib/table/__tests__/validation.test.ts b/apps/sim/lib/table/__tests__/validation.test.ts index 557354bf57b..3c9a139f7a8 100644 --- a/apps/sim/lib/table/__tests__/validation.test.ts +++ b/apps/sim/lib/table/__tests__/validation.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest' import { TABLE_LIMITS } from '../constants' import { type ColumnDefinition, + coerceRowToSchema, + coerceRowValues, getUniqueColumns, type TableSchema, validateColumnDefinition, @@ -277,6 +279,127 @@ describe('Validation', () => { }) }) + describe('coerceRowToSchema', () => { + const schema: TableSchema = { + columns: [ + { name: 'name', type: 'string', required: true }, + { name: 'age', type: 'number' }, + { name: 'founded', type: 'number', required: true }, + { name: 'active', type: 'boolean' }, + { name: 'created', type: 'date' }, + { name: 'metadata', type: 'json' }, + ], + } + + it('coerces a numeric string to a number in place', () => { + const data = { name: 'Acme', founded: '1999' } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.founded).toBe(1999) + }) + + it('nulls an un-coercible value for an optional number column', () => { + const data = { name: 'Acme', founded: 2000, age: 'unknown' } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.age).toBeNull() + }) + + it('rejects an un-coercible value for a required number column', () => { + const data = { name: 'Acme', founded: 'unknown' } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(false) + expect(result.errors[0]).toContain('founded must be number') + expect(data.founded).toBe('unknown') + }) + + it('coerces a number to a string for a string column', () => { + const data = { name: 12345, founded: 2000 } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.name).toBe('12345') + }) + + it('coerces "true"/"false" strings to booleans', () => { + const data = { name: 'Acme', founded: 2000, active: 'false' } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.active).toBe(false) + }) + + it('coerces an epoch number to an ISO date string', () => { + const epoch = Date.parse('2024-01-15T00:00:00Z') + const data = { name: 'Acme', founded: 2000, created: epoch } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.created).toBe(new Date(epoch).toISOString()) + }) + + it('coerces a Date instance to an ISO date string', () => { + const date = new Date('2024-01-15T00:00:00Z') + const data = { name: 'Acme', founded: 2000, created: date } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.created).toBe(date.toISOString()) + }) + + it('nulls an out-of-range epoch number for an optional date column without throwing', () => { + const data = { name: 'Acme', founded: 2000, created: 1e20 } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.created).toBeNull() + }) + + it('nulls an invalid Date instance for an optional date column without throwing', () => { + const data = { name: 'Acme', founded: 2000, created: new Date('not-a-date') } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data.created).toBeNull() + }) + + it('leaves already-correct values untouched and passes through json', () => { + const data = { name: 'Acme', founded: 2000, metadata: { k: 'v' } } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(true) + expect(data).toEqual({ name: 'Acme', founded: 2000, metadata: { k: 'v' } }) + }) + + it('still rejects a missing required field', () => { + const data = { name: 'Acme' } + const result = coerceRowToSchema(data, schema) + expect(result.valid).toBe(false) + expect(result.errors).toContain('Missing required field: founded') + }) + }) + + describe('coerceRowValues', () => { + const schema: TableSchema = { + columns: [ + { name: 'name', type: 'string', required: true }, + { name: 'founded', type: 'number', required: true }, + { name: 'age', type: 'number' }, + ], + } + + it('coerces a partial patch in place without flagging absent required fields', () => { + const patch = { age: '42' } + coerceRowValues(patch, schema) + expect(patch.age).toBe(42) + }) + + it('nulls an un-coercible optional value in a patch', () => { + const patch: { age: unknown } = { age: 'nope' } + coerceRowValues(patch as never, schema) + expect(patch.age).toBeNull() + }) + + it('leaves an un-coercible required value in place for downstream validation', () => { + const patch: { founded: unknown } = { founded: 'nope' } + coerceRowValues(patch as never, schema) + expect(patch.founded).toBe('nope') + }) + }) + describe('getUniqueColumns', () => { it('should return only columns with unique=true', () => { const schema: TableSchema = { diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 94d0079bc62..18f1fda418e 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -61,8 +61,9 @@ import type { import { checkBatchUniqueConstraintsDb, checkUniqueConstraintsDb, + coerceRowToSchema, + coerceRowValues, getUniqueColumns, - validateRowAgainstSchema, validateRowSize, validateTableName, validateTableSchema, @@ -913,7 +914,7 @@ export async function insertRow( } // Validate against schema - const schemaValidation = validateRowAgainstSchema(data.data, table.schema) + const schemaValidation = coerceRowToSchema(data.data, table.schema) if (!schemaValidation.valid) { throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) } @@ -1060,7 +1061,7 @@ export async function batchInsertRowsWithTx( throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) } - const schemaValidation = validateRowAgainstSchema(row, table.schema) + const schemaValidation = coerceRowToSchema(row, table.schema) if (!schemaValidation.valid) { throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) } @@ -1201,7 +1202,7 @@ export async function replaceTableRowsWithTx( throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`) } - const schemaValidation = validateRowAgainstSchema(row, table.schema) + const schemaValidation = coerceRowToSchema(row, table.schema) if (!schemaValidation.valid) { throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`) } @@ -1331,22 +1332,24 @@ export async function upsertRow( ) } - const targetValue = data.data[targetColumnName] - if (targetValue === undefined || targetValue === null) { - throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`) - } - // Validate row data const sizeValidation = validateRowSize(data.data) if (!sizeValidation.valid) { throw new Error(sizeValidation.errors.join(', ')) } - const schemaValidation = validateRowAgainstSchema(data.data, schema) + const schemaValidation = coerceRowToSchema(data.data, schema) if (!schemaValidation.valid) { throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) } + // Read the conflict-target value *after* coercion so `matchFilter` branches on + // the persisted type (e.g. a coerced `"123"` → `123` matches existing rows). + const targetValue = data.data[targetColumnName] + if (targetValue === undefined || targetValue === null) { + throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`) + } + // `data->` and `data->>` accept the JSON key as a parameterized text value; // no need for `sql.raw` interpolation. const matchFilter = @@ -1957,7 +1960,7 @@ export async function updateRow( } // Validate against schema - const schemaValidation = validateRowAgainstSchema(mergedData, table.schema) + const schemaValidation = coerceRowToSchema(mergedData, table.schema) if (!schemaValidation.valid) { throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) } @@ -2167,6 +2170,12 @@ export async function updateRowsByFilter( return { affectedCount: 0, affectedRowIds: [] } } + // Coerce the patch itself in place — the write below persists `data.data` + // (as `patchJson`), so coercing only the per-row merged copies would be + // discarded. The merged validation in the loop still enforces required + // fields against the full row. + coerceRowValues(data.data, table.schema) + for (const row of matchingRows) { const existingData = row.data as RowData const mergedData = { ...existingData, ...data.data } @@ -2176,7 +2185,7 @@ export async function updateRowsByFilter( throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`) } - const schemaValidation = validateRowAgainstSchema(mergedData, table.schema) + const schemaValidation = coerceRowToSchema(mergedData, table.schema) if (!schemaValidation.valid) { throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`) } @@ -2334,7 +2343,7 @@ export async function batchUpdateRows( throw new Error(`Row ${update.rowId}: ${sizeValidation.errors.join(', ')}`) } - const schemaValidation = validateRowAgainstSchema(merged, table.schema) + const schemaValidation = coerceRowToSchema(merged, table.schema) if (!schemaValidation.valid) { throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`) } @@ -3247,8 +3256,8 @@ export async function updateWorkflowGroup( // Resolve the new leaf type for each remap so the column's declared type // matches what the new mapping produces. Without this, a string→number - // remap would keep `type: 'string'` and validateRowAgainstSchema would - // reject every backfilled value. + // remap would keep `type: 'string'` and coerceRowToSchema would coerce + // every backfilled value toward the wrong type. try { const [ { loadWorkflowFromNormalizedTables }, diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index f173810e51c..4474710aac6 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -7,7 +7,7 @@ import { userTableRows } from '@sim/db/schema' import { and, eq, or, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants' -import type { ColumnDefinition, RowData, TableSchema, ValidationResult } from './types' +import type { ColumnDefinition, JsonValue, RowData, TableSchema, ValidationResult } from './types' export type { ColumnDefinition, TableSchema, ValidationResult } @@ -57,7 +57,7 @@ export async function validateRowData( } } - const schemaValidation = validateRowAgainstSchema(rowData, schema) + const schemaValidation = coerceRowToSchema(rowData, schema) if (!schemaValidation.valid) { return { valid: false, @@ -105,7 +105,7 @@ export async function validateBatchRows( continue } - const schemaValidation = validateRowAgainstSchema(rowData, schema) + const schemaValidation = coerceRowToSchema(rowData, schema) if (!schemaValidation.valid) { errors.push({ row: i, errors: schemaValidation.errors }) } @@ -255,6 +255,96 @@ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): Va return { valid: errors.length === 0, errors } } +/** + * Attempts to coerce a non-null value to a column's declared type. Returns the + * coerced value when the value already matches or can be converted without + * ambiguity (e.g. the string `"1999"` to the number `1999`), and `ok: false` + * when no safe conversion exists. + */ +function coerceValueToColumnType( + value: JsonValue, + type: ColumnDefinition['type'] +): { ok: true; value: JsonValue } | { ok: false } { + switch (type) { + case 'string': + if (typeof value === 'string') return { ok: true, value } + if (typeof value === 'number' || typeof value === 'boolean') { + return { ok: true, value: String(value) } + } + return { ok: false } + case 'number': + if (typeof value === 'number') { + return Number.isFinite(value) ? { ok: true, value } : { ok: false } + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + return Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false } + } + return { ok: false } + case 'boolean': + if (typeof value === 'boolean') return { ok: true, value } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true') return { ok: true, value: true } + if (normalized === 'false') return { ok: true, value: false } + } + return { ok: false } + case 'date': { + if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return { ok: true, value } + // Date instances and epoch numbers may still be out of the representable + // range (>±8.64e15ms) — guard `toISOString()`, which throws RangeError on + // an Invalid Date, so an over-range value degrades to `{ ok: false }` + // rather than crashing the write. + const date = + value instanceof Date ? value : typeof value === 'number' ? new Date(value) : null + if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() } + return { ok: false } + } + default: + return { ok: true, value } + } +} + +/** + * Coerces each present value in `data` toward its column's declared type **in + * place**. Values that already match are untouched; unambiguous conversions + * (e.g. `"1999"` → `1999`) are applied; values that cannot be coerced are set to + * `null` when the column is optional, or left in place when required (so a + * subsequent {@link validateRowAgainstSchema} reports them). + * + * Operates per-present-column, so it is safe on a partial patch (columns absent + * from `data` are skipped — it never invents a missing-required-field error). + */ +export function coerceRowValues(data: RowData, schema: TableSchema): void { + for (const column of schema.columns) { + const value = data[column.name] + if (value === null || value === undefined) continue + + const coerced = coerceValueToColumnType(value, column.type) + if (coerced.ok) { + data[column.name] = coerced.value + } else if (!column.required) { + data[column.name] = null + } + } +} + +/** + * Coerces a full row toward its schema **in place** (see {@link coerceRowValues}) + * then validates the result. + * + * This is the write-path entry point — callers that persist a complete row use + * it instead of {@link validateRowAgainstSchema} so a single off-type field (a + * tool returning `"unknown"` for a numeric column, say) nulls that one cell + * rather than failing the entire row write. Callers persisting only a partial + * patch should use {@link coerceRowValues} on the patch and validate the merged + * row separately. + */ +export function coerceRowToSchema(data: RowData, schema: TableSchema): ValidationResult { + coerceRowValues(data, schema) + return validateRowAgainstSchema(data, schema) +} + /** Validates row data size is within limits. */ export function validateRowSize(data: RowData): ValidationResult { const size = JSON.stringify(data).length diff --git a/apps/sim/lib/webhooks/providers/instantly.ts b/apps/sim/lib/webhooks/providers/instantly.ts new file mode 100644 index 00000000000..17bd554e734 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/instantly.ts @@ -0,0 +1,269 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { NextResponse } from 'next/server' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' +import { instantlyUrl } from '@/tools/instantly/utils' + +const logger = createLogger('WebhookProvider:Instantly') +const SIM_WEBHOOK_TOKEN_HEADER = 'x-sim-webhook-token' + +export const instantlyHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null { + const secretToken = providerConfig.secretToken as string | undefined + if (!secretToken) { + logger.warn(`[${requestId}] Instantly webhook secret token is missing`) + return new NextResponse('Unauthorized', { status: 401 }) + } + + if (!verifyTokenAuth(request, secretToken, SIM_WEBHOOK_TOKEN_HEADER)) { + logger.warn(`[${requestId}] Unauthorized Instantly webhook request`) + return new NextResponse('Unauthorized', { status: 401 }) + } + + return null + }, + + async matchEvent({ body, providerConfig, requestId }: EventMatchContext): Promise { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId) return true + + if (!isRecord(body)) { + logger.warn(`[${requestId}] Instantly webhook payload was not an object`) + return false + } + + const { isInstantlyEventMatch } = await import('@/triggers/instantly/utils') + if (!isInstantlyEventMatch(triggerId, body)) { + logger.info(`[${requestId}] Instantly event did not match trigger`, { + triggerId, + eventType: body.event_type, + }) + return false + } + + return true + }, + + async formatInput({ body }: FormatInputContext): Promise { + const payload = isRecord(body) ? body : {} + + return { + input: { + timestamp: toStringOrNull(payload.timestamp), + eventType: toStringOrNull(payload.event_type), + workspace: toStringOrNull(payload.workspace), + campaignId: toStringOrNull(payload.campaign_id), + campaignName: toStringOrNull(payload.campaign_name), + leadEmail: toStringOrNull(payload.lead_email), + emailAccount: toStringOrNull(payload.email_account), + uniboxUrl: toStringOrNull(payload.unibox_url), + step: toNumberOrNull(payload.step), + variant: toNumberOrNull(payload.variant), + isFirst: toBooleanOrNull(payload.is_first), + emailId: toStringOrNull(payload.email_id), + emailSubject: toStringOrNull(payload.email_subject), + emailText: toStringOrNull(payload.email_text), + emailHtml: toStringOrNull(payload.email_html), + replyTextSnippet: toStringOrNull(payload.reply_text_snippet), + replySubject: toStringOrNull(payload.reply_subject), + replyText: toStringOrNull(payload.reply_text), + replyHtml: toStringOrNull(payload.reply_html), + payload, + }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.triggerApiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const campaignId = optionalId(providerConfig.triggerCampaignId) + + if (!apiKey?.trim()) { + throw new Error('Instantly API Key is required.') + } + + if (!triggerId) { + throw new Error('Instantly trigger ID is required.') + } + + const { getInstantlySubscriptionEventTypeForTrigger } = await import( + '@/triggers/instantly/utils' + ) + const eventType = getInstantlySubscriptionEventTypeForTrigger(triggerId) + if (!eventType) { + throw new Error(`Unknown Instantly trigger type: ${triggerId}`) + } + + const secretToken = + typeof providerConfig.secretToken === 'string' && providerConfig.secretToken.length > 0 + ? providerConfig.secretToken + : generateShortId(32) + + const requestBody: Record = { + name: `Sim - ${triggerId.replace(/^instantly_/, '').replace(/_/g, ' ')}`, + target_hook_url: getNotificationUrl(webhook), + event_type: eventType, + headers: { + 'X-Sim-Webhook-Token': secretToken, + }, + } + + if (campaignId) { + requestBody.campaign = campaignId + } + + logger.info(`[${requestId}] Creating Instantly webhook`, { + triggerId, + eventType, + hasCampaignId: Boolean(campaignId), + webhookId: webhook.id, + }) + + const response = await fetch(instantlyUrl('/api/v2/webhooks'), { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await parseJsonResponse(response) + if (!response.ok) { + const message = extractInstantlyError(responseBody) + logger.error(`[${requestId}] Failed to create Instantly webhook`, { + status: response.status, + message, + response: responseBody, + }) + + if (response.status === 401 || response.status === 403) { + throw new Error('Invalid Instantly API Key or missing webhook permissions.') + } + + if (response.status === 402) { + throw new Error('Instantly webhook creation requires an active paid plan.') + } + + throw new Error( + message ? `Instantly error: ${message}` : 'Failed to create Instantly webhook' + ) + } + + const externalId = responseBody?.id + if (typeof externalId !== 'string' || externalId.length === 0) { + throw new Error('Instantly webhook was created but the API response did not include an ID.') + } + + logger.info(`[${requestId}] Successfully created Instantly webhook`, { + externalId, + webhookId: webhook.id, + }) + + return { providerConfigUpdates: { externalId, secretToken } } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.triggerApiKey as string | undefined + const externalId = providerConfig.externalId as string | undefined + + if (!apiKey?.trim() || !externalId?.trim()) { + logger.warn(`[${requestId}] Missing Instantly webhook cleanup configuration`, { + webhookId: webhook.id, + hasApiKey: Boolean(apiKey), + hasExternalId: Boolean(externalId), + }) + if (ctx.strict) throw new Error('Missing Instantly webhook cleanup configuration') + return + } + + const response = await fetch( + instantlyUrl(`/api/v2/webhooks/${encodeURIComponent(externalId.trim())}`), + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + }, + } + ) + + if (!response.ok && response.status !== 404) { + const responseBody = await parseJsonResponse(response) + logger.warn(`[${requestId}] Failed to delete Instantly webhook`, { + status: response.status, + response: responseBody, + }) + if (ctx.strict) throw new Error(`Failed to delete Instantly webhook: ${response.status}`) + return + } + + await response.body?.cancel() + logger.info(`[${requestId}] Successfully deleted Instantly webhook`, { + externalId, + webhookId: webhook.id, + }) + } catch (error) { + logger.warn(`[${requestId}] Error deleting Instantly webhook`, { + message: toError(error).message, + }) + if (ctx.strict) throw error + } + }, +} + +async function parseJsonResponse(response: Response): Promise | null> { + try { + const body: unknown = await response.json() + return isRecord(body) ? body : null + } catch { + return null + } +} + +function extractInstantlyError(body: Record | null): string | null { + if (!body) return null + if (typeof body.message === 'string') return body.message + if (typeof body.error === 'string') return body.error + return null +} + +function toStringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function toNumberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function toBooleanOrNull(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function optionalId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + if (trimmed === '' || trimmed === '-') return undefined + return trimmed +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 204be8f8a4b..44b3a0b5009 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -19,6 +19,7 @@ import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' import { grainHandler } from '@/lib/webhooks/providers/grain' import { greenhouseHandler } from '@/lib/webhooks/providers/greenhouse' import { imapHandler } from '@/lib/webhooks/providers/imap' +import { instantlyHandler } from '@/lib/webhooks/providers/instantly' import { intercomHandler } from '@/lib/webhooks/providers/intercom' import { jiraHandler } from '@/lib/webhooks/providers/jira' import { jsmHandler } from '@/lib/webhooks/providers/jsm' @@ -69,6 +70,7 @@ const PROVIDER_HANDLERS: Record = { greenhouse: greenhouseHandler, imap: imapHandler, intercom: intercomHandler, + instantly: instantlyHandler, jira: jiraHandler, jsm: jsmHandler, lemlist: lemlistHandler, diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index fc7711b5892..b33003f7a47 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -10,6 +10,7 @@ import { } from '@/lib/core/outbox/service' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' +import { setWorkflowMcpTransactionLockTimeout } from '@/lib/mcp/server-locks' import { notifyMcpToolServers, removeMcpToolsForWorkflow, @@ -450,12 +451,14 @@ async function removeMcpToolsIfStillUndeployed( requestId: string ): Promise { const tools = await db.transaction(async (tx) => { + await setWorkflowMcpTransactionLockTimeout(tx) + const [workflowRecord] = await tx .select({ id: workflowTable.id, isDeployed: workflowTable.isDeployed }) .from(workflowTable) .where(eq(workflowTable.id, workflowId)) - .limit(1) .for('update') + .limit(1) if (!workflowRecord || workflowRecord.isDeployed) return [] return removeMcpToolsForWorkflow(workflowId, requestId, tx, false, true) @@ -497,12 +500,14 @@ async function syncMcpToolsIfStillActive(params: { state: { blocks?: Record } }): Promise { const tools = await db.transaction(async (tx) => { + await setWorkflowMcpTransactionLockTimeout(tx) + const [workflowRecord] = await tx .select({ id: workflowTable.id }) .from(workflowTable) .where(eq(workflowTable.id, params.workflowId)) - .limit(1) .for('update') + .limit(1) if (!workflowRecord) return [] diff --git a/apps/sim/lib/workflows/schedules/deploy.ts b/apps/sim/lib/workflows/schedules/deploy.ts index 00d6e7075a3..1973a2cb8b9 100644 --- a/apps/sim/lib/workflows/schedules/deploy.ts +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -118,6 +118,7 @@ export async function createSchedulesForDeploy( timezone, status: 'active', failedCount: 0, + infraRetryCount: 0, } const setValues = { @@ -129,6 +130,7 @@ export async function createSchedulesForDeploy( timezone, status: 'active', failedCount: 0, + infraRetryCount: 0, } await tx diff --git a/apps/sim/lib/workflows/schedules/execution-limits.ts b/apps/sim/lib/workflows/schedules/execution-limits.ts new file mode 100644 index 00000000000..a5bb9c5bdc0 --- /dev/null +++ b/apps/sim/lib/workflows/schedules/execution-limits.ts @@ -0,0 +1,42 @@ +import { env, envNumber } from '@/lib/core/config/env' + +export const SCHEDULE_EXECUTION_QUEUE_NAME = 'schedule-execution' + +export const SCHEDULE_EXECUTION_CONCURRENCY_LIMIT = envNumber( + env.SCHEDULE_EXECUTION_CONCURRENCY_LIMIT, + 50, + { min: 1, integer: true } +) + +export const SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER = envNumber( + env.SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER, + 2, + { min: 1, integer: true } +) + +export const SCHEDULE_WORKFLOW_ENQUEUE_LIMIT = + SCHEDULE_EXECUTION_CONCURRENCY_LIMIT * SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER + +export const SCHEDULE_JITTER_MAX_MS = envNumber(env.SCHEDULE_JITTER_MAX_MS, 30_000, { + min: 0, + integer: true, +}) + +export const SCHEDULE_INFRA_RETRY_BASE_MS = envNumber(env.SCHEDULE_INFRA_RETRY_BASE_MS, 60_000, { + min: 1, + integer: true, +}) + +export const SCHEDULE_INFRA_RETRY_MAX_MS = envNumber(env.SCHEDULE_INFRA_RETRY_MAX_MS, 5 * 60_000, { + min: 1, + integer: true, +}) + +export const SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS = envNumber( + env.SCHEDULE_INFRA_RETRY_MAX_ATTEMPTS, + 10, + { + min: 1, + integer: true, + } +) diff --git a/apps/sim/tools/agentmail/get_message.ts b/apps/sim/tools/agentmail/get_message.ts index 4db694244ff..60ea59f3e0a 100644 --- a/apps/sim/tools/agentmail/get_message.ts +++ b/apps/sim/tools/agentmail/get_message.ts @@ -54,6 +54,8 @@ export const agentmailGetMessageTool: ToolConfig } @@ -417,6 +419,7 @@ export interface ListMessagesResult extends ToolResponse { to: string[] subject: string | null preview: string | null + timestamp: string | null createdAt: string }> count: number @@ -442,6 +445,8 @@ export interface GetMessageResult extends ToolResponse { subject: string | null text: string | null html: string | null + labels: string[] + timestamp: string | null createdAt: string } } diff --git a/apps/sim/tools/agentphone/get_conversation.ts b/apps/sim/tools/agentphone/get_conversation.ts index ee8c13d7411..236c145fad0 100644 --- a/apps/sim/tools/agentphone/get_conversation.ts +++ b/apps/sim/tools/agentphone/get_conversation.ts @@ -81,6 +81,7 @@ export const agentphoneGetConversationTool: ToolConfig< direction: (msg.direction as string) ?? '', channel: (msg.channel as string | null) ?? null, mediaUrl: (msg.mediaUrl as string | null) ?? null, + mediaUrls: Array.isArray(msg.mediaUrls) ? (msg.mediaUrls as string[]) : [], receivedAt: (msg.receivedAt as string) ?? '', }) ) @@ -129,6 +130,11 @@ export const agentphoneGetConversationTool: ToolConfig< direction: { type: 'string', description: 'inbound or outbound' }, channel: { type: 'string', description: 'sms, mms, or imessage', optional: true }, mediaUrl: { type: 'string', description: 'Attached media URL', optional: true }, + mediaUrls: { + type: 'array', + description: 'All attached media URLs', + items: { type: 'string' }, + }, receivedAt: { type: 'string', description: 'ISO 8601 timestamp' }, }, }, diff --git a/apps/sim/tools/agentphone/get_conversation_messages.ts b/apps/sim/tools/agentphone/get_conversation_messages.ts index 66f53ec7c42..485f0d0ab84 100644 --- a/apps/sim/tools/agentphone/get_conversation_messages.ts +++ b/apps/sim/tools/agentphone/get_conversation_messages.ts @@ -85,6 +85,7 @@ export const agentphoneGetConversationMessagesTool: ToolConfig< direction: (msg.direction as string) ?? '', channel: (msg.channel as string | null) ?? null, mediaUrl: (msg.mediaUrl as string | null) ?? null, + mediaUrls: Array.isArray(msg.mediaUrls) ? (msg.mediaUrls as string[]) : [], receivedAt: (msg.receivedAt as string) ?? '', }) ), @@ -107,6 +108,11 @@ export const agentphoneGetConversationMessagesTool: ToolConfig< direction: { type: 'string', description: 'inbound or outbound' }, channel: { type: 'string', description: 'sms, mms, or imessage', optional: true }, mediaUrl: { type: 'string', description: 'Attached media URL', optional: true }, + mediaUrls: { + type: 'array', + description: 'All attached media URLs', + items: { type: 'string' }, + }, receivedAt: { type: 'string', description: 'ISO 8601 timestamp' }, }, }, diff --git a/apps/sim/tools/agentphone/list_contacts.ts b/apps/sim/tools/agentphone/list_contacts.ts index 19f9d1bae14..0541c48034a 100644 --- a/apps/sim/tools/agentphone/list_contacts.ts +++ b/apps/sim/tools/agentphone/list_contacts.ts @@ -31,7 +31,7 @@ export const agentphoneListContactsTool: ToolConfig< type: 'number', required: false, visibility: 'user-or-llm', - description: 'Number of results to return (default 50)', + description: 'Number of results to return (default 50, max 200)', }, offset: { type: 'number', diff --git a/apps/sim/tools/agentphone/types.ts b/apps/sim/tools/agentphone/types.ts index 1d095a9510c..ae034a8a688 100644 --- a/apps/sim/tools/agentphone/types.ts +++ b/apps/sim/tools/agentphone/types.ts @@ -41,6 +41,7 @@ export interface AgentPhoneConversationMessage { direction: string channel: string | null mediaUrl: string | null + mediaUrls: string[] receivedAt: string } diff --git a/apps/sim/tools/agentphone/update_conversation.ts b/apps/sim/tools/agentphone/update_conversation.ts index 2cf6f75387e..aa623c87404 100644 --- a/apps/sim/tools/agentphone/update_conversation.ts +++ b/apps/sim/tools/agentphone/update_conversation.ts @@ -82,6 +82,7 @@ export const agentphoneUpdateConversationTool: ToolConfig< direction: (message.direction as string) ?? '', channel: (message.channel as string | null) ?? null, mediaUrl: (message.mediaUrl as string | null) ?? null, + mediaUrls: Array.isArray(message.mediaUrls) ? (message.mediaUrls as string[]) : [], receivedAt: (message.receivedAt as string) ?? '', }) ) @@ -138,6 +139,11 @@ export const agentphoneUpdateConversationTool: ToolConfig< description: 'Media URL if any', optional: true, }, + mediaUrls: { + type: 'array', + description: 'All attached media URLs', + items: { type: 'string' }, + }, receivedAt: { type: 'string', description: 'ISO 8601 timestamp' }, }, }, diff --git a/apps/sim/tools/instantly/activate_campaign.ts b/apps/sim/tools/instantly/activate_campaign.ts new file mode 100644 index 00000000000..b9755b082ea --- /dev/null +++ b/apps/sim/tools/instantly/activate_campaign.ts @@ -0,0 +1,52 @@ +import type { + InstantlyActivateCampaignParams, + InstantlyCampaignResponse, +} from '@/tools/instantly/types' +import { + campaignOutputs, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + mapCampaign, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const activateCampaignTool: ToolConfig< + InstantlyActivateCampaignParams, + InstantlyCampaignResponse +> = { + id: 'instantly_activate_campaign', + name: 'Instantly Activate Campaign', + description: 'Activates, starts, or resumes an Instantly V2 campaign.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + campaignId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Campaign ID', + }, + }, + request: { + url: (params) => instantlyUrl(`/api/v2/campaigns/${params.campaignId.trim()}/activate`), + method: 'POST', + headers: instantlyHeaders, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const campaign = mapCampaign(data) + + return { + success: true, + output: { + campaign, + id: campaign.id, + name: campaign.name, + status: campaign.status, + }, + } + }, + outputs: campaignOutputs, +} diff --git a/apps/sim/tools/instantly/create_campaign.ts b/apps/sim/tools/instantly/create_campaign.ts new file mode 100644 index 00000000000..d900d912c42 --- /dev/null +++ b/apps/sim/tools/instantly/create_campaign.ts @@ -0,0 +1,136 @@ +import type { + InstantlyCampaignResponse, + InstantlyCreateCampaignParams, +} from '@/tools/instantly/types' +import { + campaignOutputs, + compactBody, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + mapCampaign, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const createCampaignTool: ToolConfig< + InstantlyCreateCampaignParams, + InstantlyCampaignResponse +> = { + id: 'instantly_create_campaign', + name: 'Instantly Create Campaign', + description: 'Creates an Instantly V2 campaign using the documented campaign schedule schema.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Campaign name', + }, + campaign_schedule: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Campaign schedule object with schedules array', + }, + sequences: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Campaign sequence definitions', + items: { type: 'object', description: 'Sequence object' }, + }, + email_list: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Sending email accounts', + items: { type: 'string', description: 'Email address' }, + }, + daily_limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Daily sending limit', + }, + daily_max_leads: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Daily maximum new leads to contact', + }, + open_tracking: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to track opens', + }, + stop_on_reply: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to stop the campaign on reply', + }, + link_tracking: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to track links', + }, + text_only: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the campaign is text only', + }, + email_gap: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Gap between emails in minutes', + }, + pl_value: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Value of every positive lead', + }, + }, + request: { + url: () => instantlyUrl('/api/v2/campaigns'), + method: 'POST', + headers: instantlyHeaders, + body: (params) => + compactBody({ + name: params.name, + campaign_schedule: params.campaign_schedule, + sequences: params.sequences, + pl_value: params.pl_value, + email_gap: params.email_gap, + text_only: params.text_only, + email_list: params.email_list, + daily_limit: params.daily_limit, + stop_on_reply: params.stop_on_reply, + link_tracking: params.link_tracking, + open_tracking: params.open_tracking, + daily_max_leads: params.daily_max_leads, + }), + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const campaign = mapCampaign(data) + + return { + success: true, + output: { + campaign, + id: campaign.id, + name: campaign.name, + status: campaign.status, + }, + } + }, + outputs: campaignOutputs, +} diff --git a/apps/sim/tools/instantly/create_lead.ts b/apps/sim/tools/instantly/create_lead.ts new file mode 100644 index 00000000000..21ed22974f7 --- /dev/null +++ b/apps/sim/tools/instantly/create_lead.ts @@ -0,0 +1,187 @@ +import type { InstantlyCreateLeadParams, InstantlyLeadResponse } from '@/tools/instantly/types' +import { + compactBody, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + leadOutputs, + mapLead, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const createLeadTool: ToolConfig = { + id: 'instantly_create_lead', + name: 'Instantly Create Lead', + description: 'Creates an Instantly V2 lead in a campaign or lead list.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + campaign: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Campaign ID associated with the lead', + }, + list_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead list ID associated with the lead', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead email address. Required when adding to a campaign.', + }, + first_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead first name', + }, + last_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead last name', + }, + company_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead company name', + }, + job_title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead job title', + }, + phone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead phone number', + }, + website: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead website', + }, + personalization: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead personalization text', + }, + lt_interest_status: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Lead interest status value', + }, + pl_value_lead: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Potential value of the lead', + }, + assigned_to: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Organization user ID assigned to the lead', + }, + skip_if_in_workspace: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Skip if the lead already exists in the workspace', + }, + skip_if_in_campaign: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Skip if the lead already exists in the campaign', + }, + skip_if_in_list: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Skip if the lead already exists in the list', + }, + blocklist_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Blocklist ID to check for the lead', + }, + verify_leads_for_lead_finder: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to verify leads imported from Lead Finder', + }, + verify_leads_on_import: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to verify leads on import', + }, + custom_variables: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Custom variable object with string, number, boolean, or null values', + }, + }, + request: { + url: () => instantlyUrl('/api/v2/leads'), + method: 'POST', + headers: instantlyHeaders, + body: (params) => + compactBody({ + campaign: params.campaign, + list_id: params.list_id, + email: params.email, + first_name: params.first_name, + last_name: params.last_name, + company_name: params.company_name, + job_title: params.job_title, + phone: params.phone, + website: params.website, + personalization: params.personalization, + lt_interest_status: params.lt_interest_status, + pl_value_lead: params.pl_value_lead, + assigned_to: params.assigned_to, + skip_if_in_workspace: params.skip_if_in_workspace, + skip_if_in_campaign: params.skip_if_in_campaign, + skip_if_in_list: params.skip_if_in_list, + blocklist_id: params.blocklist_id, + verify_leads_for_lead_finder: params.verify_leads_for_lead_finder, + verify_leads_on_import: params.verify_leads_on_import, + custom_variables: params.custom_variables, + }), + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const lead = mapLead(data) + + return { + success: true, + output: { + lead, + id: lead.id, + email_address: lead.email, + first_name: lead.first_name, + last_name: lead.last_name, + campaign: lead.campaign, + status: lead.status, + }, + } + }, + outputs: leadOutputs, +} diff --git a/apps/sim/tools/instantly/create_lead_list.ts b/apps/sim/tools/instantly/create_lead_list.ts new file mode 100644 index 00000000000..288b61ca50a --- /dev/null +++ b/apps/sim/tools/instantly/create_lead_list.ts @@ -0,0 +1,70 @@ +import type { + InstantlyCreateLeadListParams, + InstantlyLeadListResponse, +} from '@/tools/instantly/types' +import { + compactBody, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + leadListOutputs, + mapLeadList, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const createLeadListTool: ToolConfig< + InstantlyCreateLeadListParams, + InstantlyLeadListResponse +> = { + id: 'instantly_create_lead_list', + name: 'Instantly Create Lead List', + description: 'Creates an Instantly V2 lead list.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Lead list name', + }, + has_enrichment_task: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether this list runs enrichment for every added lead', + }, + owned_by: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'User ID of the lead list owner', + }, + }, + request: { + url: () => instantlyUrl('/api/v2/lead-lists'), + method: 'POST', + headers: instantlyHeaders, + body: (params) => + compactBody({ + name: params.name, + has_enrichment_task: params.has_enrichment_task, + owned_by: params.owned_by, + }), + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const leadList = mapLeadList(data) + + return { + success: true, + output: { + lead_list: leadList, + id: leadList.id, + name: leadList.name, + }, + } + }, + outputs: leadListOutputs, +} diff --git a/apps/sim/tools/instantly/delete_leads.ts b/apps/sim/tools/instantly/delete_leads.ts new file mode 100644 index 00000000000..8e313a2df8e --- /dev/null +++ b/apps/sim/tools/instantly/delete_leads.ts @@ -0,0 +1,82 @@ +import type { + InstantlyDeleteLeadsParams, + InstantlyDeleteLeadsResponse, +} from '@/tools/instantly/types' +import { + asRecord, + compactBody, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteLeadsTool: ToolConfig = + { + id: 'instantly_delete_leads', + name: 'Instantly Delete Leads', + description: 'Deletes Instantly V2 leads in bulk from a campaign or lead list.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + campaign_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Campaign ID to delete leads from. Required if list_id is not provided.', + }, + list_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead list ID to delete leads from. Required if campaign_id is not provided.', + }, + status: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Optional lead status filter', + }, + ids: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Specific lead IDs to delete', + items: { type: 'string', description: 'Lead ID' }, + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of matching leads to delete, up to 10000', + }, + }, + request: { + url: () => instantlyUrl('/api/v2/leads'), + method: 'DELETE', + headers: instantlyHeaders, + body: (params) => + compactBody({ + campaign_id: params.campaign_id, + list_id: params.list_id, + status: params.status, + ids: params.ids, + limit: params.limit, + }), + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const result = asRecord(data) + + return { + success: true, + output: { + count: typeof result.count === 'number' ? result.count : null, + }, + } + }, + outputs: { + count: { type: 'number', description: 'Number of leads deleted', optional: true }, + }, + } diff --git a/apps/sim/tools/instantly/get_lead.ts b/apps/sim/tools/instantly/get_lead.ts new file mode 100644 index 00000000000..1f7239d68ff --- /dev/null +++ b/apps/sim/tools/instantly/get_lead.ts @@ -0,0 +1,49 @@ +import type { InstantlyGetLeadParams, InstantlyLeadResponse } from '@/tools/instantly/types' +import { + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + leadOutputs, + mapLead, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const getLeadTool: ToolConfig = { + id: 'instantly_get_lead', + name: 'Instantly Get Lead', + description: 'Retrieves an Instantly V2 lead by ID.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + leadId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Lead ID', + }, + }, + request: { + url: (params) => instantlyUrl(`/api/v2/leads/${params.leadId.trim()}`), + method: 'GET', + headers: instantlyHeaders, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const lead = mapLead(data) + + return { + success: true, + output: { + lead, + id: lead.id, + email_address: lead.email, + first_name: lead.first_name, + last_name: lead.last_name, + campaign: lead.campaign, + status: lead.status, + }, + } + }, + outputs: leadOutputs, +} diff --git a/apps/sim/tools/instantly/index.ts b/apps/sim/tools/instantly/index.ts new file mode 100644 index 00000000000..6af2ed62141 --- /dev/null +++ b/apps/sim/tools/instantly/index.ts @@ -0,0 +1,14 @@ +export { activateCampaignTool as instantlyActivateCampaignTool } from '@/tools/instantly/activate_campaign' +export { createCampaignTool as instantlyCreateCampaignTool } from '@/tools/instantly/create_campaign' +export { createLeadTool as instantlyCreateLeadTool } from '@/tools/instantly/create_lead' +export { createLeadListTool as instantlyCreateLeadListTool } from '@/tools/instantly/create_lead_list' +export { deleteLeadsTool as instantlyDeleteLeadsTool } from '@/tools/instantly/delete_leads' +export { getLeadTool as instantlyGetLeadTool } from '@/tools/instantly/get_lead' +export { listCampaignsTool as instantlyListCampaignsTool } from '@/tools/instantly/list_campaigns' +export { listEmailsTool as instantlyListEmailsTool } from '@/tools/instantly/list_emails' +export { listLeadListsTool as instantlyListLeadListsTool } from '@/tools/instantly/list_lead_lists' +export { listLeadsTool as instantlyListLeadsTool } from '@/tools/instantly/list_leads' +export { patchCampaignTool as instantlyPatchCampaignTool } from '@/tools/instantly/patch_campaign' +export { replyToEmailTool as instantlyReplyToEmailTool } from '@/tools/instantly/reply_to_email' +export * from '@/tools/instantly/types' +export { updateLeadInterestStatusTool as instantlyUpdateLeadInterestStatusTool } from '@/tools/instantly/update_lead_interest_status' diff --git a/apps/sim/tools/instantly/list_campaigns.ts b/apps/sim/tools/instantly/list_campaigns.ts new file mode 100644 index 00000000000..fa51ca23b96 --- /dev/null +++ b/apps/sim/tools/instantly/list_campaigns.ts @@ -0,0 +1,91 @@ +import type { + InstantlyListCampaignsParams, + InstantlyListCampaignsResponse, +} from '@/tools/instantly/types' +import { + campaignsListOutputs, + getItems, + getNextStartingAfter, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + mapCampaign, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const listCampaignsTool: ToolConfig< + InstantlyListCampaignsParams, + InstantlyListCampaignsResponse +> = { + id: 'instantly_list_campaigns', + name: 'Instantly List Campaigns', + description: 'Retrieves Instantly V2 campaigns with search, status, tag, and pagination filters.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of campaigns to return, from 1 to 100', + }, + starting_after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from next_starting_after', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search by campaign name', + }, + tag_ids: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated campaign tag IDs', + }, + ai_sales_agent_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'AI Sales Agent ID filter', + }, + status: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Campaign status enum value', + }, + }, + request: { + url: (params) => + instantlyUrl('/api/v2/campaigns', { + limit: params.limit, + starting_after: params.starting_after, + search: params.search, + tag_ids: params.tag_ids, + ai_sales_agent_id: params.ai_sales_agent_id, + status: params.status, + }), + method: 'GET', + headers: instantlyHeaders, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const campaigns = getItems(data).map(mapCampaign) + + return { + success: true, + output: { + campaigns, + count: campaigns.length, + next_starting_after: getNextStartingAfter(data), + }, + } + }, + outputs: campaignsListOutputs, +} diff --git a/apps/sim/tools/instantly/list_emails.ts b/apps/sim/tools/instantly/list_emails.ts new file mode 100644 index 00000000000..522f8ecfb6f --- /dev/null +++ b/apps/sim/tools/instantly/list_emails.ts @@ -0,0 +1,109 @@ +import type { + InstantlyListEmailsParams, + InstantlyListEmailsResponse, +} from '@/tools/instantly/types' +import { + emailsListOutputs, + getItems, + getNextStartingAfter, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + mapEmail, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const listEmailsTool: ToolConfig = { + id: 'instantly_list_emails', + name: 'Instantly List Emails', + description: 'Retrieves Instantly V2 Unibox emails with search and pagination filters.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of emails to return, from 1 to 100', + }, + starting_after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from next_starting_after', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search query, email address, or thread:', + }, + campaign_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Campaign ID filter', + }, + list_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead list ID filter', + }, + i_status: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Email interest status filter', + }, + eaccount: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sending email account filter', + }, + lead: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead email address filter', + }, + is_unread: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Unread status filter', + }, + }, + request: { + url: (params) => + instantlyUrl('/api/v2/emails', { + limit: params.limit, + starting_after: params.starting_after, + search: params.search, + campaign_id: params.campaign_id, + list_id: params.list_id, + i_status: params.i_status, + eaccount: params.eaccount, + lead: params.lead, + is_unread: params.is_unread, + }), + method: 'GET', + headers: instantlyHeaders, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const emails = getItems(data).map(mapEmail) + + return { + success: true, + output: { + emails, + count: emails.length, + next_starting_after: getNextStartingAfter(data), + }, + } + }, + outputs: emailsListOutputs, +} diff --git a/apps/sim/tools/instantly/list_lead_lists.ts b/apps/sim/tools/instantly/list_lead_lists.ts new file mode 100644 index 00000000000..02849521ae2 --- /dev/null +++ b/apps/sim/tools/instantly/list_lead_lists.ts @@ -0,0 +1,77 @@ +import type { + InstantlyListLeadListsParams, + InstantlyListLeadListsResponse, +} from '@/tools/instantly/types' +import { + getItems, + getNextStartingAfter, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + leadListsListOutputs, + mapLeadList, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const listLeadListsTool: ToolConfig< + InstantlyListLeadListsParams, + InstantlyListLeadListsResponse +> = { + id: 'instantly_list_lead_lists', + name: 'Instantly List Lead Lists', + description: 'Retrieves Instantly V2 lead lists with search and pagination filters.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of lead lists to return, from 1 to 100', + }, + starting_after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Starting-after timestamp cursor', + }, + has_enrichment_task: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Filter by enrichment task setting', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search query to filter lead lists by name', + }, + }, + request: { + url: (params) => + instantlyUrl('/api/v2/lead-lists', { + limit: params.limit, + starting_after: params.starting_after, + has_enrichment_task: params.has_enrichment_task, + search: params.search, + }), + method: 'GET', + headers: instantlyHeaders, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const leadLists = getItems(data).map(mapLeadList) + + return { + success: true, + output: { + lead_lists: leadLists, + count: leadLists.length, + next_starting_after: getNextStartingAfter(data), + }, + } + }, + outputs: leadListsListOutputs, +} diff --git a/apps/sim/tools/instantly/list_leads.ts b/apps/sim/tools/instantly/list_leads.ts new file mode 100644 index 00000000000..cbfae6efc00 --- /dev/null +++ b/apps/sim/tools/instantly/list_leads.ts @@ -0,0 +1,168 @@ +import type { InstantlyListLeadsParams, InstantlyListLeadsResponse } from '@/tools/instantly/types' +import { + compactBody, + getItems, + getNextStartingAfter, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + leadsListOutputs, + mapLead, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const listLeadsTool: ToolConfig = { + id: 'instantly_list_leads', + name: 'Instantly List Leads', + description: 'Retrieves Instantly V2 leads with search, campaign, list, and pagination filters.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search by first name, last name, or email', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instantly lead filter value, such as FILTER_VAL_CONTACTED or FILTER_VAL_ACTIVE', + }, + campaign: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Campaign ID to filter leads', + }, + list_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead list ID to filter leads', + }, + in_campaign: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the lead is in a campaign', + }, + in_list: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the lead is in a list', + }, + ids: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Lead IDs to include', + items: { type: 'string', description: 'Lead ID' }, + }, + excluded_ids: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Lead IDs to exclude', + items: { type: 'string', description: 'Lead ID' }, + }, + organization_user_ids: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Organization user IDs to filter leads', + items: { type: 'string', description: 'Organization user ID' }, + }, + smart_view_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Smart view ID to filter leads', + }, + contacts: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Lead email addresses to include', + items: { type: 'string', description: 'Email address' }, + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of leads to return, from 1 to 100', + }, + starting_after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Forward pagination cursor from next_starting_after', + }, + distinct_contacts: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return distinct contacts', + }, + is_website_visitor: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the lead is a website visitor', + }, + enrichment_status: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Enrichment status filter', + }, + esg_code: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email security gateway code filter', + }, + }, + request: { + url: () => instantlyUrl('/api/v2/leads/list'), + method: 'POST', + headers: instantlyHeaders, + body: (params) => + compactBody({ + search: params.search, + filter: params.filter, + campaign: params.campaign, + list_id: params.list_id, + in_campaign: params.in_campaign, + in_list: params.in_list, + ids: params.ids, + excluded_ids: params.excluded_ids, + contacts: params.contacts, + limit: params.limit, + starting_after: params.starting_after, + organization_user_ids: params.organization_user_ids, + smart_view_id: params.smart_view_id, + is_website_visitor: params.is_website_visitor, + distinct_contacts: params.distinct_contacts, + enrichment_status: params.enrichment_status, + esg_code: params.esg_code, + }), + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const leads = getItems(data).map(mapLead) + + return { + success: true, + output: { + leads, + count: leads.length, + next_starting_after: getNextStartingAfter(data), + }, + } + }, + outputs: leadsListOutputs, +} diff --git a/apps/sim/tools/instantly/patch_campaign.ts b/apps/sim/tools/instantly/patch_campaign.ts new file mode 100644 index 00000000000..8ec89425ce4 --- /dev/null +++ b/apps/sim/tools/instantly/patch_campaign.ts @@ -0,0 +1,142 @@ +import type { + InstantlyCampaignResponse, + InstantlyPatchCampaignParams, +} from '@/tools/instantly/types' +import { + campaignOutputs, + compactBody, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + mapCampaign, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const patchCampaignTool: ToolConfig< + InstantlyPatchCampaignParams, + InstantlyCampaignResponse +> = { + id: 'instantly_patch_campaign', + name: 'Instantly Patch Campaign', + description: 'Updates documented Instantly V2 campaign fields.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + campaignId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Campaign ID', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Campaign name', + }, + campaign_schedule: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Campaign schedule object with schedules array', + }, + sequences: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Campaign sequence definitions', + items: { type: 'object', description: 'Sequence object' }, + }, + email_list: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Sending email accounts', + items: { type: 'string', description: 'Email address' }, + }, + daily_limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Daily sending limit', + }, + daily_max_leads: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Daily maximum new leads to contact', + }, + open_tracking: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to track opens', + }, + stop_on_reply: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to stop the campaign on reply', + }, + link_tracking: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to track links', + }, + text_only: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the campaign is text only', + }, + email_gap: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Gap between emails in minutes', + }, + pl_value: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Value of every positive lead', + }, + }, + request: { + url: (params) => instantlyUrl(`/api/v2/campaigns/${params.campaignId.trim()}`), + method: 'PATCH', + headers: instantlyHeaders, + body: (params) => + compactBody({ + name: params.name, + campaign_schedule: params.campaign_schedule, + sequences: params.sequences, + pl_value: params.pl_value, + email_gap: params.email_gap, + text_only: params.text_only, + email_list: params.email_list, + daily_limit: params.daily_limit, + stop_on_reply: params.stop_on_reply, + link_tracking: params.link_tracking, + open_tracking: params.open_tracking, + daily_max_leads: params.daily_max_leads, + }), + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const campaign = mapCampaign(data) + + return { + success: true, + output: { + campaign, + id: campaign.id, + name: campaign.name, + status: campaign.status, + }, + } + }, + outputs: campaignOutputs, +} diff --git a/apps/sim/tools/instantly/reply_to_email.ts b/apps/sim/tools/instantly/reply_to_email.ts new file mode 100644 index 00000000000..c980f2a450e --- /dev/null +++ b/apps/sim/tools/instantly/reply_to_email.ts @@ -0,0 +1,86 @@ +import type { InstantlyEmailResponse, InstantlyReplyToEmailParams } from '@/tools/instantly/types' +import { + compactBody, + emailOutputs, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + mapEmail, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const replyToEmailTool: ToolConfig = { + id: 'instantly_reply_to_email', + name: 'Instantly Reply To Email', + description: 'Sends an Instantly V2 reply to an existing Unibox email.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + eaccount: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Connected email account used to send the reply', + }, + reply_to_uuid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email ID to reply to', + }, + subject: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Reply subject', + }, + body: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Reply body object with text and/or html', + }, + cc_address_email_list: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated CC email addresses', + }, + bcc_address_email_list: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated BCC email addresses', + }, + }, + request: { + url: () => instantlyUrl('/api/v2/emails/reply'), + method: 'POST', + headers: instantlyHeaders, + body: (params) => + compactBody({ + eaccount: params.eaccount, + reply_to_uuid: params.reply_to_uuid, + subject: params.subject, + body: params.body, + cc_address_email_list: params.cc_address_email_list, + bcc_address_email_list: params.bcc_address_email_list, + }), + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const email = mapEmail(data) + + return { + success: true, + output: { + email, + id: email.id, + subject: email.subject, + thread_id: email.thread_id, + }, + } + }, + outputs: emailOutputs, +} diff --git a/apps/sim/tools/instantly/types.ts b/apps/sim/tools/instantly/types.ts new file mode 100644 index 00000000000..4c22923da63 --- /dev/null +++ b/apps/sim/tools/instantly/types.ts @@ -0,0 +1,314 @@ +import type { ToolResponse } from '@/tools/types' + +export type InstantlyScalar = string | number | boolean | null + +export interface InstantlyBaseParams { + apiKey: string +} + +export interface InstantlyLead { + id: string | null + timestamp_created: string | null + timestamp_updated: string | null + organization: string | null + campaign: string | null + status: number | null + email: string | null + personalization: string | null + website: string | null + last_name: string | null + first_name: string | null + company_name: string | null + job_title: string | null + phone: string | null + email_open_count: number | null + email_reply_count: number | null + email_click_count: number | null + company_domain: string | null + payload: Record | null + lt_interest_status: number | null +} + +export interface InstantlyCampaign { + id: string | null + name: string | null + pl_value: number | null + status: number | null + is_evergreen: boolean | null + timestamp_created: string | null + timestamp_updated: string | null + email_gap: number | null + daily_limit: number | null + daily_max_leads: number | null + open_tracking: boolean | null + stop_on_reply: boolean | null + sequences: unknown[] + campaign_schedule: Record | null +} + +export interface InstantlyEmail { + id: string | null + timestamp_created: string | null + timestamp_email: string | null + message_id: string | null + subject: string | null + from_address_email: string | null + to_address_email_list: string | null + cc_address_email_list: string | null + bcc_address_email_list: string | null + reply_to: string | null + body: { + text: string | null + html: string | null + } + organization_id: string | null + campaign_id: string | null + subsequence_id: string | null + list_id: string | null + lead: string | null + lead_id: string | null + eaccount: string | null + ue_type: number | null + is_unread: number | null + is_auto_reply: number | null + i_status: number | null + thread_id: string | null + content_preview: string | null +} + +export interface InstantlyLeadList { + id: string | null + organization_id: string | null + has_enrichment_task: boolean | null + owned_by: string | null + name: string | null + timestamp_created: string | null +} + +export interface InstantlyListLeadsParams extends InstantlyBaseParams { + search?: string + filter?: string + campaign?: string + list_id?: string + in_campaign?: boolean + in_list?: boolean + ids?: string[] + excluded_ids?: string[] + contacts?: string[] + limit?: number + starting_after?: string + organization_user_ids?: string[] + smart_view_id?: string + is_website_visitor?: boolean + distinct_contacts?: boolean + enrichment_status?: number + esg_code?: string +} + +export interface InstantlyGetLeadParams extends InstantlyBaseParams { + leadId: string +} + +export interface InstantlyCreateLeadParams extends InstantlyBaseParams { + campaign?: string | null + email?: string | null + personalization?: string | null + website?: string | null + last_name?: string | null + first_name?: string | null + company_name?: string | null + job_title?: string | null + phone?: string | null + lt_interest_status?: number + pl_value_lead?: string | null + list_id?: string | null + assigned_to?: string | null + skip_if_in_workspace?: boolean + skip_if_in_campaign?: boolean + skip_if_in_list?: boolean + blocklist_id?: string + verify_leads_for_lead_finder?: boolean + verify_leads_on_import?: boolean + custom_variables?: Record +} + +export interface InstantlyDeleteLeadsParams extends InstantlyBaseParams { + campaign_id?: string + list_id?: string + status?: number + ids?: string[] + limit?: number +} + +export interface InstantlyUpdateLeadInterestStatusParams extends InstantlyBaseParams { + lead_email: string + interest_value: number | null + campaign_id?: string + ai_interest_value?: number + disable_auto_interest?: boolean + list_id?: string +} + +export interface InstantlyListCampaignsParams extends InstantlyBaseParams { + limit?: number + starting_after?: string + search?: string + tag_ids?: string + ai_sales_agent_id?: string + status?: number +} + +export interface InstantlyCreateCampaignParams extends InstantlyBaseParams { + name: string + campaign_schedule: Record + sequences?: unknown[] + pl_value?: number | null + email_gap?: number | null + text_only?: boolean | null + email_list?: string[] + daily_limit?: number | null + stop_on_reply?: boolean | null + link_tracking?: boolean | null + open_tracking?: boolean + daily_max_leads?: number | null +} + +export interface InstantlyPatchCampaignParams extends Partial { + apiKey: string + campaignId: string +} + +export interface InstantlyActivateCampaignParams extends InstantlyBaseParams { + campaignId: string +} + +export interface InstantlyListEmailsParams extends InstantlyBaseParams { + limit?: number + starting_after?: string + search?: string + campaign_id?: string + list_id?: string + i_status?: number + eaccount?: string + lead?: string + is_unread?: boolean +} + +export interface InstantlyReplyToEmailParams extends InstantlyBaseParams { + eaccount: string + reply_to_uuid: string + subject: string + body: { + text?: string + html?: string + } + cc_address_email_list?: string + bcc_address_email_list?: string +} + +export interface InstantlyListLeadListsParams extends InstantlyBaseParams { + limit?: number + starting_after?: string + has_enrichment_task?: boolean + search?: string +} + +export interface InstantlyCreateLeadListParams extends InstantlyBaseParams { + name: string + has_enrichment_task?: boolean | null + owned_by?: string | null +} + +export interface InstantlyListLeadsResponse extends ToolResponse { + output: { + leads: InstantlyLead[] + count: number + next_starting_after: string | null + } +} + +export interface InstantlyLeadResponse extends ToolResponse { + output: { + lead: InstantlyLead + id: string | null + email_address: string | null + first_name: string | null + last_name: string | null + campaign: string | null + status: number | null + } +} + +export interface InstantlyDeleteLeadsResponse extends ToolResponse { + output: { + count: number | null + } +} + +export interface InstantlyUpdateLeadInterestStatusResponse extends ToolResponse { + output: { + message: string | null + } +} + +export interface InstantlyListCampaignsResponse extends ToolResponse { + output: { + campaigns: InstantlyCampaign[] + count: number + next_starting_after: string | null + } +} + +export interface InstantlyCampaignResponse extends ToolResponse { + output: { + campaign: InstantlyCampaign + id: string | null + name: string | null + status: number | null + } +} + +export interface InstantlyListEmailsResponse extends ToolResponse { + output: { + emails: InstantlyEmail[] + count: number + next_starting_after: string | null + } +} + +export interface InstantlyEmailResponse extends ToolResponse { + output: { + email: InstantlyEmail + id: string | null + subject: string | null + thread_id: string | null + } +} + +export interface InstantlyListLeadListsResponse extends ToolResponse { + output: { + lead_lists: InstantlyLeadList[] + count: number + next_starting_after: string | null + } +} + +export interface InstantlyLeadListResponse extends ToolResponse { + output: { + lead_list: InstantlyLeadList + id: string | null + name: string | null + } +} + +export type InstantlyResponse = + | InstantlyListLeadsResponse + | InstantlyLeadResponse + | InstantlyDeleteLeadsResponse + | InstantlyUpdateLeadInterestStatusResponse + | InstantlyListCampaignsResponse + | InstantlyCampaignResponse + | InstantlyListEmailsResponse + | InstantlyEmailResponse + | InstantlyListLeadListsResponse + | InstantlyLeadListResponse diff --git a/apps/sim/tools/instantly/update_lead_interest_status.ts b/apps/sim/tools/instantly/update_lead_interest_status.ts new file mode 100644 index 00000000000..83e06078144 --- /dev/null +++ b/apps/sim/tools/instantly/update_lead_interest_status.ts @@ -0,0 +1,99 @@ +import type { + InstantlyUpdateLeadInterestStatusParams, + InstantlyUpdateLeadInterestStatusResponse, +} from '@/tools/instantly/types' +import { + asRecord, + compactBody, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const updateLeadInterestStatusTool: ToolConfig< + InstantlyUpdateLeadInterestStatusParams, + InstantlyUpdateLeadInterestStatusResponse +> = { + id: 'instantly_update_lead_interest_status', + name: 'Instantly Update Lead Interest Status', + description: 'Submits an Instantly V2 background job to update a lead interest status.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + lead_email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Lead email address', + }, + interest_value: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Interest status value. Leave empty in the block or pass null to reset to Lead.', + }, + campaign_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Campaign ID for the lead', + }, + list_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead list ID for the lead', + }, + ai_interest_value: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'AI interest value to set for the lead', + }, + disable_auto_interest: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to disable auto interest', + }, + }, + request: { + url: () => instantlyUrl('/api/v2/leads/update-interest-status'), + method: 'POST', + headers: instantlyHeaders, + body: (params) => { + if (params.interest_value === undefined) { + throw new Error('Interest Value is required for Instantly Update Lead Interest Status') + } + + return compactBody({ + lead_email: params.lead_email, + interest_value: params.interest_value, + campaign_id: params.campaign_id, + list_id: params.list_id, + ai_interest_value: params.ai_interest_value, + disable_auto_interest: params.disable_auto_interest, + }) + }, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const result = asRecord(data) + + return { + success: true, + output: { + message: typeof result.message === 'string' ? result.message : null, + }, + } + }, + outputs: { + message: { + type: 'string', + description: 'Background job submission message', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instantly/utils.ts b/apps/sim/tools/instantly/utils.ts new file mode 100644 index 00000000000..885eab479a9 --- /dev/null +++ b/apps/sim/tools/instantly/utils.ts @@ -0,0 +1,366 @@ +import { filterUndefined } from '@sim/utils/object' +import type { + InstantlyCampaign, + InstantlyEmail, + InstantlyLead, + InstantlyLeadList, +} from '@/tools/instantly/types' +import type { ToolConfig } from '@/tools/types' + +const INSTANTLY_API_BASE_URL = 'https://api.instantly.ai' + +type InstantlyBaseParams = { apiKey: string } +type JsonRecord = Record + +export const instantlyBaseParamFields = { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Instantly API key with the required V2 scopes', + }, +} satisfies ToolConfig['params'] + +export const instantlyHeaders = (params: InstantlyBaseParams) => ({ + Authorization: `Bearer ${params.apiKey.trim()}`, + 'Content-Type': 'application/json', +}) + +export function instantlyUrl(path: string, query?: Record): string { + const url = new URL(path, INSTANTLY_API_BASE_URL) + + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + url.searchParams.append(key, String(value)) + } + } + + return url.toString() +} + +export function compactBody(values: Record): Record { + return filterUndefined(values) +} + +export async function parseInstantlyResponse(response: Response): Promise { + const data = await parseJsonResponse(response) + + if (!response.ok) { + throw new Error( + extractInstantlyError(data, `Instantly API request failed (${response.status})`) + ) + } + + return data +} + +export function asRecord(value: unknown): JsonRecord { + return isRecord(value) ? value : {} +} + +export function getItems(value: unknown): JsonRecord[] { + const data = asRecord(value) + return Array.isArray(data.items) ? data.items.map(asRecord) : [] +} + +export function getNextStartingAfter(value: unknown): string | null { + const data = asRecord(value) + return asString(data.next_starting_after) +} + +export function mapLead(value: unknown): InstantlyLead { + const lead = asRecord(value) + + return { + id: asString(lead.id), + timestamp_created: asString(lead.timestamp_created), + timestamp_updated: asString(lead.timestamp_updated), + organization: asString(lead.organization), + campaign: asString(lead.campaign), + status: asNumber(lead.status), + email: asString(lead.email), + personalization: asString(lead.personalization), + website: asString(lead.website), + last_name: asString(lead.last_name), + first_name: asString(lead.first_name), + company_name: asString(lead.company_name), + job_title: asString(lead.job_title), + phone: asString(lead.phone), + email_open_count: asNumber(lead.email_open_count), + email_reply_count: asNumber(lead.email_reply_count), + email_click_count: asNumber(lead.email_click_count), + company_domain: asString(lead.company_domain), + payload: isRecord(lead.payload) ? lead.payload : null, + lt_interest_status: asNumber(lead.lt_interest_status), + } +} + +export function mapCampaign(value: unknown): InstantlyCampaign { + const campaign = asRecord(value) + + return { + id: asString(campaign.id), + name: asString(campaign.name), + pl_value: asNumber(campaign.pl_value), + status: asNumber(campaign.status), + is_evergreen: asBoolean(campaign.is_evergreen), + timestamp_created: asString(campaign.timestamp_created), + timestamp_updated: asString(campaign.timestamp_updated), + email_gap: asNumber(campaign.email_gap), + daily_limit: asNumber(campaign.daily_limit), + daily_max_leads: asNumber(campaign.daily_max_leads), + open_tracking: asBoolean(campaign.open_tracking), + stop_on_reply: asBoolean(campaign.stop_on_reply), + sequences: Array.isArray(campaign.sequences) ? campaign.sequences : [], + campaign_schedule: isRecord(campaign.campaign_schedule) ? campaign.campaign_schedule : null, + } +} + +export function mapEmail(value: unknown): InstantlyEmail { + const email = asRecord(value) + const body = asRecord(email.body) + + return { + id: asString(email.id), + timestamp_created: asString(email.timestamp_created), + timestamp_email: asString(email.timestamp_email), + message_id: asString(email.message_id), + subject: asString(email.subject), + from_address_email: asString(email.from_address_email), + to_address_email_list: asString(email.to_address_email_list), + cc_address_email_list: asString(email.cc_address_email_list), + bcc_address_email_list: asString(email.bcc_address_email_list), + reply_to: asString(email.reply_to), + body: { + text: asString(body.text), + html: asString(body.html), + }, + organization_id: asString(email.organization_id), + campaign_id: asString(email.campaign_id), + subsequence_id: asString(email.subsequence_id), + list_id: asString(email.list_id), + lead: asString(email.lead), + lead_id: asString(email.lead_id), + eaccount: asString(email.eaccount), + ue_type: asNumber(email.ue_type), + is_unread: asNumber(email.is_unread), + is_auto_reply: asNumber(email.is_auto_reply), + i_status: asNumber(email.i_status), + thread_id: asString(email.thread_id), + content_preview: asString(email.content_preview), + } +} + +export function mapLeadList(value: unknown): InstantlyLeadList { + const leadList = asRecord(value) + + return { + id: asString(leadList.id), + organization_id: asString(leadList.organization_id), + has_enrichment_task: asBoolean(leadList.has_enrichment_task), + owned_by: asString(leadList.owned_by), + name: asString(leadList.name), + timestamp_created: asString(leadList.timestamp_created), + } +} + +export const leadOutputs = { + lead: { + type: 'object', + description: 'Lead details', + properties: { + id: { type: 'string', description: 'Lead ID', nullable: true }, + email: { type: 'string', description: 'Lead email address', nullable: true }, + first_name: { type: 'string', description: 'Lead first name', nullable: true }, + last_name: { type: 'string', description: 'Lead last name', nullable: true }, + company_name: { type: 'string', description: 'Lead company name', nullable: true }, + job_title: { type: 'string', description: 'Lead job title', nullable: true }, + campaign: { type: 'string', description: 'Campaign ID', nullable: true }, + status: { type: 'number', description: 'Lead status', nullable: true }, + payload: { type: 'json', description: 'Lead custom variables', nullable: true }, + }, + }, + id: { type: 'string', description: 'Lead ID', optional: true }, + email_address: { type: 'string', description: 'Lead email address', optional: true }, + first_name: { type: 'string', description: 'Lead first name', optional: true }, + last_name: { type: 'string', description: 'Lead last name', optional: true }, + campaign: { type: 'string', description: 'Campaign ID', optional: true }, + status: { type: 'number', description: 'Lead status', optional: true }, +} satisfies ToolConfig['outputs'] + +export const leadsListOutputs = { + leads: { + type: 'array', + description: 'List of leads', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Lead ID', nullable: true }, + email: { type: 'string', description: 'Lead email address', nullable: true }, + first_name: { type: 'string', description: 'Lead first name', nullable: true }, + last_name: { type: 'string', description: 'Lead last name', nullable: true }, + company_name: { type: 'string', description: 'Lead company name', nullable: true }, + campaign: { type: 'string', description: 'Campaign ID', nullable: true }, + status: { type: 'number', description: 'Lead status', nullable: true }, + }, + }, + }, + count: { type: 'number', description: 'Number of leads returned' }, + next_starting_after: { type: 'string', description: 'Cursor for the next page', optional: true }, +} satisfies ToolConfig['outputs'] + +export const campaignOutputs = { + campaign: { + type: 'object', + description: 'Campaign details', + properties: { + id: { type: 'string', description: 'Campaign ID', nullable: true }, + name: { type: 'string', description: 'Campaign name', nullable: true }, + status: { type: 'number', description: 'Campaign status', nullable: true }, + daily_limit: { type: 'number', description: 'Daily sending limit', nullable: true }, + daily_max_leads: { type: 'number', description: 'Daily max new leads', nullable: true }, + open_tracking: { + type: 'boolean', + description: 'Whether open tracking is enabled', + nullable: true, + }, + }, + }, + id: { type: 'string', description: 'Campaign ID', optional: true }, + name: { type: 'string', description: 'Campaign name', optional: true }, + status: { type: 'number', description: 'Campaign status', optional: true }, +} satisfies ToolConfig['outputs'] + +export const campaignsListOutputs = { + campaigns: { + type: 'array', + description: 'List of campaigns', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Campaign ID', nullable: true }, + name: { type: 'string', description: 'Campaign name', nullable: true }, + status: { type: 'number', description: 'Campaign status', nullable: true }, + daily_limit: { type: 'number', description: 'Daily sending limit', nullable: true }, + }, + }, + }, + count: { type: 'number', description: 'Number of campaigns returned' }, + next_starting_after: { type: 'string', description: 'Cursor for the next page', optional: true }, +} satisfies ToolConfig['outputs'] + +export const emailOutputs = { + email: { + type: 'object', + description: 'Email details', + properties: { + id: { type: 'string', description: 'Email ID', nullable: true }, + subject: { type: 'string', description: 'Email subject', nullable: true }, + from_address_email: { type: 'string', description: 'Sender email', nullable: true }, + to_address_email_list: { + type: 'string', + description: 'Recipient email list', + nullable: true, + }, + thread_id: { type: 'string', description: 'Thread ID', nullable: true }, + content_preview: { type: 'string', description: 'Email content preview', nullable: true }, + }, + }, + id: { type: 'string', description: 'Email ID', optional: true }, + subject: { type: 'string', description: 'Email subject', optional: true }, + thread_id: { type: 'string', description: 'Thread ID', optional: true }, +} satisfies ToolConfig['outputs'] + +export const emailsListOutputs = { + emails: { + type: 'array', + description: 'List of emails', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Email ID', nullable: true }, + subject: { type: 'string', description: 'Email subject', nullable: true }, + from_address_email: { type: 'string', description: 'Sender email', nullable: true }, + lead: { type: 'string', description: 'Lead email', nullable: true }, + thread_id: { type: 'string', description: 'Thread ID', nullable: true }, + }, + }, + }, + count: { type: 'number', description: 'Number of emails returned' }, + next_starting_after: { type: 'string', description: 'Cursor for the next page', optional: true }, +} satisfies ToolConfig['outputs'] + +export const leadListOutputs = { + lead_list: { + type: 'object', + description: 'Lead list details', + properties: { + id: { type: 'string', description: 'Lead list ID', nullable: true }, + organization_id: { type: 'string', description: 'Organization ID', nullable: true }, + has_enrichment_task: { + type: 'boolean', + description: 'Whether enrichment is enabled', + nullable: true, + }, + owned_by: { type: 'string', description: 'Owner user ID', nullable: true }, + name: { type: 'string', description: 'Lead list name', nullable: true }, + timestamp_created: { type: 'string', description: 'Creation timestamp', nullable: true }, + }, + }, + id: { type: 'string', description: 'Lead list ID', optional: true }, + name: { type: 'string', description: 'Lead list name', optional: true }, +} satisfies ToolConfig['outputs'] + +export const leadListsListOutputs = { + lead_lists: { + type: 'array', + description: 'List of lead lists', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Lead list ID', nullable: true }, + name: { type: 'string', description: 'Lead list name', nullable: true }, + has_enrichment_task: { + type: 'boolean', + description: 'Whether enrichment is enabled', + nullable: true, + }, + timestamp_created: { type: 'string', description: 'Creation timestamp', nullable: true }, + }, + }, + }, + count: { type: 'number', description: 'Number of lead lists returned' }, + next_starting_after: { type: 'string', description: 'Cursor for the next page', optional: true }, +} satisfies ToolConfig['outputs'] + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function parseJsonResponse(response: Response): Promise { + try { + return await response.json() + } catch { + return null + } +} + +function extractInstantlyError(value: unknown, fallback: string): string { + const data = asRecord(value) + if (typeof data.message === 'string') return data.message + if (typeof data.error === 'string') return data.error + return fallback +} + +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function asBoolean(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 54e16312802..3133260d9cc 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1390,6 +1390,21 @@ import { infisicalListSecretsTool, infisicalUpdateSecretTool, } from '@/tools/infisical' +import { + instantlyActivateCampaignTool, + instantlyCreateCampaignTool, + instantlyCreateLeadListTool, + instantlyCreateLeadTool, + instantlyDeleteLeadsTool, + instantlyGetLeadTool, + instantlyListCampaignsTool, + instantlyListEmailsTool, + instantlyListLeadListsTool, + instantlyListLeadsTool, + instantlyPatchCampaignTool, + instantlyReplyToEmailTool, + instantlyUpdateLeadInterestStatusTool, +} from '@/tools/instantly' import { intercomAssignConversationV2Tool, intercomAttachContactToCompanyV2Tool, @@ -3434,6 +3449,19 @@ export const tools: Record = { hex_list_users: hexListUsersTool, hex_run_project: hexRunProjectTool, hex_update_project: hexUpdateProjectTool, + instantly_activate_campaign: instantlyActivateCampaignTool, + instantly_create_campaign: instantlyCreateCampaignTool, + instantly_create_lead: instantlyCreateLeadTool, + instantly_create_lead_list: instantlyCreateLeadListTool, + instantly_delete_leads: instantlyDeleteLeadsTool, + instantly_get_lead: instantlyGetLeadTool, + instantly_list_campaigns: instantlyListCampaignsTool, + instantly_list_emails: instantlyListEmailsTool, + instantly_list_lead_lists: instantlyListLeadListsTool, + instantly_list_leads: instantlyListLeadsTool, + instantly_patch_campaign: instantlyPatchCampaignTool, + instantly_reply_to_email: instantlyReplyToEmailTool, + instantly_update_lead_interest_status: instantlyUpdateLeadInterestStatusTool, jina_read_url: jinaReadUrlTool, jina_search: jinaSearchTool, ketch_get_consent: ketchGetConsentTool, diff --git a/apps/sim/tools/sixtyfour/enrich_company.ts b/apps/sim/tools/sixtyfour/enrich_company.ts index b296dbca1fb..6920e469aff 100644 --- a/apps/sim/tools/sixtyfour/enrich_company.ts +++ b/apps/sim/tools/sixtyfour/enrich_company.ts @@ -123,6 +123,7 @@ export const sixtyfourEnrichCompanyTool: ToolConfig< structuredData: data.structured_data ?? {}, references: data.references ?? {}, confidenceScore: data.confidence_score ?? null, + orgChart: data.org_chart ?? null, }, } }, @@ -139,5 +140,10 @@ export const sixtyfourEnrichCompanyTool: ToolConfig< description: 'Quality score for the returned data (0-10)', optional: true, }, + orgChart: { + type: 'json', + description: 'Org chart returned when fullOrgChart is enabled', + optional: true, + }, }, } diff --git a/apps/sim/tools/sixtyfour/types.ts b/apps/sim/tools/sixtyfour/types.ts index e50afcab7fb..bf8df9e2b3a 100644 --- a/apps/sim/tools/sixtyfour/types.ts +++ b/apps/sim/tools/sixtyfour/types.ts @@ -74,5 +74,6 @@ export interface SixtyfourEnrichCompanyResponse extends ToolResponse { structuredData: Record references: Record confidenceScore: number | null + orgChart: Record | unknown[] | null } } diff --git a/apps/sim/triggers/instantly/account_error.ts b/apps/sim/triggers/instantly/account_error.ts new file mode 100644 index 00000000000..896c3fe34bb --- /dev/null +++ b/apps/sim/triggers/instantly/account_error.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyAccountErrorTrigger = createInstantlyTrigger({ + id: 'instantly_account_error', + name: 'Instantly Account Error', + description: 'Trigger when Instantly reports an account-level error', + eventLabel: 'Account Error', +}) diff --git a/apps/sim/triggers/instantly/auto_reply_received.ts b/apps/sim/triggers/instantly/auto_reply_received.ts new file mode 100644 index 00000000000..647d1f8b4ec --- /dev/null +++ b/apps/sim/triggers/instantly/auto_reply_received.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyAutoReplyReceivedTrigger = createInstantlyTrigger({ + id: 'instantly_auto_reply_received', + name: 'Instantly Auto Reply Received', + description: 'Trigger when Instantly receives an auto-reply from a lead', + eventLabel: 'Auto Reply Received', +}) diff --git a/apps/sim/triggers/instantly/campaign_completed.ts b/apps/sim/triggers/instantly/campaign_completed.ts new file mode 100644 index 00000000000..ffc06a48207 --- /dev/null +++ b/apps/sim/triggers/instantly/campaign_completed.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyCampaignCompletedTrigger = createInstantlyTrigger({ + id: 'instantly_campaign_completed', + name: 'Instantly Campaign Completed', + description: 'Trigger when an Instantly campaign completes', + eventLabel: 'Campaign Completed', +}) diff --git a/apps/sim/triggers/instantly/email_bounced.ts b/apps/sim/triggers/instantly/email_bounced.ts new file mode 100644 index 00000000000..291bbe8365c --- /dev/null +++ b/apps/sim/triggers/instantly/email_bounced.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyEmailBouncedTrigger = createInstantlyTrigger({ + id: 'instantly_email_bounced', + name: 'Instantly Email Bounced', + description: 'Trigger when an Instantly email bounces', + eventLabel: 'Email Bounced', +}) diff --git a/apps/sim/triggers/instantly/email_opened.ts b/apps/sim/triggers/instantly/email_opened.ts new file mode 100644 index 00000000000..66c6f2e2076 --- /dev/null +++ b/apps/sim/triggers/instantly/email_opened.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyEmailOpenedTrigger = createInstantlyTrigger({ + id: 'instantly_email_opened', + name: 'Instantly Email Opened', + description: 'Trigger when a lead opens an Instantly email', + eventLabel: 'Email Opened', +}) diff --git a/apps/sim/triggers/instantly/email_sent.ts b/apps/sim/triggers/instantly/email_sent.ts new file mode 100644 index 00000000000..57ae784d0e6 --- /dev/null +++ b/apps/sim/triggers/instantly/email_sent.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyEmailSentTrigger = createInstantlyTrigger({ + id: 'instantly_email_sent', + name: 'Instantly Email Sent', + description: 'Trigger when Instantly sends an email', + eventLabel: 'Email Sent', +}) diff --git a/apps/sim/triggers/instantly/index.ts b/apps/sim/triggers/instantly/index.ts new file mode 100644 index 00000000000..540e74e9764 --- /dev/null +++ b/apps/sim/triggers/instantly/index.ts @@ -0,0 +1,20 @@ +export { instantlyAccountErrorTrigger } from '@/triggers/instantly/account_error' +export { instantlyAutoReplyReceivedTrigger } from '@/triggers/instantly/auto_reply_received' +export { instantlyCampaignCompletedTrigger } from '@/triggers/instantly/campaign_completed' +export { instantlyEmailBouncedTrigger } from '@/triggers/instantly/email_bounced' +export { instantlyEmailOpenedTrigger } from '@/triggers/instantly/email_opened' +export { instantlyEmailSentTrigger } from '@/triggers/instantly/email_sent' +export { instantlyLeadClosedTrigger } from '@/triggers/instantly/lead_closed' +export { instantlyLeadInterestedTrigger } from '@/triggers/instantly/lead_interested' +export { instantlyLeadMeetingBookedTrigger } from '@/triggers/instantly/lead_meeting_booked' +export { instantlyLeadMeetingCompletedTrigger } from '@/triggers/instantly/lead_meeting_completed' +export { instantlyLeadNeutralTrigger } from '@/triggers/instantly/lead_neutral' +export { instantlyLeadNoShowTrigger } from '@/triggers/instantly/lead_no_show' +export { instantlyLeadNotInterestedTrigger } from '@/triggers/instantly/lead_not_interested' +export { instantlyLeadOutOfOfficeTrigger } from '@/triggers/instantly/lead_out_of_office' +export { instantlyLeadUnsubscribedTrigger } from '@/triggers/instantly/lead_unsubscribed' +export { instantlyLeadWrongPersonTrigger } from '@/triggers/instantly/lead_wrong_person' +export { instantlyLinkClickedTrigger } from '@/triggers/instantly/link_clicked' +export { instantlyReplyReceivedTrigger } from '@/triggers/instantly/reply_received' +export { instantlySupersearchEnrichmentCompletedTrigger } from '@/triggers/instantly/supersearch_enrichment_completed' +export { instantlyWebhookTrigger } from '@/triggers/instantly/webhook' diff --git a/apps/sim/triggers/instantly/lead_closed.ts b/apps/sim/triggers/instantly/lead_closed.ts new file mode 100644 index 00000000000..ee6f522fe54 --- /dev/null +++ b/apps/sim/triggers/instantly/lead_closed.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadClosedTrigger = createInstantlyTrigger({ + id: 'instantly_lead_closed', + name: 'Instantly Lead Closed', + description: 'Trigger when an Instantly lead is marked closed', + eventLabel: 'Lead Closed', +}) diff --git a/apps/sim/triggers/instantly/lead_interested.ts b/apps/sim/triggers/instantly/lead_interested.ts new file mode 100644 index 00000000000..4c0791da4fb --- /dev/null +++ b/apps/sim/triggers/instantly/lead_interested.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadInterestedTrigger = createInstantlyTrigger({ + id: 'instantly_lead_interested', + name: 'Instantly Lead Interested', + description: 'Trigger when an Instantly lead is marked interested', + eventLabel: 'Lead Interested', +}) diff --git a/apps/sim/triggers/instantly/lead_meeting_booked.ts b/apps/sim/triggers/instantly/lead_meeting_booked.ts new file mode 100644 index 00000000000..f74ab39b33b --- /dev/null +++ b/apps/sim/triggers/instantly/lead_meeting_booked.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadMeetingBookedTrigger = createInstantlyTrigger({ + id: 'instantly_lead_meeting_booked', + name: 'Instantly Lead Meeting Booked', + description: 'Trigger when an Instantly lead books a meeting', + eventLabel: 'Lead Meeting Booked', +}) diff --git a/apps/sim/triggers/instantly/lead_meeting_completed.ts b/apps/sim/triggers/instantly/lead_meeting_completed.ts new file mode 100644 index 00000000000..470a56dd142 --- /dev/null +++ b/apps/sim/triggers/instantly/lead_meeting_completed.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadMeetingCompletedTrigger = createInstantlyTrigger({ + id: 'instantly_lead_meeting_completed', + name: 'Instantly Lead Meeting Completed', + description: 'Trigger when an Instantly lead completes a meeting', + eventLabel: 'Lead Meeting Completed', +}) diff --git a/apps/sim/triggers/instantly/lead_neutral.ts b/apps/sim/triggers/instantly/lead_neutral.ts new file mode 100644 index 00000000000..0b796024b20 --- /dev/null +++ b/apps/sim/triggers/instantly/lead_neutral.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadNeutralTrigger = createInstantlyTrigger({ + id: 'instantly_lead_neutral', + name: 'Instantly Lead Neutral', + description: 'Trigger when an Instantly lead is marked neutral', + eventLabel: 'Lead Neutral', +}) diff --git a/apps/sim/triggers/instantly/lead_no_show.ts b/apps/sim/triggers/instantly/lead_no_show.ts new file mode 100644 index 00000000000..a5391873810 --- /dev/null +++ b/apps/sim/triggers/instantly/lead_no_show.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadNoShowTrigger = createInstantlyTrigger({ + id: 'instantly_lead_no_show', + name: 'Instantly Lead No Show', + description: 'Trigger when an Instantly lead is marked no show', + eventLabel: 'Lead No Show', +}) diff --git a/apps/sim/triggers/instantly/lead_not_interested.ts b/apps/sim/triggers/instantly/lead_not_interested.ts new file mode 100644 index 00000000000..fa5218616ec --- /dev/null +++ b/apps/sim/triggers/instantly/lead_not_interested.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadNotInterestedTrigger = createInstantlyTrigger({ + id: 'instantly_lead_not_interested', + name: 'Instantly Lead Not Interested', + description: 'Trigger when an Instantly lead is marked not interested', + eventLabel: 'Lead Not Interested', +}) diff --git a/apps/sim/triggers/instantly/lead_out_of_office.ts b/apps/sim/triggers/instantly/lead_out_of_office.ts new file mode 100644 index 00000000000..e974f9cb041 --- /dev/null +++ b/apps/sim/triggers/instantly/lead_out_of_office.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadOutOfOfficeTrigger = createInstantlyTrigger({ + id: 'instantly_lead_out_of_office', + name: 'Instantly Lead Out Of Office', + description: 'Trigger when an Instantly lead is out of office', + eventLabel: 'Lead Out Of Office', +}) diff --git a/apps/sim/triggers/instantly/lead_unsubscribed.ts b/apps/sim/triggers/instantly/lead_unsubscribed.ts new file mode 100644 index 00000000000..ed3132974fa --- /dev/null +++ b/apps/sim/triggers/instantly/lead_unsubscribed.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadUnsubscribedTrigger = createInstantlyTrigger({ + id: 'instantly_lead_unsubscribed', + name: 'Instantly Lead Unsubscribed', + description: 'Trigger when an Instantly lead unsubscribes', + eventLabel: 'Lead Unsubscribed', +}) diff --git a/apps/sim/triggers/instantly/lead_wrong_person.ts b/apps/sim/triggers/instantly/lead_wrong_person.ts new file mode 100644 index 00000000000..44d6e79b9a6 --- /dev/null +++ b/apps/sim/triggers/instantly/lead_wrong_person.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLeadWrongPersonTrigger = createInstantlyTrigger({ + id: 'instantly_lead_wrong_person', + name: 'Instantly Lead Wrong Person', + description: 'Trigger when an Instantly lead is marked wrong person', + eventLabel: 'Lead Wrong Person', +}) diff --git a/apps/sim/triggers/instantly/link_clicked.ts b/apps/sim/triggers/instantly/link_clicked.ts new file mode 100644 index 00000000000..a6105323aa5 --- /dev/null +++ b/apps/sim/triggers/instantly/link_clicked.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyLinkClickedTrigger = createInstantlyTrigger({ + id: 'instantly_link_clicked', + name: 'Instantly Link Clicked', + description: 'Trigger when a lead clicks a tracked Instantly link', + eventLabel: 'Link Clicked', +}) diff --git a/apps/sim/triggers/instantly/reply_received.ts b/apps/sim/triggers/instantly/reply_received.ts new file mode 100644 index 00000000000..cd62ffc80ad --- /dev/null +++ b/apps/sim/triggers/instantly/reply_received.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyReplyReceivedTrigger = createInstantlyTrigger({ + id: 'instantly_reply_received', + name: 'Instantly Reply Received', + description: 'Trigger when a lead replies to an Instantly email', + eventLabel: 'Reply Received', +}) diff --git a/apps/sim/triggers/instantly/supersearch_enrichment_completed.ts b/apps/sim/triggers/instantly/supersearch_enrichment_completed.ts new file mode 100644 index 00000000000..3a481457203 --- /dev/null +++ b/apps/sim/triggers/instantly/supersearch_enrichment_completed.ts @@ -0,0 +1,8 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlySupersearchEnrichmentCompletedTrigger = createInstantlyTrigger({ + id: 'instantly_supersearch_enrichment_completed', + name: 'Instantly Supersearch Enrichment Completed', + description: 'Trigger when Instantly completes a Supersearch enrichment', + eventLabel: 'Supersearch Enrichment Completed', +}) diff --git a/apps/sim/triggers/instantly/trigger.ts b/apps/sim/triggers/instantly/trigger.ts new file mode 100644 index 00000000000..7f82bf37c74 --- /dev/null +++ b/apps/sim/triggers/instantly/trigger.ts @@ -0,0 +1,43 @@ +import { InstantlyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildInstantlyExtraFields, + buildInstantlyOutputs, + instantlySetupInstructions, + instantlyTriggerOptions, +} from '@/triggers/instantly/utils' +import type { TriggerConfig } from '@/triggers/types' + +interface CreateInstantlyTriggerOptions { + id: string + name: string + description: string + eventLabel: string + includeDropdown?: boolean +} + +export function createInstantlyTrigger({ + id, + name, + description, + eventLabel, + includeDropdown = false, +}: CreateInstantlyTriggerOptions): TriggerConfig { + return { + id, + name, + provider: 'instantly', + description, + version: '1.0.0', + icon: InstantlyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: id, + triggerOptions: instantlyTriggerOptions, + includeDropdown, + setupInstructions: instantlySetupInstructions(eventLabel), + extraFields: buildInstantlyExtraFields(id), + }), + outputs: buildInstantlyOutputs(), + webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } }, + } +} diff --git a/apps/sim/triggers/instantly/utils.ts b/apps/sim/triggers/instantly/utils.ts new file mode 100644 index 00000000000..4581a7ee57c --- /dev/null +++ b/apps/sim/triggers/instantly/utils.ts @@ -0,0 +1,152 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +export const INSTANTLY_TRIGGER_TO_EVENT_TYPE = { + instantly_webhook: 'all_events', + instantly_email_sent: 'email_sent', + instantly_email_opened: 'email_opened', + instantly_reply_received: 'reply_received', + instantly_auto_reply_received: 'auto_reply_received', + instantly_link_clicked: 'link_clicked', + instantly_email_bounced: 'email_bounced', + instantly_lead_unsubscribed: 'lead_unsubscribed', + instantly_account_error: 'account_error', + instantly_campaign_completed: 'campaign_completed', + instantly_lead_neutral: 'lead_neutral', + instantly_lead_interested: 'lead_interested', + instantly_lead_not_interested: 'lead_not_interested', + instantly_lead_meeting_booked: 'lead_meeting_booked', + instantly_lead_meeting_completed: 'lead_meeting_completed', + instantly_lead_closed: 'lead_closed', + instantly_lead_out_of_office: 'lead_out_of_office', + instantly_lead_wrong_person: 'lead_wrong_person', + instantly_lead_no_show: 'lead_no_show', + instantly_supersearch_enrichment_completed: 'supersearch_enrichment_completed', +} as const + +export const INSTANTLY_TRIGGER_TO_SUBSCRIPTION_EVENT_TYPE = { + ...INSTANTLY_TRIGGER_TO_EVENT_TYPE, + instantly_auto_reply_received: 'all_events', + instantly_link_clicked: 'email_link_clicked', +} as const + +export const instantlyTriggerOptions = [ + { label: 'All Events', id: 'instantly_webhook' }, + { label: 'Email Sent', id: 'instantly_email_sent' }, + { label: 'Email Opened', id: 'instantly_email_opened' }, + { label: 'Reply Received', id: 'instantly_reply_received' }, + { label: 'Auto Reply Received', id: 'instantly_auto_reply_received' }, + { label: 'Link Clicked', id: 'instantly_link_clicked' }, + { label: 'Email Bounced', id: 'instantly_email_bounced' }, + { label: 'Lead Unsubscribed', id: 'instantly_lead_unsubscribed' }, + { label: 'Account Error', id: 'instantly_account_error' }, + { label: 'Campaign Completed', id: 'instantly_campaign_completed' }, + { label: 'Lead Neutral', id: 'instantly_lead_neutral' }, + { label: 'Lead Interested', id: 'instantly_lead_interested' }, + { label: 'Lead Not Interested', id: 'instantly_lead_not_interested' }, + { label: 'Lead Meeting Booked', id: 'instantly_lead_meeting_booked' }, + { label: 'Lead Meeting Completed', id: 'instantly_lead_meeting_completed' }, + { label: 'Lead Closed', id: 'instantly_lead_closed' }, + { label: 'Lead Out Of Office', id: 'instantly_lead_out_of_office' }, + { label: 'Lead Wrong Person', id: 'instantly_lead_wrong_person' }, + { label: 'Lead No Show', id: 'instantly_lead_no_show' }, + { + label: 'Supersearch Enrichment Completed', + id: 'instantly_supersearch_enrichment_completed', + }, +] + +export function instantlySetupInstructions(eventType: string): string { + const instructions = [ + 'Enter an Instantly API Key with webhook create/delete permissions.', + 'Optionally enter a Campaign ID to receive only events for that campaign.', + `Click Save Configuration to automatically create an Instantly webhook for ${eventType}.`, + 'The webhook will be automatically deleted from Instantly when this trigger is removed.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +export function buildInstantlyExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'triggerApiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Instantly API key', + password: true, + required: true, + paramVisibility: 'user-only', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerCampaignId', + title: 'Campaign ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for all campaigns', + paramVisibility: 'user-only', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +export function buildInstantlyOutputs(): Record { + return { + timestamp: { type: 'string', description: 'ISO timestamp when the event occurred' }, + eventType: { type: 'string', description: 'Instantly webhook event type' }, + workspace: { type: 'string', description: 'Instantly workspace UUID' }, + campaignId: { type: 'string', description: 'Instantly campaign UUID' }, + campaignName: { type: 'string', description: 'Instantly campaign name' }, + leadEmail: { type: 'string', description: 'Lead email address' }, + emailAccount: { type: 'string', description: 'Email account used to send the message' }, + uniboxUrl: { type: 'string', description: 'URL to view the conversation in Unibox' }, + step: { type: 'number', description: 'Campaign step number, starting at 1' }, + variant: { type: 'number', description: 'Campaign step variant number, starting at 1' }, + isFirst: { type: 'boolean', description: 'Whether this is the first event of this type' }, + emailId: { type: 'string', description: 'Email ID, usable as reply_to_uuid' }, + emailSubject: { type: 'string', description: 'Sent email subject' }, + emailText: { type: 'string', description: 'Sent email plain-text content' }, + emailHtml: { type: 'string', description: 'Sent email HTML content' }, + replyTextSnippet: { type: 'string', description: 'Short preview of the reply content' }, + replySubject: { type: 'string', description: 'Reply email subject' }, + replyText: { type: 'string', description: 'Full plain-text reply content' }, + replyHtml: { type: 'string', description: 'Full HTML reply content' }, + payload: { + type: 'json', + description: 'Full Instantly webhook payload, including any extra lead data fields', + }, + } +} + +export function getInstantlyEventTypeForTrigger(triggerId: string): string | undefined { + return INSTANTLY_TRIGGER_TO_EVENT_TYPE[triggerId as keyof typeof INSTANTLY_TRIGGER_TO_EVENT_TYPE] +} + +export function getInstantlySubscriptionEventTypeForTrigger(triggerId: string): string | undefined { + return INSTANTLY_TRIGGER_TO_SUBSCRIPTION_EVENT_TYPE[ + triggerId as keyof typeof INSTANTLY_TRIGGER_TO_SUBSCRIPTION_EVENT_TYPE + ] +} + +export function isInstantlyEventMatch(triggerId: string, body: Record): boolean { + if (triggerId === 'instantly_webhook') return true + + const expectedEventType = getInstantlyEventTypeForTrigger(triggerId) + if (!expectedEventType) return false + + const actualEventType = body.event_type + if (typeof actualEventType !== 'string') return false + + if (triggerId === 'instantly_link_clicked') { + return actualEventType === 'link_clicked' || actualEventType === 'email_link_clicked' + } + + return actualEventType === expectedEventType +} diff --git a/apps/sim/triggers/instantly/webhook.ts b/apps/sim/triggers/instantly/webhook.ts new file mode 100644 index 00000000000..904f2192ac0 --- /dev/null +++ b/apps/sim/triggers/instantly/webhook.ts @@ -0,0 +1,9 @@ +import { createInstantlyTrigger } from '@/triggers/instantly/trigger' + +export const instantlyWebhookTrigger = createInstantlyTrigger({ + id: 'instantly_webhook', + name: 'Instantly Webhook', + description: 'Trigger workflow on any Instantly webhook event', + eventLabel: 'All Events', + includeDropdown: true, +}) diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 43a5af5dedf..0de5587cb5b 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -147,6 +147,28 @@ import { } from '@/triggers/greenhouse' import { hubspotPollingTrigger } from '@/triggers/hubspot' import { imapPollingTrigger } from '@/triggers/imap' +import { + instantlyAccountErrorTrigger, + instantlyAutoReplyReceivedTrigger, + instantlyCampaignCompletedTrigger, + instantlyEmailBouncedTrigger, + instantlyEmailOpenedTrigger, + instantlyEmailSentTrigger, + instantlyLeadClosedTrigger, + instantlyLeadInterestedTrigger, + instantlyLeadMeetingBookedTrigger, + instantlyLeadMeetingCompletedTrigger, + instantlyLeadNeutralTrigger, + instantlyLeadNoShowTrigger, + instantlyLeadNotInterestedTrigger, + instantlyLeadOutOfOfficeTrigger, + instantlyLeadUnsubscribedTrigger, + instantlyLeadWrongPersonTrigger, + instantlyLinkClickedTrigger, + instantlyReplyReceivedTrigger, + instantlySupersearchEnrichmentCompletedTrigger, + instantlyWebhookTrigger, +} from '@/triggers/instantly' import { intercomContactCreatedTrigger, intercomConversationClosedTrigger, @@ -563,6 +585,26 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { intercom_contact_created: intercomContactCreatedTrigger, intercom_user_created: intercomUserCreatedTrigger, intercom_webhook: intercomWebhookTrigger, + instantly_webhook: instantlyWebhookTrigger, + instantly_email_sent: instantlyEmailSentTrigger, + instantly_email_opened: instantlyEmailOpenedTrigger, + instantly_reply_received: instantlyReplyReceivedTrigger, + instantly_auto_reply_received: instantlyAutoReplyReceivedTrigger, + instantly_link_clicked: instantlyLinkClickedTrigger, + instantly_email_bounced: instantlyEmailBouncedTrigger, + instantly_lead_unsubscribed: instantlyLeadUnsubscribedTrigger, + instantly_account_error: instantlyAccountErrorTrigger, + instantly_campaign_completed: instantlyCampaignCompletedTrigger, + instantly_lead_neutral: instantlyLeadNeutralTrigger, + instantly_lead_interested: instantlyLeadInterestedTrigger, + instantly_lead_not_interested: instantlyLeadNotInterestedTrigger, + instantly_lead_meeting_booked: instantlyLeadMeetingBookedTrigger, + instantly_lead_meeting_completed: instantlyLeadMeetingCompletedTrigger, + instantly_lead_closed: instantlyLeadClosedTrigger, + instantly_lead_out_of_office: instantlyLeadOutOfOfficeTrigger, + instantly_lead_wrong_person: instantlyLeadWrongPersonTrigger, + instantly_lead_no_show: instantlyLeadNoShowTrigger, + instantly_supersearch_enrichment_completed: instantlySupersearchEnrichmentCompletedTrigger, zoom_meeting_started: zoomMeetingStartedTrigger, zoom_meeting_ended: zoomMeetingEndedTrigger, zoom_participant_joined: zoomParticipantJoinedTrigger, diff --git a/bun.lock b/bun.lock index a1f5f4ea6db..8d326c45be4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/packages/db/migrations/0215_flowery_hellcat.sql b/packages/db/migrations/0215_flowery_hellcat.sql new file mode 100644 index 00000000000..e30e1c63a6d --- /dev/null +++ b/packages/db/migrations/0215_flowery_hellcat.sql @@ -0,0 +1,5 @@ +ALTER TABLE "workflow_schedule" ADD COLUMN "infra_retry_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +CREATE INDEX "async_jobs_schedule_pending_run_at_idx" ON "async_jobs" USING btree ("run_at","created_at","id") WHERE "async_jobs"."type" = 'schedule-execution' AND "async_jobs"."status" = 'pending';--> statement-breakpoint +CREATE INDEX "async_jobs_schedule_processing_started_at_idx" ON "async_jobs" USING btree ("started_at","id") WHERE "async_jobs"."type" = 'schedule-execution' AND "async_jobs"."status" = 'processing';--> statement-breakpoint +CREATE INDEX "workflow_schedule_due_workflow_idx" ON "workflow_schedule" USING btree ("next_run_at","last_queued_at","deployment_version_id","workflow_id") 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);--> statement-breakpoint +CREATE INDEX "workflow_schedule_due_job_idx" ON "workflow_schedule" USING btree ("next_run_at","last_queued_at") WHERE "workflow_schedule"."archived_at" IS NULL AND "workflow_schedule"."status" NOT IN ('disabled', 'completed') AND "workflow_schedule"."source_type" = 'job'; \ No newline at end of file diff --git a/packages/db/migrations/meta/0215_snapshot.json b/packages/db/migrations/meta/0215_snapshot.json new file mode 100644 index 00000000000..e216365571b --- /dev/null +++ b/packages/db/migrations/meta/0215_snapshot.json @@ -0,0 +1,17188 @@ +{ + "id": "79162625-15bc-4a1c-b51a-cd2d21add267", + "prevId": "c7a4672e-4eb5-41be-a71a-2fca982f80cc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "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": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_partial_idx": { + "name": "form_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"form\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_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_access_token_access_token_idx": { + "name": "oauth_access_token_access_token_idx", + "columns": [ + { + "expression": "access_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_token_idx": { + "name": "oauth_access_token_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": ["access_token"] + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": ["refresh_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_application": { + "name": "oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "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": { + "oauth_application_client_id_idx": { + "name": "oauth_application_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_application_client_id_unique": { + "name": "oauth_application_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": true + }, + "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 + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_user_client_idx": { + "name": "oauth_consent_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": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_name_unique": { + "name": "permission_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "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": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "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": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_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": {} + } + }, + "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": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_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 + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index fbdfbfb2fdc..1c24f5b79a2 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1499,6 +1499,13 @@ "when": 1779868021005, "tag": "0214_light_moira_mactaggert", "breakpoints": true + }, + { + "idx": 215, + "version": "7", + "when": 1779902345831, + "tag": "0215_flowery_hellcat", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 1a7a9faf22a..5dcf005e7b5 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -609,6 +609,7 @@ export const workflowSchedule = pgTable( triggerType: text('trigger_type').notNull(), // "manual", "webhook", "schedule" timezone: text('timezone').notNull().default('UTC'), failedCount: integer('failed_count').notNull().default(0), + infraRetryCount: integer('infra_retry_count').notNull().default(0), status: text('status').notNull().default('active'), // 'active', 'disabled', or 'completed' lastFailedAt: timestamp('last_failed_at'), sourceType: text('source_type').notNull().default('workflow'), // 'workflow' or 'job' @@ -644,6 +645,16 @@ export const workflowSchedule = pgTable( sourceWorkspaceSourceTypeIdx: index( 'idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6' ).on(table.sourceWorkspaceId, table.sourceType, table.archivedAt, table.status), + dueWorkflowIdx: index('workflow_schedule_due_workflow_idx') + .on(table.nextRunAt, table.lastQueuedAt, table.deploymentVersionId, table.workflowId) + .where( + sql`${table.archivedAt} IS NULL AND ${table.status} NOT IN ('disabled', 'completed') AND (${table.sourceType} = 'workflow' OR ${table.sourceType} IS NULL)` + ), + dueJobIdx: index('workflow_schedule_due_job_idx') + .on(table.nextRunAt, table.lastQueuedAt) + .where( + sql`${table.archivedAt} IS NULL AND ${table.status} NOT IN ('disabled', 'completed') AND ${table.sourceType} = 'job'` + ), } } ) @@ -3014,6 +3025,12 @@ export const asyncJobs = pgTable( table.status, table.completedAt ), + schedulePendingRunAtIdx: index('async_jobs_schedule_pending_run_at_idx') + .on(table.runAt, table.createdAt, table.id) + .where(sql`${table.type} = 'schedule-execution' AND ${table.status} = 'pending'`), + scheduleProcessingStartedAtIdx: index('async_jobs_schedule_processing_started_at_idx') + .on(table.startedAt, table.id) + .where(sql`${table.type} = 'schedule-execution' AND ${table.status} = 'processing'`), }) ) diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index 6abcb8ac341..dead81be44c 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -97,19 +97,27 @@ export function createMockSqlOperators() { * ``` */ const limit = vi.fn(() => Promise.resolve([] as unknown[])) -const orderBy = vi.fn(() => Promise.resolve([] as unknown[])) const returning = vi.fn(() => Promise.resolve([] as unknown[])) -const groupBy = vi.fn(() => Promise.resolve([] as unknown[])) const execute = vi.fn(() => Promise.resolve([] as unknown[])) -const forBuilder = () => { +const terminalBuilder = () => { const thenable: any = Promise.resolve([] as unknown[]) thenable.limit = limit thenable.orderBy = orderBy thenable.returning = returning thenable.groupBy = groupBy + thenable.for = forClause return thenable } + +const orderBy = vi.fn(terminalBuilder) +const having = vi.fn(terminalBuilder) +const groupBy = vi.fn(() => { + const builder = terminalBuilder() + builder.having = having + return builder +}) +const forBuilder = terminalBuilder const forClause = vi.fn(forBuilder) const onConflictDoUpdate = vi.fn(() => ({ returning }) as unknown as Promise) @@ -162,6 +170,7 @@ export const dbChainMockFns = { innerJoin, leftJoin, groupBy, + having, execute, for: forClause, insert, @@ -199,9 +208,14 @@ export function resetDbChainMock(): void { set.mockImplementation(() => ({ where })) del.mockImplementation(() => ({ where })) limit.mockImplementation(() => Promise.resolve([] as unknown[])) - orderBy.mockImplementation(() => Promise.resolve([] as unknown[])) + orderBy.mockImplementation(terminalBuilder) returning.mockImplementation(() => Promise.resolve([] as unknown[])) - groupBy.mockImplementation(() => Promise.resolve([] as unknown[])) + having.mockImplementation(terminalBuilder) + groupBy.mockImplementation(() => { + const builder = terminalBuilder() + builder.having = having + return builder + }) execute.mockImplementation(() => Promise.resolve([] as unknown[])) forClause.mockImplementation(forBuilder) transaction.mockImplementation(async (cb: (tx: typeof dbChainMock.db) => unknown) =>