diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 4359fd261ea..68c5a9901de 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -44,11 +44,13 @@ jobs: key: ${{ github.repository }}-turbo-cache path: ./.turbo - - name: Mount Next.js build cache (Sticky Disk) - uses: useblacksmith/stickydisk@v1 + - name: Restore Next.js build cache + uses: actions/cache@v5 with: - key: ${{ github.repository }}-nextjs-cache path: ./apps/sim/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-nextjs- - name: Install dependencies run: bun install --frozen-lockfile diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 4092f8c10a7..88836b8317d 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -895,6 +895,24 @@ export function YouTubeIcon(props: React.SVGProps) { ) } +export function PeopleDataLabsIcon(props: SVGProps) { + return ( + + + + ) +} + export function PerplexityIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index b515c6ccdd2..609f37382d7 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -135,6 +135,7 @@ import { PackageSearchIcon, PagerDutyIcon, ParallelIcon, + PeopleDataLabsIcon, PerplexityIcon, PineconeIcon, PipedriveIcon, @@ -351,6 +352,7 @@ export const blockTypeToIconMap: Record = { outlook: OutlookIcon, pagerduty: PagerDutyIcon, parallel_ai: ParallelIcon, + peopledatalabs: PeopleDataLabsIcon, perplexity: PerplexityIcon, pinecone: PineconeIcon, pipedrive: PipedriveIcon, diff --git a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx index ad9ece3b50f..c5459d6369b 100644 --- a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx +++ b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx @@ -31,10 +31,12 @@ Speed up your workflow building with these keyboard shortcuts and mouse controls | `Mod` + `Z` | Undo | | `Mod` + `Shift` + `Z` | Redo | | `Mod` + `C` | Copy selected blocks | +| `Mod` + `X` | Cut selected blocks | | `Mod` + `V` | Paste blocks | | `Delete` or `Backspace` | Delete selected blocks or edges | | `Shift` + `L` | Auto-layout canvas | | `Mod` + `Shift` + `F` | Fit to view | +| `Mod` + `F` | Open workflow search and replace | | `Mod` + `Shift` + `Enter` | Accept Copilot changes | ## Panel Navigation @@ -43,7 +45,7 @@ These shortcuts switch between panel tabs on the right side of the canvas. | Shortcut | Action | |----------|--------| -| `Mod` + `F` | Focus Toolbar search | +| `Mod` + `Alt` + `F` | Focus Toolbar search | ## Global Navigation diff --git a/apps/docs/content/docs/en/tools/apollo.mdx b/apps/docs/content/docs/en/tools/apollo.mdx index d063d017123..355f62d9642 100644 --- a/apps/docs/content/docs/en/tools/apollo.mdx +++ b/apps/docs/content/docs/en/tools/apollo.mdx @@ -49,9 +49,15 @@ Search Apollo | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key | | `person_titles` | array | No | Job titles to search for \(e.g., \["CEO", "VP of Sales"\]\) | +| `include_similar_titles` | boolean | No | Whether to return people with job titles similar to person_titles | | `person_locations` | array | No | Locations to search in \(e.g., \["San Francisco, CA", "New York, NY"\]\) | -| `person_seniorities` | array | No | Seniority levels \(e.g., \["senior", "executive", "manager"\]\) | -| `organization_names` | array | No | Company names to search within | +| `person_seniorities` | array | No | Seniority levels \(one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern\) | +| `organization_ids` | array | No | Apollo organization IDs to filter by \(e.g., \["5e66b6381e05b4008c8331b8"\]\) | +| `organization_names` | array | No | Company names to search within \(legacy filter\) | +| `organization_locations` | array | No | Headquarters locations of the people's current employer \(e.g., \['texas', 'tokyo', 'spain'\]\) | +| `q_organization_domains_list` | array | No | Employer domain names \(e.g., \["apollo.io", "microsoft.com"\]\) — up to 1,000, no www. or @ | +| `organization_num_employees_ranges` | array | No | Employee count ranges for the person\'s current employer. Each entry is "min,max" \(e.g., \["1,10", "250,500", "10000,20000"\]\) | +| `contact_email_status` | array | No | Email statuses to filter by: "verified", "unverified", "likely to engage", "unavailable" | | `q_keywords` | string | No | Keywords to search for | | `page` | number | No | Page number for pagination, default 1 \(e.g., 1, 2, 3\) | | `per_page` | number | No | Results per page, default 25, max 100 \(e.g., 25, 50, 100\) | @@ -76,12 +82,16 @@ Enrich data for a single person using Apollo | `apiKey` | string | Yes | Apollo API key | | `first_name` | string | No | First name of the person | | `last_name` | string | No | Last name of the person | +| `name` | string | No | Full name of the person \(alternative to first_name/last_name\) | +| `id` | string | No | Apollo ID for the person | +| `hashed_email` | string | No | MD5 or SHA-256 hashed email | | `email` | string | No | Email address of the person | | `organization_name` | string | No | Company name where the person works | | `domain` | string | No | Company domain \(e.g., "apollo.io", "acme.com"\) | | `linkedin_url` | string | No | LinkedIn profile URL | | `reveal_personal_emails` | boolean | No | Reveal personal email addresses \(uses credits\) | -| `reveal_phone_number` | boolean | No | Reveal phone numbers \(uses credits\) | +| `reveal_phone_number` | boolean | No | Reveal phone numbers \(uses credits, requires webhook_url\) | +| `webhook_url` | string | No | Webhook URL for async phone number delivery \(required when reveal_phone_number is true\) | #### Output @@ -101,15 +111,18 @@ Enrich data for up to 10 people at once using Apollo | `apiKey` | string | Yes | Apollo API key | | `people` | array | Yes | Array of people to enrich \(max 10\) | | `reveal_personal_emails` | boolean | No | Reveal personal email addresses \(uses credits\) | -| `reveal_phone_number` | boolean | No | Reveal phone numbers \(uses credits\) | +| `reveal_phone_number` | boolean | No | Reveal phone numbers \(uses credits, requires webhook_url\) | +| `webhook_url` | string | No | Webhook URL for async phone number delivery \(required when reveal_phone_number is true\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `people` | json | Array of enriched people data | -| `total` | number | Total number of people processed | -| `enriched` | number | Number of people successfully enriched | +| `matches` | json | Array of enriched people \(null entries indicate no match\) | +| `total_requested_enrichments` | number | Total number of records submitted for enrichment | +| `unique_enriched_records` | number | Number of records successfully enriched | +| `missing_records` | number | Number of records that could not be enriched | +| `credits_consumed` | number | Number of Apollo credits consumed by this request | ### `apollo_organization_search` @@ -120,10 +133,13 @@ Search Apollo | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key | -| `organization_locations` | array | No | Company locations to search | -| `organization_num_employees_ranges` | array | No | Employee count ranges \(e.g., \["1-10", "11-50"\]\) | +| `organization_locations` | array | No | Company HQ locations \(cities, US states, or countries\) | +| `organization_not_locations` | array | No | Exclude companies whose HQ is in these locations | +| `organization_num_employees_ranges` | array | No | Employee count ranges as "min,max" strings \(e.g., \["1,10", "250,500", "10000,20000"\]\) | | `q_organization_keyword_tags` | array | No | Industry or keyword tags | | `q_organization_name` | string | No | Organization name to search for \(e.g., "Acme", "TechCorp"\) | +| `organization_ids` | array | No | Apollo organization IDs to include \(e.g., \["5e66b6381e05b4008c8331b8"\]\) | +| `q_organization_domains_list` | array | No | Domain names to filter by \(no www. or @, up to 1,000\) | | `page` | number | No | Page number for pagination \(e.g., 1, 2, 3\) | | `per_page` | number | No | Results per page, max 100 \(e.g., 25, 50, 100\) | @@ -145,8 +161,7 @@ Enrich data for a single organization using Apollo | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key | -| `organization_name` | string | No | Name of the organization \(e.g., "Acme Corporation"\) - at least one of organization_name or domain is required | -| `domain` | string | No | Company domain \(e.g., "apollo.io", "acme.com"\) - at least one of domain or organization_name is required | +| `domain` | string | Yes | Company domain \(e.g., "apollo.io", "acme.com"\) | #### Output @@ -164,15 +179,17 @@ Enrich data for up to 10 organizations at once using Apollo | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key | -| `organizations` | array | Yes | Array of organizations to enrich \(max 10\) | +| `organizations` | array | Yes | Array of organizations to enrich \(max 10\). Each item requires `name` and may include `domain` \(e.g., \[\{"name": "Example Corp", "domain": "example.com"\}\]\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `organizations` | json | Array of enriched organization data | -| `total` | number | Total number of organizations processed | -| `enriched` | number | Number of organizations successfully enriched | +| `total` | number | Total number of domains requested | +| `enriched` | number | Number of unique enriched records | +| `missing_records` | number | Number of domains that could not be enriched | +| `unique_domains` | number | Number of unique domains processed | ### `apollo_contact_create` @@ -188,7 +205,19 @@ Create a new contact in your Apollo database | `email` | string | No | Email address of the contact | | `title` | string | No | Job title \(e.g., "VP of Sales", "Software Engineer"\) | | `account_id` | string | No | Apollo account ID to associate with \(e.g., "acc_abc123"\) | -| `owner_id` | string | No | User ID of the contact owner | +| `owner_id` | string | No | User ID of the contact owner \(accepted by Apollo but not officially documented for POST /contacts\) | +| `organization_name` | string | No | Name of the contact\'s employer \(e.g., "Apollo"\) | +| `website_url` | string | No | Corporate website URL \(e.g., "https://www.apollo.io/"\) | +| `label_names` | array | No | Lists/labels to add the contact to \(e.g., \["Prospects"\]\) | +| `contact_stage_id` | string | No | Apollo ID for the contact stage | +| `present_raw_address` | string | No | Personal location for the contact \(e.g., "Atlanta, United States"\) | +| `direct_phone` | string | No | Primary phone number | +| `corporate_phone` | string | No | Work/office phone number | +| `mobile_phone` | string | No | Mobile phone number | +| `home_phone` | string | No | Home phone number | +| `other_phone` | string | No | Alternative phone number | +| `typed_custom_fields` | json | No | Custom field values keyed by custom field ID | +| `run_dedupe` | boolean | No | When true, Apollo deduplicates against existing contacts | #### Output @@ -212,7 +241,18 @@ Update an existing contact in your Apollo database | `email` | string | No | Email address | | `title` | string | No | Job title \(e.g., "VP of Sales", "Software Engineer"\) | | `account_id` | string | No | Apollo account ID \(e.g., "acc_abc123"\) | -| `owner_id` | string | No | User ID of the contact owner | +| `owner_id` | string | No | User ID of the contact owner \(accepted by Apollo but not officially documented for PATCH /contacts/\{id\}\) | +| `organization_name` | string | No | Name of the contact\'s employer \(e.g., "Apollo"\) | +| `website_url` | string | No | Corporate website URL \(e.g., "https://www.apollo.io/"\) | +| `label_names` | array | No | Lists/labels to add the contact to \(e.g., \["Prospects"\]\) | +| `contact_stage_id` | string | No | Apollo ID for the contact stage | +| `present_raw_address` | string | No | Personal location for the contact \(e.g., "Atlanta, United States"\) | +| `direct_phone` | string | No | Primary phone number | +| `corporate_phone` | string | No | Work/office phone number | +| `mobile_phone` | string | No | Mobile phone number | +| `home_phone` | string | No | Home phone number | +| `other_phone` | string | No | Alternative phone number | +| `typed_custom_fields` | json | No | Custom field values keyed by custom field ID \(accepted by Apollo but not officially documented for PATCH /contacts/\{id\}\) | #### Output @@ -232,6 +272,9 @@ Search your team | `apiKey` | string | Yes | Apollo API key | | `q_keywords` | string | No | Keywords to search for | | `contact_stage_ids` | array | No | Filter by contact stage IDs | +| `contact_label_ids` | array | No | Filter by Apollo label IDs \(lists\) | +| `sort_by_field` | string | No | Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at | +| `sort_ascending` | boolean | No | When true, sort ascending. Must be used together with sort_by_field | | `page` | number | No | Page number for pagination \(e.g., 1, 2, 3\) | | `per_page` | number | No | Results per page, max 100 \(e.g., 25, 50, 100\) | @@ -251,7 +294,8 @@ Create up to 100 contacts at once in your Apollo database. Supports deduplicatio | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | -| `contacts` | array | Yes | Array of contacts to create \(max 100\). Each contact should include first_name, last_name, and optionally email, title, account_id, owner_id | +| `contacts` | array | Yes | Array of contacts to create \(max 100\). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone \(single string\) or phone_numbers \(array of \{raw_number, position\}\), contact_emails, typed_custom_fields, and CRM IDs \(salesforce_contact_id, hubspot_id, team_id\) for cross-system matching | +| `append_label_names` | array | No | Label names to add to all contacts in this request \(e.g., \["Hot Lead"\]\) | | `run_dedupe` | boolean | No | Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification | #### Output @@ -273,17 +317,16 @@ Update up to 100 existing contacts at once in your Apollo database. Each contact | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | -| `contacts` | array | Yes | Array of contacts to update \(max 100\). Each contact must include id field, and optionally first_name, last_name, email, title, account_id, owner_id | +| `contact_ids` | array | No | Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts. | +| `contact_attributes` | json | No | Required. Either an array of per-contact updates \(each with id\) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields | +| `async` | boolean | No | Force asynchronous processing. Automatically enabled for >100 contacts | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `updated_contacts` | json | Array of successfully updated contacts | -| `failed_contacts` | json | Array of contacts that failed to update | -| `total_submitted` | number | Total number of contacts submitted | -| `updated` | number | Number of contacts successfully updated | -| `failed` | number | Number of contacts that failed to update | +| `message` | string | Confirmation message from Apollo | +| `job_id` | string | Async job ID \(returned for >100 contacts\) | ### `apollo_account_create` @@ -293,11 +336,14 @@ Create a new account (company) in your Apollo database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `apiKey` | string | Yes | Apollo API key | +| `apiKey` | string | Yes | Apollo API key \(master key required\) | | `name` | string | Yes | Company name \(e.g., "Acme Corporation"\) | -| `website_url` | string | No | Company website URL | -| `phone` | string | No | Company phone number | -| `owner_id` | string | No | User ID of the account owner | +| `domain` | string | No | Company domain without www. prefix \(e.g., "acme.com"\) | +| `phone` | string | No | Primary phone number for the account | +| `owner_id` | string | No | Apollo user ID of the account owner | +| `account_stage_id` | string | No | Apollo ID for the account stage to assign this account to | +| `raw_address` | string | No | Corporate location \(e.g., "San Francisco, CA, USA"\) | +| `typed_custom_fields` | json | No | Custom field values as \{ custom_field_id: value \} map | #### Output @@ -317,9 +363,12 @@ Update an existing account in your Apollo database | `apiKey` | string | Yes | Apollo API key | | `account_id` | string | Yes | ID of the account to update \(e.g., "acc_abc123"\) | | `name` | string | No | Company name \(e.g., "Acme Corporation"\) | -| `website_url` | string | No | Company website URL | +| `domain` | string | No | Company domain \(e.g., "acme.com"\) | | `phone` | string | No | Company phone number | -| `owner_id` | string | No | User ID of the account owner | +| `owner_id` | string | No | Apollo user ID of the account owner | +| `account_stage_id` | string | No | Apollo ID for the account stage to assign this account to | +| `raw_address` | string | No | Corporate location \(e.g., "San Francisco, CA, USA"\) | +| `typed_custom_fields` | json | No | Custom field values as \{ custom_field_id: value \} map | #### Output @@ -337,9 +386,11 @@ Search your team | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | -| `q_keywords` | string | No | Keywords to search for in account data | -| `owner_id` | string | No | Filter by account owner user ID | +| `q_organization_name` | string | No | Filter accounts by organization name \(partial-match search\) | | `account_stage_ids` | array | No | Filter by account stage IDs | +| `account_label_ids` | array | No | Filter by account label IDs | +| `sort_by_field` | string | No | Sort field: "account_last_activity_date", "account_created_at", or "account_updated_at" | +| `sort_ascending` | boolean | No | Sort ascending when true. Defaults to descending. | | `page` | number | No | Page number for pagination \(e.g., 1, 2, 3\) | | `per_page` | number | No | Results per page, max 100 \(e.g., 25, 50, 100\) | @@ -352,24 +403,28 @@ Search your team ### `apollo_account_bulk_create` -Create up to 100 accounts at once in your Apollo database. Note: Apollo does not apply deduplication - duplicate accounts may be created if entries share similar names or domains. Master key required. +Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | -| `accounts` | array | Yes | Array of accounts to create \(max 100\). Each account should include name \(required\), and optionally website_url, phone, owner_id | +| `accounts` | array | Yes | Array of accounts to create \(max 100\). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id. | +| `append_label_names` | array | No | Array of label names to add to ALL accounts in this request | +| `run_dedupe` | boolean | No | When true, performs aggressive deduplication by domain, organization_id, and name \(defaults to false\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `created_accounts` | json | Array of newly created accounts | -| `failed_accounts` | json | Array of accounts that failed to create | -| `total_submitted` | number | Total number of accounts submitted | +| `existing_accounts` | json | Array of existing accounts returned by Apollo \(when duplicates are detected\) | +| `failed_accounts` | json | Array of accounts that failed to be created, with reasons for failure | +| `total_submitted` | number | Total number of accounts in the response \(created + existing + failed\) | | `created` | number | Number of accounts successfully created | -| `failed` | number | Number of accounts that failed to create | +| `existing` | number | Number of existing accounts found | +| `failed` | number | Number of accounts that failed to be created | ### `apollo_account_bulk_update` @@ -380,17 +435,18 @@ Update up to 1000 existing accounts at once in your Apollo database (higher limi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | -| `accounts` | array | Yes | Array of accounts to update \(max 1000\). Each account must include id field, and optionally name, website_url, phone, owner_id | +| `account_ids` | array | No | Array of account IDs to update with the same values \(max 1000\). Use with name/owner_id for uniform updates. Use either this OR account_attributes. | +| `name` | string | No | When using account_ids, apply this name to all accounts | +| `owner_id` | string | No | When using account_ids, apply this owner to all accounts | +| `account_attributes` | json | No | Array of account objects with individual updates \(each must include id\). Example: \[\{"id": "acc1", "name": "Acme", "owner_id": "u1", "account_stage_id": "s1", "typed_custom_fields": \{"field_id": "value"\}\}\] | +| `async` | boolean | No | When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `updated_accounts` | json | Array of successfully updated accounts | -| `failed_accounts` | json | Array of accounts that failed to update | -| `total_submitted` | number | Total number of accounts submitted | -| `updated` | number | Number of accounts successfully updated | -| `failed` | number | Number of accounts that failed to update | +| `message` | string | Confirmation message from Apollo | +| `account_ids` | json | IDs of accounts that were updated | ### `apollo_opportunity_create` @@ -402,12 +458,12 @@ Create a new deal for an account in your Apollo database (master key required) | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | | `name` | string | Yes | Name of the opportunity/deal \(e.g., "Enterprise License - Q1"\) | -| `account_id` | string | Yes | ID of the account this opportunity belongs to \(e.g., "acc_abc123"\) | -| `amount` | number | No | Monetary value of the opportunity | -| `stage_id` | string | No | ID of the deal stage | +| `account_id` | string | No | ID of the account this opportunity belongs to \(e.g., "acc_abc123"\) | +| `amount` | string | No | Monetary value as a plain number string with no commas or currency symbols | +| `opportunity_stage_id` | string | No | ID of the opportunity stage | | `owner_id` | string | No | User ID of the opportunity owner | -| `close_date` | string | No | Expected close date \(ISO 8601 format\) | -| `description` | string | No | Description or notes about the opportunity | +| `closed_date` | string | No | Expected close date in YYYY-MM-DD format | +| `typed_custom_fields` | json | No | Custom field values as \{ custom_field_id: value \} map | #### Output @@ -425,10 +481,7 @@ Search and list all deals/opportunities in your team | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key | -| `q_keywords` | string | No | Keywords to search for in opportunity names | -| `account_ids` | array | No | Filter by specific account IDs \(e.g., \["acc_123", "acc_456"\]\) | -| `stage_ids` | array | No | Filter by deal stage IDs | -| `owner_ids` | array | No | Filter by opportunity owner IDs | +| `sort_by_field` | string | No | Sort field: "amount", "is_closed", or "is_won" | | `page` | number | No | Page number for pagination \(e.g., 1, 2, 3\) | | `per_page` | number | No | Results per page, max 100 \(e.g., 25, 50, 100\) | @@ -470,11 +523,11 @@ Update an existing deal/opportunity in your Apollo database | `apiKey` | string | Yes | Apollo API key | | `opportunity_id` | string | Yes | ID of the opportunity to update \(e.g., "opp_abc123"\) | | `name` | string | No | Name of the opportunity/deal \(e.g., "Enterprise License - Q1"\) | -| `amount` | number | No | Monetary value of the opportunity | -| `stage_id` | string | No | ID of the deal stage | +| `amount` | string | No | Monetary value as a plain number string with no commas or currency symbols | +| `opportunity_stage_id` | string | No | ID of the opportunity stage | | `owner_id` | string | No | User ID of the opportunity owner | -| `close_date` | string | No | Expected close date \(ISO 8601 format\) | -| `description` | string | No | Description or notes about the opportunity | +| `closed_date` | string | No | Expected close date in YYYY-MM-DD format | +| `typed_custom_fields` | json | No | Custom field values as \{ custom_field_id: value \} map | #### Output @@ -493,7 +546,6 @@ Search for sequences/campaigns in your team | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | | `q_name` | string | No | Search sequences by name \(e.g., "Outbound Q1", "Follow-up"\) | -| `active` | boolean | No | Filter by active status \(true for active sequences, false for inactive\) | | `page` | number | No | Page number for pagination \(e.g., 1, 2, 3\) | | `per_page` | number | No | Results per page, max 100 \(e.g., 25, 50, 100\) | @@ -516,40 +568,58 @@ Add contacts to an Apollo sequence | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | | `sequence_id` | string | Yes | ID of the sequence to add contacts to \(e.g., "seq_abc123"\) | -| `contact_ids` | array | Yes | Array of contact IDs to add to the sequence \(e.g., \["con_abc123", "con_def456"\]\) | -| `emailer_campaign_id` | string | No | Optional emailer campaign ID | -| `send_email_from_user_id` | string | No | User ID to send emails from | +| `contact_ids` | array | No | Array of contact IDs to add to the sequence \(e.g., \["con_abc123", "con_def456"\]\). Either contact_ids or label_names must be provided. | +| `label_names` | array | No | Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided. | +| `send_email_from_email_account_id` | string | Yes | ID of the email account to send from. Use the Get Email Accounts operation to look this up. | +| `send_email_from_email_address` | string | No | Specific email address to send from within the email account. | +| `sequence_no_email` | boolean | No | Add contacts even if they have no email address | +| `sequence_unverified_email` | boolean | No | Add contacts with unverified email addresses | +| `sequence_job_change` | boolean | No | Add contacts who recently changed jobs | +| `sequence_active_in_other_campaigns` | boolean | No | Add contacts active in other campaigns | +| `sequence_finished_in_other_campaigns` | boolean | No | Add contacts who finished other campaigns | +| `sequence_same_company_in_same_campaign` | boolean | No | Add contacts even if others from the same company are in the sequence | +| `contacts_without_ownership_permission` | boolean | No | Add contacts without ownership permission | +| `add_if_in_queue` | boolean | No | Add contacts even if they are in the queue | +| `contact_verification_skipped` | boolean | No | Skip contact verification when adding | +| `user_id` | string | No | ID of the user performing the action | +| `status` | string | No | Initial status for added contacts: "active" or "paused" | +| `auto_unpause_at` | string | No | ISO 8601 datetime to automatically unpause contacts | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `contacts_added` | json | Array of contact IDs added to the sequence | +| `added` | json | Array of contact objects successfully added to the sequence | +| `skipped` | json | Array of contact objects that were skipped, with reasons | +| `skipped_contact_ids` | json | Skipped contact IDs — either an array of IDs or a hash mapping ID → reason code | +| `emailer_campaign` | json | Details of the emailer campaign \(id, name\) | | `sequence_id` | string | ID of the sequence contacts were added to | | `total_added` | number | Total number of contacts added | +| `total_skipped` | number | Total number of contacts skipped | ### `apollo_task_create` -Create a new task in Apollo +Create one or more tasks in Apollo (one task per contact_id, master key required) #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | -| `note` | string | Yes | Task note/description | -| `contact_id` | string | No | Contact ID to associate with \(e.g., "con_abc123"\) | -| `account_id` | string | No | Account ID to associate with \(e.g., "acc_abc123"\) | -| `due_at` | string | No | Due date in ISO format | -| `priority` | string | No | Task priority | -| `type` | string | No | Task type | +| `user_id` | string | Yes | ID of the Apollo user the task is assigned to | +| `contact_ids` | array | Yes | Array of contact IDs. One task is created per contact. | +| `priority` | string | No | Task priority: "high", "medium", or "low" \(defaults to "medium"\) | +| `due_at` | string | Yes | Due date/time in ISO 8601 format \(e.g., "2024-12-31T23:59:59Z"\) | +| `type` | string | Yes | Task type: "call", "outreach_manual_email", "linkedin_step_connect", "linkedin_step_message", "linkedin_step_view_profile", "linkedin_step_interact_post", or "action_item" | +| `status` | string | Yes | Task status: "scheduled", "completed", or "skipped" | +| `note` | string | No | Free-form note providing context for the task | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `task` | json | Created task data from Apollo | -| `created` | boolean | Whether the task was successfully created | +| `tasks` | json | Array of created tasks \(when returned by Apollo\) | +| `created` | boolean | Whether the request succeeded | ### `apollo_task_search` @@ -560,9 +630,8 @@ Search for tasks in Apollo | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Apollo API key \(master key required\) | -| `contact_id` | string | No | Filter by contact ID \(e.g., "con_abc123"\) | -| `account_id` | string | No | Filter by account ID \(e.g., "acc_abc123"\) | -| `completed` | boolean | No | Filter by completion status | +| `sort_by_field` | string | No | Sort field: "task_due_at" or "task_priority" | +| `open_factor_names` | array | No | Filter by status. Common values: \["task_types"\] for open tasks, \["task_completed_at"\] for completed tasks. | | `page` | number | No | Page number for pagination \(e.g., 1, 2, 3\) | | `per_page` | number | No | Results per page, max 100 \(e.g., 25, 50, 100\) | diff --git a/apps/docs/content/docs/en/tools/hunter.mdx b/apps/docs/content/docs/en/tools/hunter.mdx index d1ea0fff4aa..a87b1f9e151 100644 --- a/apps/docs/content/docs/en/tools/hunter.mdx +++ b/apps/docs/content/docs/en/tools/hunter.mdx @@ -1,5 +1,5 @@ --- -title: Hunter io +title: Hunter.io description: Find and verify professional email addresses --- @@ -53,11 +53,16 @@ Returns companies matching a set of criteria using Hunter.io AI-powered search. | Parameter | Type | Description | | --------- | ---- | ----------- | | `results` | array | List of companies matching the search criteria | +| ↳ `name` | string | Company name | | ↳ `domain` | string | Company domain | -| ↳ `name` | string | Company/organization name | -| ↳ `headcount` | number | Company size/headcount | -| ↳ `technologies` | array | Technologies used by the company | -| ↳ `email_count` | number | Total number of email addresses found | +| ↳ `logo` | string | URL of the company logo | +| ↳ `linkedin_url` | string | LinkedIn profile URL of the company | +| ↳ `company_type` | string | Company type \(e.g., privately held, public company\) | +| ↳ `industry` | string | Industry of the company | +| ↳ `size` | string | Headcount range of the company | +| ↳ `location` | string | Headquarters location | +| ↳ `founded_year` | number | Year the company was founded | +| ↳ `crunchbase_url` | string | Crunchbase URL of the company | ### `hunter_domain_search` @@ -86,8 +91,9 @@ Returns all the email addresses found using one given domain name, with sources. | ↳ `first_name` | string | Person's first name | | ↳ `last_name` | string | Person's last name | | ↳ `position` | string | Job title/position | +| ↳ `position_raw` | string | Raw job title as found | | ↳ `seniority` | string | Seniority level \(junior, senior, executive\) | -| ↳ `department` | string | Department \(executive, it, finance, management, sales, legal, support, hr, marketing, communication\) | +| ↳ `department` | string | Department \(executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations\) | | ↳ `linkedin` | string | LinkedIn profile URL | | ↳ `twitter` | string | Twitter handle | | ↳ `phone_number` | string | Phone number | @@ -106,19 +112,7 @@ Returns all the email addresses found using one given domain name, with sources. | `accept_all` | boolean | Whether the server accepts all email addresses \(may cause false positives\) | | `pattern` | string | The email pattern used by the organization \(e.g., \{first\}, \{first\}.\{last\}\) | | `organization` | string | The organization/company name | -| `description` | string | Description of the organization | -| `industry` | string | Industry classification of the organization | -| `twitter` | string | Twitter handle of the organization | -| `facebook` | string | Facebook page URL of the organization | -| `linkedin` | string | LinkedIn company page URL | -| `instagram` | string | Instagram profile of the organization | -| `youtube` | string | YouTube channel of the organization | -| `technologies` | array | Technologies used by the organization | -| `country` | string | Country where the organization is headquartered | -| `state` | string | State/province where the organization is located | -| `city` | string | City where the organization is located | -| `postal_code` | string | Postal code of the organization | -| `street` | string | Street address of the organization | +| `linked_domains` | array | Other domains linked to the organization | ### `hunter_email_finder` @@ -147,8 +141,17 @@ Finds the most likely email address for a person given their name and company do | `verification` | object | Email verification information | | ↳ `date` | string | Date when the email was verified \(YYYY-MM-DD\) | | ↳ `status` | string | Verification status \(valid, invalid, accept_all, webmail, disposable, unknown\) | +| `first_name` | string | Person's first name | +| `last_name` | string | Person's last name | | `email` | string | The found email address | | `score` | number | Confidence score \(0-100\) for the found email address | +| `domain` | string | Domain that was searched | +| `accept_all` | boolean | Whether the server accepts all email addresses \(may cause false positives\) | +| `position` | string | Job title/position | +| `twitter` | string | Twitter handle | +| `linkedin_url` | string | LinkedIn profile URL | +| `phone_number` | string | Phone number | +| `company` | string | Company name | ### `hunter_email_verifier` @@ -200,15 +203,24 @@ Enriches company data using domain name. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `company` | object | Company information | -| ↳ `name` | string | Company name | -| ↳ `domain` | string | Company domain | -| ↳ `industry` | string | Industry classification | -| ↳ `size` | string | Company size/headcount range | -| ↳ `country` | string | Country where the company is located | -| ↳ `linkedin` | string | LinkedIn company page URL | -| ↳ `twitter` | string | Twitter handle | -| `person` | object | Person information \(undefined for companies_find tool\) | +| `name` | string | Company name | +| `domain` | string | Company domain | +| `description` | string | Company description | +| `industry` | string | Industry classification | +| `sector` | string | Business sector | +| `size` | string | Employee headcount range \(e.g., "11-50"\) | +| `founded_year` | number | Year founded | +| `location` | string | Headquarters location \(formatted\) | +| `country` | string | Country \(full name\) | +| `country_code` | string | ISO 3166-1 alpha-2 country code | +| `state` | string | State/province | +| `city` | string | City | +| `linkedin` | string | LinkedIn handle \(e.g., company/hunterio\) | +| `twitter` | string | Twitter handle | +| `facebook` | string | Facebook handle | +| `logo` | string | Company logo URL | +| `phone` | string | Company phone number | +| `tech` | array | Technologies used by the company | ### `hunter_email_count` diff --git a/apps/docs/content/docs/en/tools/knowledge.mdx b/apps/docs/content/docs/en/tools/knowledge.mdx index b0e1338d9e0..40da7b3f0e3 100644 --- a/apps/docs/content/docs/en/tools/knowledge.mdx +++ b/apps/docs/content/docs/en/tools/knowledge.mdx @@ -60,6 +60,7 @@ Search for similar content in a knowledge base using vector similarity | `results` | array | Array of search results from the knowledge base | | ↳ `documentId` | string | Document ID | | ↳ `documentName` | string | Document name | +| ↳ `sourceUrl` | string | URL to the original source document \(e.g., Confluence page, Google Doc, Notion page\). Null for documents without an external source. | | ↳ `content` | string | Content of the result | | ↳ `chunkIndex` | number | Index of the chunk within the document | | ↳ `similarity` | number | Similarity score of the result | diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index af967f765a0..ad0f6b437ad 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -131,6 +131,7 @@ "outlook", "pagerduty", "parallel_ai", + "peopledatalabs", "perplexity", "pinecone", "pipedrive", diff --git a/apps/docs/content/docs/en/tools/peopledatalabs.mdx b/apps/docs/content/docs/en/tools/peopledatalabs.mdx new file mode 100644 index 00000000000..ace546acde5 --- /dev/null +++ b/apps/docs/content/docs/en/tools/peopledatalabs.mdx @@ -0,0 +1,70 @@ +--- +title: People Data Labs +description: Enrich and search people and companies +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +## Description + +[People Data Labs](https://www.peopledatalabs.com/) is a person and company data provider with a global dataset of 3B+ person profiles and 25M+ company records. The Sim block exposes the core REST endpoints so agents can enrich a single contact, look up a company, or run dataset-wide search queries. + +Use this block to: +- **Person Enrich** — resolve a single person by email, phone, LinkedIn URL, or name + company/location, and pull back their work history, contact info, and skills. +- **Person Search** — run SQL or Elasticsearch DSL queries against the person dataset to build prospect lists or audience segments. +- **Company Enrich** — resolve a single company by name, website, ticker, LinkedIn URL, or PDL ID, and pull back firmographics. +- **Company Search** — query the company dataset with SQL or Elasticsearch DSL. +- **Autocomplete** — get suggested values for fields like `title`, `skill`, `industry`, or `location` to build well-formed search queries. + +Authentication uses an API key passed as the `X-Api-Key` header. Get a key from the [PDL dashboard](https://dashboard.peopledatalabs.com/). +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Enrich a single person or company with People Data Labs, or search the global person and company datasets with SQL or Elasticsearch DSL. Useful for sales enrichment, contact lookup, and CRM hygiene. + + + +## Tools + +### `pdl_person_enrich` + + +### `pdl_person_identify` + + +### `pdl_person_search` + + +### `pdl_bulk_person_enrich` + + +### `pdl_company_enrich` + + +### `pdl_company_search` + + +### `pdl_bulk_company_enrich` + + +### `pdl_clean_company` + + +### `pdl_clean_location` + + +### `pdl_clean_school` + + +### `pdl_autocomplete` + + + diff --git a/apps/docs/content/docs/en/tools/revenuecat.mdx b/apps/docs/content/docs/en/tools/revenuecat.mdx index ad4b42ee4a8..6c2813db7b7 100644 --- a/apps/docs/content/docs/en/tools/revenuecat.mdx +++ b/apps/docs/content/docs/en/tools/revenuecat.mdx @@ -51,8 +51,10 @@ Retrieve subscriber information by app user ID | --------- | ---- | ----------- | | `subscriber` | object | The subscriber object with subscriptions and entitlements | | ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen | +| ↳ `last_seen` | string | ISO 8601 date when subscriber was last seen | | ↳ `original_app_user_id` | string | Original app user ID | -| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase | +| ↳ `original_application_version` | string | iOS only. First App Store version of your app the customer installed | +| ↳ `original_purchase_date` | string | iOS only. Date the app was first purchased/downloaded | | ↳ `management_url` | string | URL for managing the subscriber subscriptions | | ↳ `subscriptions` | object | Map of product identifiers to subscription objects | | ↳ `store_transaction_id` | string | Store transaction identifier | @@ -71,16 +73,13 @@ Retrieve subscriber information by app user ID | ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume | | ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) | | ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects | -| ↳ `grant_date` | string | ISO 8601 grant date | -| ↳ `expires_date` | string | ISO 8601 expiration date | +| ↳ `expires_date` | string | ISO 8601 expiration date \(null for non-expiring entitlements\) | +| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `product_identifier` | string | Product identifier | -| ↳ `is_active` | boolean | Whether the entitlement is active | -| ↳ `will_renew` | boolean | Whether the entitlement will renew | -| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) | | ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal | -| ↳ `store` | string | Store the entitlement was granted from | -| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects | +| ↳ `other_purchases` | object | Other purchases attached to the subscriber | +| ↳ `subscriber_attributes` | object | Custom attributes set on the subscriber. Only returned when using a secret API key | | `metadata` | object | Subscriber summary metadata | | ↳ `app_user_id` | string | The app user ID | | ↳ `first_seen` | string | ISO 8601 date when the subscriber was first seen | @@ -115,21 +114,29 @@ Record a purchase (receipt) for a subscriber via the REST API | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | RevenueCat API key \(public or secret\) | | `appUserId` | string | Yes | The app user ID of the subscriber | -| `fetchToken` | string | Yes | The receipt token or purchase token from the store \(App Store receipt, Google Play purchase token, or Stripe subscription ID\) | -| `productId` | string | Yes | The product identifier for the purchase | -| `price` | number | No | The price of the product in the currency specified | -| `currency` | string | No | ISO 4217 currency code \(e.g., USD, EUR\) | -| `isRestore` | boolean | No | Whether this is a restore of a previous purchase | -| `platform` | string | No | Platform of the purchase \(ios, android, amazon, macos, stripe\). Required for Stripe and Paddle purchases. | +| `fetchToken` | string | Yes | For iOS, the base64-encoded receipt \(or JWSTransaction for StoreKit2\); for Android the purchase token; for Amazon the receipt; for Stripe the subscription ID or Checkout Session ID; for Roku the transaction ID; for Paddle the subscription ID or transaction ID | +| `productId` | string | No | Apple, Google, Amazon, Roku, or Paddle product identifier or SKU. Required for Google. | +| `price` | number | No | Price of the product. Required if you provide a currency. | +| `currency` | string | No | ISO 4217 currency code \(e.g., USD, EUR\). Required if you provide a price. | +| `isRestore` | boolean | No | Deprecated. Triggers configured restore behavior for shared fetch tokens. | +| `presentedOfferingIdentifier` | string | No | Identifier of the offering presented to the customer at the time of purchase. Attached to new transactions in this fetch token and exposed in ETL exports and webhooks. | +| `paymentMode` | string | No | Payment mode for the introductory period. One of: pay_as_you_go, pay_up_front, free_trial. Defaults to free_trial when an introductory period is detected and no value is provided. | +| `introductoryPrice` | number | No | Introductory price paid \(if any\). | +| `attributes` | json | No | JSON object of subscriber attributes to set alongside the purchase. Each key maps to \{"value": string, "updated_at_ms": number\}. | +| `updatedAtMs` | number | No | UNIX epoch in milliseconds used to resolve attribute conflicts at the request level. | +| `platform` | string | Yes | Platform of the purchase. One of: ios, android, amazon, macos, uikitformac, stripe, roku, paddle. Sent as the X-Platform header \(required by RevenueCat\). | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | +| `customer` | object | Customer object returned at the top level of POST /v1/receipts \(first_seen, last_seen, original_app_user_id, original_application_version, original_sdk_version, management_url, entitlements, original_purchase_date, request_date\). Null when the response uses the `value`-wrapped envelope. | | `subscriber` | object | The updated subscriber object after recording the purchase | | ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen | +| ↳ `last_seen` | string | ISO 8601 date when subscriber was last seen | | ↳ `original_app_user_id` | string | Original app user ID | -| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase | +| ↳ `original_application_version` | string | iOS only. First App Store version of your app the customer installed | +| ↳ `original_purchase_date` | string | iOS only. Date the app was first purchased/downloaded | | ↳ `management_url` | string | URL for managing the subscriber subscriptions | | ↳ `subscriptions` | object | Map of product identifiers to subscription objects | | ↳ `store_transaction_id` | string | Store transaction identifier | @@ -148,16 +155,13 @@ Record a purchase (receipt) for a subscriber via the REST API | ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume | | ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) | | ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects | -| ↳ `grant_date` | string | ISO 8601 grant date | -| ↳ `expires_date` | string | ISO 8601 expiration date | +| ↳ `expires_date` | string | ISO 8601 expiration date \(null for non-expiring entitlements\) | +| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `product_identifier` | string | Product identifier | -| ↳ `is_active` | boolean | Whether the entitlement is active | -| ↳ `will_renew` | boolean | Whether the entitlement will renew | -| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) | | ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal | -| ↳ `store` | string | Store the entitlement was granted from | -| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects | +| ↳ `other_purchases` | object | Other purchases attached to the subscriber | +| ↳ `subscriber_attributes` | object | Custom attributes set on the subscriber. Only returned when using a secret API key | ### `revenuecat_grant_entitlement` @@ -170,8 +174,9 @@ Grant a promotional entitlement to a subscriber | `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) | | `appUserId` | string | Yes | The app user ID of the subscriber | | `entitlementIdentifier` | string | Yes | The entitlement identifier to grant | -| `duration` | string | Yes | Duration of the entitlement \(daily, three_day, weekly, monthly, two_month, three_month, six_month, yearly, lifetime\) | -| `startTimeMs` | number | No | Optional start time in milliseconds since Unix epoch. Set to a past time to achieve custom durations shorter than daily. | +| `duration` | string | No | Deprecated. Duration of the entitlement. Provide either duration or endTimeMs \(endTimeMs preferred\). One of: daily, three_day, weekly, two_week, monthly, two_month, three_month, six_month, yearly, lifetime | +| `endTimeMs` | number | No | Absolute end time in milliseconds since Unix epoch. Use instead of duration to grant the entitlement until a specific timestamp. | +| `startTimeMs` | number | No | Deprecated. Optional start time in milliseconds since Unix epoch, used with duration to determine expiration. Regardless of value, the entitlement is always granted immediately. | #### Output @@ -179,8 +184,10 @@ Grant a promotional entitlement to a subscriber | --------- | ---- | ----------- | | `subscriber` | object | The updated subscriber object after granting the entitlement | | ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen | +| ↳ `last_seen` | string | ISO 8601 date when subscriber was last seen | | ↳ `original_app_user_id` | string | Original app user ID | -| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase | +| ↳ `original_application_version` | string | iOS only. First App Store version of your app the customer installed | +| ↳ `original_purchase_date` | string | iOS only. Date the app was first purchased/downloaded | | ↳ `management_url` | string | URL for managing the subscriber subscriptions | | ↳ `subscriptions` | object | Map of product identifiers to subscription objects | | ↳ `store_transaction_id` | string | Store transaction identifier | @@ -199,16 +206,13 @@ Grant a promotional entitlement to a subscriber | ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume | | ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) | | ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects | -| ↳ `grant_date` | string | ISO 8601 grant date | -| ↳ `expires_date` | string | ISO 8601 expiration date | +| ↳ `expires_date` | string | ISO 8601 expiration date \(null for non-expiring entitlements\) | +| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `product_identifier` | string | Product identifier | -| ↳ `is_active` | boolean | Whether the entitlement is active | -| ↳ `will_renew` | boolean | Whether the entitlement will renew | -| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) | | ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal | -| ↳ `store` | string | Store the entitlement was granted from | -| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects | +| ↳ `other_purchases` | object | Other purchases attached to the subscriber | +| ↳ `subscriber_attributes` | object | Custom attributes set on the subscriber. Only returned when using a secret API key | ### `revenuecat_revoke_entitlement` @@ -228,8 +232,10 @@ Revoke all promotional entitlements for a specific entitlement identifier | --------- | ---- | ----------- | | `subscriber` | object | The updated subscriber object after revoking the entitlement | | ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen | +| ↳ `last_seen` | string | ISO 8601 date when subscriber was last seen | | ↳ `original_app_user_id` | string | Original app user ID | -| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase | +| ↳ `original_application_version` | string | iOS only. First App Store version of your app the customer installed | +| ↳ `original_purchase_date` | string | iOS only. Date the app was first purchased/downloaded | | ↳ `management_url` | string | URL for managing the subscriber subscriptions | | ↳ `subscriptions` | object | Map of product identifiers to subscription objects | | ↳ `store_transaction_id` | string | Store transaction identifier | @@ -248,16 +254,13 @@ Revoke all promotional entitlements for a specific entitlement identifier | ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume | | ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) | | ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects | -| ↳ `grant_date` | string | ISO 8601 grant date | -| ↳ `expires_date` | string | ISO 8601 expiration date | +| ↳ `expires_date` | string | ISO 8601 expiration date \(null for non-expiring entitlements\) | +| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `product_identifier` | string | Product identifier | -| ↳ `is_active` | boolean | Whether the entitlement is active | -| ↳ `will_renew` | boolean | Whether the entitlement will renew | -| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) | | ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal | -| ↳ `store` | string | Store the entitlement was granted from | -| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects | +| ↳ `other_purchases` | object | Other purchases attached to the subscriber | +| ↳ `subscriber_attributes` | object | Custom attributes set on the subscriber. Only returned when using a secret API key | ### `revenuecat_list_offerings` @@ -269,7 +272,7 @@ List all offerings configured for the project | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | RevenueCat API key | | `appUserId` | string | Yes | An app user ID to retrieve offerings for | -| `platform` | string | No | Platform to filter offerings \(ios, android, stripe, etc.\) | +| `platform` | string | No | X-Platform header value. One of: ios, android, amazon, stripe, roku, paddle. Required when using a legacy public API key; ignored with app-specific API keys. | #### Output @@ -296,7 +299,7 @@ Update custom subscriber attributes (e.g., $email, $displayName, or custom key-v | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) | | `appUserId` | string | Yes | The app user ID of the subscriber | -| `attributes` | json | Yes | JSON object of attributes to set. Each key maps to an object with a "value" field. Example: \{"$email": \{"value": "user@example.com"\}, "$displayName": \{"value": "John"\}\} | +| `attributes` | json | Yes | JSON object of attributes to set. Each key maps to an object with "value" \(string; null or empty deletes the attribute\) and "updated_at_ms" \(Unix epoch ms used for conflict resolution — required\). Example: \{"$email": \{"value": "user@example.com", "updated_at_ms": 1709195668093\}\} | #### Output @@ -316,7 +319,8 @@ Defer a Google Play subscription by extending its billing date by a number of da | `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) | | `appUserId` | string | Yes | The app user ID of the subscriber | | `productId` | string | Yes | The Google Play product identifier of the subscription to defer \(use the part before the colon for products set up after Feb 2023\) | -| `extendByDays` | number | Yes | Number of days to extend the subscription by \(1-365\) | +| `extendByDays` | number | No | Number of days to extend the subscription by \(1-365\). Provide either extendByDays or expiryTimeMs. | +| `expiryTimeMs` | number | No | Absolute new expiry time in milliseconds since Unix epoch. Use instead of extendByDays to set an exact expiry. | #### Output @@ -324,8 +328,10 @@ Defer a Google Play subscription by extending its billing date by a number of da | --------- | ---- | ----------- | | `subscriber` | object | The updated subscriber object after deferring the Google subscription | | ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen | +| ↳ `last_seen` | string | ISO 8601 date when subscriber was last seen | | ↳ `original_app_user_id` | string | Original app user ID | -| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase | +| ↳ `original_application_version` | string | iOS only. First App Store version of your app the customer installed | +| ↳ `original_purchase_date` | string | iOS only. Date the app was first purchased/downloaded | | ↳ `management_url` | string | URL for managing the subscriber subscriptions | | ↳ `subscriptions` | object | Map of product identifiers to subscription objects | | ↳ `store_transaction_id` | string | Store transaction identifier | @@ -344,20 +350,17 @@ Defer a Google Play subscription by extending its billing date by a number of da | ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume | | ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) | | ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects | -| ↳ `grant_date` | string | ISO 8601 grant date | -| ↳ `expires_date` | string | ISO 8601 expiration date | +| ↳ `expires_date` | string | ISO 8601 expiration date \(null for non-expiring entitlements\) | +| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `product_identifier` | string | Product identifier | -| ↳ `is_active` | boolean | Whether the entitlement is active | -| ↳ `will_renew` | boolean | Whether the entitlement will renew | -| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) | | ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal | -| ↳ `store` | string | Store the entitlement was granted from | -| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects | +| ↳ `other_purchases` | object | Other purchases attached to the subscriber | +| ↳ `subscriber_attributes` | object | Custom attributes set on the subscriber. Only returned when using a secret API key | ### `revenuecat_refund_google_subscription` -Refund and optionally revoke a Google Play subscription (Google Play only) +Refund a specific store transaction by its store transaction identifier and revoke access (subscription or non-subscription, last 365 days) #### Input @@ -365,7 +368,7 @@ Refund and optionally revoke a Google Play subscription (Google Play only) | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | RevenueCat secret API key \(sk_...\) | | `appUserId` | string | Yes | The app user ID of the subscriber | -| `productId` | string | Yes | The Google Play product identifier of the subscription to refund | +| `storeTransactionId` | string | Yes | The store transaction identifier of the purchase to refund \(e.g., GPA.3309-9122-6177-45730 for Google Play\) | #### Output @@ -373,8 +376,10 @@ Refund and optionally revoke a Google Play subscription (Google Play only) | --------- | ---- | ----------- | | `subscriber` | object | The updated subscriber object after refunding the Google subscription | | ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen | +| ↳ `last_seen` | string | ISO 8601 date when subscriber was last seen | | ↳ `original_app_user_id` | string | Original app user ID | -| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase | +| ↳ `original_application_version` | string | iOS only. First App Store version of your app the customer installed | +| ↳ `original_purchase_date` | string | iOS only. Date the app was first purchased/downloaded | | ↳ `management_url` | string | URL for managing the subscriber subscriptions | | ↳ `subscriptions` | object | Map of product identifiers to subscription objects | | ↳ `store_transaction_id` | string | Store transaction identifier | @@ -393,16 +398,13 @@ Refund and optionally revoke a Google Play subscription (Google Play only) | ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume | | ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) | | ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects | -| ↳ `grant_date` | string | ISO 8601 grant date | -| ↳ `expires_date` | string | ISO 8601 expiration date | +| ↳ `expires_date` | string | ISO 8601 expiration date \(null for non-expiring entitlements\) | +| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `product_identifier` | string | Product identifier | -| ↳ `is_active` | boolean | Whether the entitlement is active | -| ↳ `will_renew` | boolean | Whether the entitlement will renew | -| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) | | ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal | -| ↳ `store` | string | Store the entitlement was granted from | -| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects | +| ↳ `other_purchases` | object | Other purchases attached to the subscriber | +| ↳ `subscriber_attributes` | object | Custom attributes set on the subscriber. Only returned when using a secret API key | ### `revenuecat_revoke_google_subscription` @@ -422,8 +424,10 @@ Immediately revoke access to a Google Play subscription and issue a refund (Goog | --------- | ---- | ----------- | | `subscriber` | object | The updated subscriber object after revoking the Google subscription | | ↳ `first_seen` | string | ISO 8601 date when subscriber was first seen | +| ↳ `last_seen` | string | ISO 8601 date when subscriber was last seen | | ↳ `original_app_user_id` | string | Original app user ID | -| ↳ `original_purchase_date` | string | ISO 8601 date of original purchase | +| ↳ `original_application_version` | string | iOS only. First App Store version of your app the customer installed | +| ↳ `original_purchase_date` | string | iOS only. Date the app was first purchased/downloaded | | ↳ `management_url` | string | URL for managing the subscriber subscriptions | | ↳ `subscriptions` | object | Map of product identifiers to subscription objects | | ↳ `store_transaction_id` | string | Store transaction identifier | @@ -442,15 +446,12 @@ Immediately revoke access to a Google Play subscription and issue a refund (Goog | ↳ `auto_resume_date` | string | ISO 8601 date when a paused subscription will auto-resume | | ↳ `product_plan_identifier` | string | Google Play base plan identifier \(for products set up after Feb 2023\) | | ↳ `entitlements` | object | Map of entitlement identifiers to entitlement objects | -| ↳ `grant_date` | string | ISO 8601 grant date | -| ↳ `expires_date` | string | ISO 8601 expiration date | +| ↳ `expires_date` | string | ISO 8601 expiration date \(null for non-expiring entitlements\) | +| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `product_identifier` | string | Product identifier | -| ↳ `is_active` | boolean | Whether the entitlement is active | -| ↳ `will_renew` | boolean | Whether the entitlement will renew | -| ↳ `period_type` | string | Period type \(normal, trial, intro, promotional\) | | ↳ `purchase_date` | string | ISO 8601 date of the latest purchase or renewal | -| ↳ `store` | string | Store the entitlement was granted from | -| ↳ `grace_period_expires_date` | string | ISO 8601 grace period expiration date | | ↳ `non_subscriptions` | object | Map of non-subscription product identifiers to arrays of purchase objects | +| ↳ `other_purchases` | object | Other purchases attached to the subscriber | +| ↳ `subscriber_attributes` | object | Custom attributes set on the subscriber. Only returned when using a secret API key | diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index febad336bcc..04e135f4aaf 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -5030,6 +5030,7 @@ { "documentId": "doc_abc123", "documentName": "Getting Started.pdf", + "sourceUrl": "https://example.atlassian.net/wiki/spaces/DOCS/pages/12345", "content": "To reset your password, go to Settings > Security.", "chunkIndex": 3, "similarity": 0.95, @@ -6264,6 +6265,11 @@ "type": "string", "description": "Filename of the source document." }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "URL to the original source document for connector-synced documents (e.g., a Confluence page, Google Doc, or Notion page). Null for documents without an external source." + }, "content": { "type": "string", "description": "The matched chunk content." diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 279b56a9b03..14fa8639eaf 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -8,6 +8,7 @@ import { EDGE_OPERATIONS, EDGES_OPERATIONS, OPERATION_TARGETS, + SUBBLOCK_OPERATIONS, SUBFLOW_OPERATIONS, VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, @@ -15,6 +16,7 @@ import { import { getActiveWorkflowContext } from '@sim/workflow-authz' import { loadWorkflowFromNormalizedTablesRaw } from '@sim/workflow-persistence/load' import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks' +import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' @@ -46,26 +48,6 @@ interface DbBlockRef { data: unknown } -/** - * Checks if a block is protected (locked or inside a locked ancestor). - * Works with raw DB records. - */ -function isDbBlockProtected(blockId: string, blocksById: Record): boolean { - const block = blocksById[blockId] - if (!block) return false - if (block.locked) return true - const visited = new Set() - let parentId = (block.data as Record | null)?.parentId as string | undefined - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - if (blocksById[parentId]?.locked) return true - parentId = (blocksById[parentId]?.data as Record | null)?.parentId as - | string - | undefined - } - return false -} - /** * Finds all descendant block IDs of a container (recursive). * Works with raw DB block arrays. @@ -251,6 +233,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an case OPERATION_TARGETS.SUBFLOW: await handleSubflowOperationTx(tx, workflowId, op, payload) break + case OPERATION_TARGETS.SUBBLOCK: + await handleSubblockOperationTx(tx, workflowId, op, payload) + break case OPERATION_TARGETS.VARIABLE: await handleVariableOperationTx(tx, workflowId, op, payload) break @@ -876,7 +861,7 @@ async function handleBlocksOperationTx( ) // Filter out protected blocks from deletion request - const deletableIds = ids.filter((id) => !isDbBlockProtected(id, blocksById)) + const deletableIds = ids.filter((id) => !isWorkflowBlockProtected(id, blocksById)) if (deletableIds.length === 0) { logger.info('All requested blocks are protected, skipping deletion') return @@ -991,14 +976,14 @@ async function handleBlocksOperationTx( // Collect all blocks to toggle including descendants of containers for (const id of blockIds) { const block = blocksById[id] - if (!block || isDbBlockProtected(id, blocksById)) continue + if (!block || isWorkflowBlockProtected(id, blocksById)) continue blocksToToggle.add(id) // If it's a loop or parallel, also include all non-locked descendants if (block.type === 'loop' || block.type === 'parallel') { for (const descId of findDbDescendants(id, allBlocks)) { - if (!isDbBlockProtected(descId, blocksById)) { + if (!isWorkflowBlockProtected(descId, blocksById)) { blocksToToggle.add(descId) } } @@ -1053,7 +1038,7 @@ async function handleBlocksOperationTx( // Filter to only toggle handles on unprotected blocks const blocksToToggle = blockIds.filter( - (id) => blocksById[id] && !isDbBlockProtected(id, blocksById) + (id) => blocksById[id] && !isWorkflowBlockProtected(id, blocksById) ) if (blocksToToggle.length === 0) { logger.info('All requested blocks are protected, skipping handles toggle') @@ -1165,13 +1150,13 @@ async function handleBlocksOperationTx( if (!id) continue // Skip protected blocks (locked or inside locked container) - if (isDbBlockProtected(id, blocksById)) { + if (isWorkflowBlockProtected(id, blocksById)) { logger.info(`Skipping block ${id} parent update - block is protected`) continue } // Skip if trying to move into a locked container (or any of its ancestors) - if (parentId && isDbBlockProtected(parentId, blocksById)) { + if (parentId && isWorkflowBlockProtected(parentId, blocksById)) { logger.info(`Skipping block ${id} parent update - target parent ${parentId} is protected`) continue } @@ -1295,7 +1280,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } - if (isDbBlockProtected(payload.target, blocksById)) { + if (isWorkflowBlockProtected(payload.target, blocksById)) { logger.info(`Skipping edge add - target block is protected`) break } @@ -1383,7 +1368,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } - if (isDbBlockProtected(edgeToRemove.targetBlockId, blocksById)) { + if (isWorkflowBlockProtected(edgeToRemove.targetBlockId, blocksById)) { logger.info(`Skipping edge remove - target block is protected`) break } @@ -1494,7 +1479,7 @@ async function handleEdgesOperationTx( } const safeEdgeIds = edgesToRemove - .filter((e: EdgeToRemove) => !isDbBlockProtected(e.targetBlockId, blocksById)) + .filter((e: EdgeToRemove) => !isWorkflowBlockProtected(e.targetBlockId, blocksById)) .map((e: EdgeToRemove) => e.id) if (safeEdgeIds.length === 0) { @@ -1581,7 +1566,7 @@ async function handleEdgesOperationTx( // Filter edges - only add edges where target block is not protected const safeEdges = (edges as Array>).filter( - (e) => !isDbBlockProtected(e.target as string, blocksById) + (e) => !isWorkflowBlockProtected(e.target as string, blocksById) ) if (safeEdges.length === 0) { @@ -1734,6 +1719,86 @@ async function handleSubflowOperationTx( } } +function valuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +// Subblock operations - targeted value updates without replacing workflow state +async function handleSubblockOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any +) { + switch (operation) { + case SUBBLOCK_OPERATIONS.BATCH_UPDATE: { + const updates = payload.updates + if (!Array.isArray(updates) || updates.length === 0) { + return + } + + const allBlocks = await tx + .select({ + id: workflowBlocks.id, + subBlocks: workflowBlocks.subBlocks, + locked: workflowBlocks.locked, + data: workflowBlocks.data, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, workflowId)) + + type SubblockUpdateBlockRecord = (typeof allBlocks)[number] + const blocksById: Record = Object.fromEntries( + allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block]) + ) + + for (const update of updates) { + const { blockId, subblockId, value, expectedValue } = update + if (!blockId || !subblockId) { + throw new Error('Missing required fields for subblock batch update') + } + + const block = blocksById[blockId] + if (!block) { + throw new Error(`Block ${blockId} not found`) + } + + if (isWorkflowBlockProtected(blockId, blocksById)) { + throw new Error(`Block ${blockId} is locked or inside a locked container`) + } + + const subBlocks = { ...((block.subBlocks as Record) || {}) } + const currentSubBlock = subBlocks[subblockId] + const currentValue = currentSubBlock?.value + if (expectedValue !== undefined && !valuesEqual(currentValue, expectedValue)) { + throw new Error(`Subblock ${blockId}.${subblockId} changed since replacement was planned`) + } + + subBlocks[subblockId] = currentSubBlock + ? { ...currentSubBlock, value } + : { id: subblockId, type: 'unknown', value } + + await tx + .update(workflowBlocks) + .set({ + subBlocks, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + + blocksById[blockId] = { ...block, subBlocks } + } + + logger.debug(`Batch updated ${updates.length} subblocks for workflow ${workflowId}`) + break + } + + default: + logger.warn(`Unknown subblock operation: ${operation}`) + throw new Error(`Unsupported subblock operation: ${operation}`) + } +} + // Variable operations - updates workflow.variables JSON field async function handleVariableOperationTx( tx: any, diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index 8977eea550a..6ded2e54741 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -2,7 +2,6 @@ import { setupConnectionHandlers } from '@/handlers/connection' import { setupOperationsHandlers } from '@/handlers/operations' import { setupPresenceHandlers } from '@/handlers/presence' import { setupSubblocksHandlers } from '@/handlers/subblocks' -import { setupTableHandlers } from '@/handlers/tables' import { setupVariablesHandlers } from '@/handlers/variables' import { setupWorkflowHandlers } from '@/handlers/workflow' import type { AuthenticatedSocket } from '@/middleware/auth' @@ -14,6 +13,5 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupSubblocksHandlers(socket, roomManager) setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) - setupTableHandlers(socket, roomManager) setupConnectionHandlers(socket, roomManager) } diff --git a/apps/realtime/src/handlers/subblocks.ts b/apps/realtime/src/handlers/subblocks.ts index b3be99e7457..4650f8487cc 100644 --- a/apps/realtime/src/handlers/subblocks.ts +++ b/apps/realtime/src/handlers/subblocks.ts @@ -3,6 +3,7 @@ import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz' +import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' import { and, eq } from 'drizzle-orm' import type { AuthenticatedSocket } from '@/middleware/auth' import { checkRolePermission } from '@/middleware/permissions' @@ -273,45 +274,33 @@ async function flushSubblockUpdate( let updateSuccessful = false let blockLocked = false await db.transaction(async (tx) => { - const [block] = await tx + const allBlocks = await tx .select({ + id: workflowBlocks.id, subBlocks: workflowBlocks.subBlocks, locked: workflowBlocks.locked, data: workflowBlocks.data, }) .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) + .where(eq(workflowBlocks.workflowId, workflowId)) + type SubblockUpdateBlockRecord = (typeof allBlocks)[number] + const blocksById: Record = Object.fromEntries( + allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block]) + ) + const block = blocksById[blockId] if (!block) { return } - // Check if block is locked directly - if (block.locked) { - logger.info(`Skipping subblock update - block ${blockId} is locked`) + if (isWorkflowBlockProtected(blockId, blocksById)) { + logger.info( + `Skipping subblock update - block ${blockId} is locked or inside a locked container` + ) blockLocked = true return } - // Check if block is inside a locked parent container - const parentId = (block.data as Record | null)?.parentId as - | string - | undefined - if (parentId) { - const [parentBlock] = await tx - .select({ locked: workflowBlocks.locked }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, parentId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - - if (parentBlock?.locked) { - logger.info(`Skipping subblock update - parent ${parentId} is locked`) - blockLocked = true - return - } - } - const subBlocks = (block.subBlocks as any) || {} if (!subBlocks[subblockId]) { subBlocks[subblockId] = { id: subblockId, type: 'unknown', value } diff --git a/apps/realtime/src/handlers/tables.ts b/apps/realtime/src/handlers/tables.ts deleted file mode 100644 index ae9a7c6f003..00000000000 --- a/apps/realtime/src/handlers/tables.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createLogger } from '@sim/logger' -import type { AuthenticatedSocket } from '@/middleware/auth' -import { verifyTableAccess } from '@/middleware/permissions' -import { type IRoomManager, tableRoomName } from '@/rooms/types' - -const logger = createLogger('TableHandlers') - -/** - * Wires `join-table` / `leave-table` socket events. Tables don't track presence - * or last-modified state — joining is a thin wrapper around `socket.join` so the - * Sim API → Realtime HTTP bridge can broadcast row updates back to subscribed clients. - */ -export function setupTableHandlers(socket: AuthenticatedSocket, _roomManager: IRoomManager) { - socket.on('join-table', async ({ tableId }: { tableId?: string }) => { - try { - if (!tableId || typeof tableId !== 'string') { - socket.emit('join-table-error', { - tableId: tableId ?? null, - error: 'tableId required', - code: 'INVALID_TABLE_ID', - retryable: false, - }) - return - } - - const userId = socket.userId - if (!userId) { - socket.emit('join-table-error', { - tableId, - error: 'Authentication required', - code: 'AUTHENTICATION_REQUIRED', - retryable: false, - }) - return - } - - const { hasAccess } = await verifyTableAccess(userId, tableId) - if (!hasAccess) { - socket.emit('join-table-error', { - tableId, - error: 'Access denied to table', - code: 'ACCESS_DENIED', - retryable: false, - }) - return - } - - const room = tableRoomName(tableId) - socket.join(room) - socket.emit('join-table-success', { tableId, socketId: socket.id }) - logger.debug(`Socket ${socket.id} (user ${userId}) joined ${room}`) - } catch (error) { - logger.error(`Error joining table room:`, error) - socket.emit('join-table-error', { - tableId: null, - error: 'Failed to join table', - code: 'JOIN_TABLE_FAILED', - retryable: true, - }) - } - }) - - socket.on('leave-table', async ({ tableId }: { tableId?: string }) => { - try { - if (!tableId || typeof tableId !== 'string') return - const room = tableRoomName(tableId) - socket.leave(room) - logger.debug(`Socket ${socket.id} left ${room}`) - } catch (error) { - logger.error(`Error leaving table room:`, error) - } - }) -} diff --git a/apps/realtime/src/middleware/permissions.test.ts b/apps/realtime/src/middleware/permissions.test.ts index 2d8cd12999c..0aa9cada905 100644 --- a/apps/realtime/src/middleware/permissions.test.ts +++ b/apps/realtime/src/middleware/permissions.test.ts @@ -51,6 +51,11 @@ describe('checkRolePermission', () => { const result = checkRolePermission('admin', 'replace-state') expectPermissionAllowed(result) }) + + it('should allow subblock-batch-update operation', () => { + const result = checkRolePermission('admin', 'subblock-batch-update') + expectPermissionAllowed(result) + }) }) describe('write role', () => { @@ -77,6 +82,11 @@ describe('checkRolePermission', () => { const result = checkRolePermission('write', 'update-position') expectPermissionAllowed(result) }) + + it('should allow subblock-batch-update operation', () => { + const result = checkRolePermission('write', 'subblock-batch-update') + expectPermissionAllowed(result) + }) }) describe('read role', () => { @@ -111,6 +121,11 @@ describe('checkRolePermission', () => { expectPermissionDenied(result, 'read') }) + it('should deny subblock-batch-update operation for read role', () => { + const result = checkRolePermission('read', 'subblock-batch-update') + expectPermissionDenied(result, 'read') + }) + it('should deny toggle-enabled operation for read role', () => { const result = checkRolePermission('read', 'toggle-enabled') expectPermissionDenied(result, 'read') diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index db97b16f8a2..661f4d52d44 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -46,6 +46,7 @@ const WRITE_OPERATIONS: string[] = [ SUBFLOW_OPERATIONS.UPDATE, // Subblock operations SUBBLOCK_OPERATIONS.UPDATE, + SUBBLOCK_OPERATIONS.BATCH_UPDATE, // Variable operations VARIABLE_OPERATIONS.UPDATE, // Workflow operations @@ -131,51 +132,3 @@ export async function verifyWorkflowAccess( return { hasAccess: false } } } - -/** - * Verify a user has read access to a table by virtue of workspace permission. - * Mirrors `verifyWorkflowAccess` for the table-room socket join check. - */ -export async function verifyTableAccess( - userId: string, - tableId: string -): Promise<{ hasAccess: boolean; workspaceId?: string }> { - try { - const { userTableDefinitions, permissions } = await import('@sim/db') - const tableData = await db - .select({ workspaceId: userTableDefinitions.workspaceId }) - .from(userTableDefinitions) - .where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))) - .limit(1) - - if (!tableData.length) { - logger.warn(`Table ${tableId} not found`) - return { hasAccess: false } - } - const { workspaceId } = tableData[0] - if (!workspaceId) return { hasAccess: false } - - const [permissionRow] = await db - .select({ permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - .limit(1) - - if (!permissionRow?.permissionType) { - logger.warn( - `User ${userId} has no permission for workspace ${workspaceId} (table ${tableId})` - ) - return { hasAccess: false } - } - return { hasAccess: true, workspaceId } - } catch (error) { - logger.error(`Error verifying table access for user ${userId}, table ${tableId}:`, error) - return { hasAccess: false } - } -} diff --git a/apps/realtime/src/rooms/memory-manager.ts b/apps/realtime/src/rooms/memory-manager.ts index 0cd37daf493..a032e785bb5 100644 --- a/apps/realtime/src/rooms/memory-manager.ts +++ b/apps/realtime/src/rooms/memory-manager.ts @@ -1,13 +1,6 @@ import { createLogger } from '@sim/logger' import type { Server } from 'socket.io' -import { - type IRoomManager, - type TableRowUpdatedPayload, - tableRoomName, - type UserPresence, - type UserSession, - type WorkflowRoom, -} from '@/rooms/types' +import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types' const logger = createLogger('MemoryRoomManager') @@ -262,23 +255,4 @@ export class MemoryRoomManager implements IRoomManager { logger.info(`Notified ${room.users.size} users about workflow deployment change: ${workflowId}`) } - - emitToTable(tableId: string, event: string, payload: T): void { - this._io.to(tableRoomName(tableId)).emit(event, payload) - } - - async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise { - this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload }) - } - - async handleTableRowDeleted(tableId: string, rowId: string): Promise { - this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId }) - } - - async handleTableDeleted(tableId: string): Promise { - logger.info(`Handling table deletion notification for ${tableId}`) - this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() }) - // Eject sockets so they don't hold a stale room. Cross-pod safe via socket.io. - await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId)) - } } diff --git a/apps/realtime/src/rooms/redis-manager.ts b/apps/realtime/src/rooms/redis-manager.ts index 0fb41417906..0e6b3eadf2b 100644 --- a/apps/realtime/src/rooms/redis-manager.ts +++ b/apps/realtime/src/rooms/redis-manager.ts @@ -1,13 +1,7 @@ import { createLogger } from '@sim/logger' import { createClient, type RedisClientType } from 'redis' import type { Server } from 'socket.io' -import { - type IRoomManager, - type TableRowUpdatedPayload, - tableRoomName, - type UserPresence, - type UserSession, -} from '@/rooms/types' +import type { IRoomManager, UserPresence, UserSession } from '@/rooms/types' const logger = createLogger('RedisRoomManager') @@ -463,23 +457,4 @@ export class RedisRoomManager implements IRoomManager { const userCount = await this.getUniqueUserCount(workflowId) logger.info(`Notified ${userCount} users about workflow deployment change: ${workflowId}`) } - - emitToTable(tableId: string, event: string, payload: T): void { - this._io.to(tableRoomName(tableId)).emit(event, payload) - } - - async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise { - this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload }) - } - - async handleTableRowDeleted(tableId: string, rowId: string): Promise { - this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId }) - } - - async handleTableDeleted(tableId: string): Promise { - logger.info(`Handling table deletion notification for ${tableId}`) - this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() }) - // Eject sockets across all pods via socket.io's Redis adapter. - await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId)) - } } diff --git a/apps/realtime/src/rooms/types.ts b/apps/realtime/src/rooms/types.ts index 9c15c967d54..9553a427e1e 100644 --- a/apps/realtime/src/rooms/types.ts +++ b/apps/realtime/src/rooms/types.ts @@ -143,45 +143,4 @@ export interface IRoomManager { * Handle workflow deployment change - notify users to refresh deployment state */ handleWorkflowDeployed(workflowId: string): Promise - - /** - * Emit an event to all clients in a table room (`table:${tableId}`). - * Tables don't track presence/last-modified state — just pub/sub. - */ - emitToTable(tableId: string, event: string, payload: T): void - - /** - * Notify all clients in a table room of a row write (insert/update/cell-state-change). - * Sim API calls this via the `/api/table-row-updated` HTTP bridge after every successful - * row commit; the client merges the delta into its React Query cache. - */ - handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise - - /** - * Notify all clients in a table room that a row has been deleted. - */ - handleTableRowDeleted(tableId: string, rowId: string): Promise - - /** - * Notify all clients in a table room that the table has been deleted; eject sockets. - */ - handleTableDeleted(tableId: string): Promise -} - -/** - * Payload broadcast on `table-row-updated`. Mirrors the shape of `TableRow.data` so - * the client can merge directly into its React Query rows cache. `position` and - * `updatedAt` are included for cache reconciliation; `data` is the full row data - * (not a per-cell delta) — see plan Notes. - */ -export interface TableRowUpdatedPayload { - rowId: string - data: Record - /** Per-workflow-group execution state. Keyed by `WorkflowGroup.id`. */ - executions?: Record - position: number - updatedAt: string | number } - -/** Socket.IO room name for a table. Namespaced from workflow rooms. */ -export const tableRoomName = (tableId: string): string => `table:${tableId}` diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index 78cd89e63d9..0f8ed73cc52 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -150,52 +150,6 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return } - // Handle table row write notifications from the Sim API - if (req.method === 'POST' && req.url === '/api/table-row-updated') { - try { - const body = await readRequestBody(req) - const { tableId, rowId, data, executions, position, updatedAt } = JSON.parse(body) - await roomManager.handleTableRowUpdated(tableId, { - rowId, - data, - executions, - position, - updatedAt, - }) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table row update notification:', error) - sendError(res, 'Failed to process table row update') - } - return - } - - if (req.method === 'POST' && req.url === '/api/table-row-deleted') { - try { - const body = await readRequestBody(req) - const { tableId, rowId } = JSON.parse(body) - await roomManager.handleTableRowDeleted(tableId, rowId) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table row deletion notification:', error) - sendError(res, 'Failed to process table row deletion') - } - return - } - - if (req.method === 'POST' && req.url === '/api/table-deleted') { - try { - const body = await readRequestBody(req) - const { tableId } = JSON.parse(body) - await roomManager.handleTableDeleted(tableId) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table deletion notification:', error) - sendError(res, 'Failed to process table deletion') - } - return - } - res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Not found' })) } diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index e19d3d267b1..f80e850cebe 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -135,6 +135,7 @@ import { PackageSearchIcon, PagerDutyIcon, ParallelIcon, + PeopleDataLabsIcon, PerplexityIcon, PineconeIcon, PipedriveIcon, @@ -335,6 +336,7 @@ export const blockTypeToIconMap: Record = { outlook: OutlookIcon, pagerduty: PagerDutyIcon, parallel_ai: ParallelIcon, + peopledatalabs: PeopleDataLabsIcon, perplexity: PerplexityIcon, pinecone: PineconeIcon, pipedrive: PipedriveIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 9a1513dc6f8..3e6de1c0eef 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -862,7 +862,7 @@ }, { "name": "Bulk Create Accounts", - "description": "Create up to 100 accounts at once in your Apollo database. Note: Apollo does not apply deduplication - duplicate accounts may be created if entries share similar names or domains. Master key required." + "description": "Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required." }, { "name": "Bulk Update Accounts", @@ -894,7 +894,7 @@ }, { "name": "Create Task", - "description": "Create a new task in Apollo" + "description": "Create one or more tasks in Apollo (one task per contact_id, master key required)" }, { "name": "Search Tasks", @@ -6526,7 +6526,7 @@ { "type": "hunter", "slug": "hunter-io", - "name": "Hunter io", + "name": "Hunter.io", "description": "Find and verify professional email addresses", "longDescription": "Integrate Hunter into the workflow. Can search domains, find email addresses, verify email addresses, discover companies, find companies, and count email addresses.", "bgColor": "#E0E0E0", @@ -9715,6 +9715,69 @@ "integrationTypes": ["search", "ai"], "tags": ["web-scraping", "llm", "agentic"] }, + { + "type": "peopledatalabs", + "slug": "people-data-labs", + "name": "People Data Labs", + "description": "Enrich and search people and companies", + "longDescription": "Enrich a single person or company with People Data Labs, or search the global person and company datasets with SQL or Elasticsearch DSL. Useful for sales enrichment, contact lookup, and CRM hygiene.", + "bgColor": "#4831C3", + "iconName": "PeopleDataLabsIcon", + "docsUrl": "https://docs.sim.ai/tools/peopledatalabs", + "operations": [ + { + "name": "Person Enrich", + "description": "Enrich a single person profile using People Data Labs. Match by email, phone, LinkedIn URL, or name + company/location. Returns work history, contact details, location, and skills." + }, + { + "name": "Person Identify", + "description": "Return up to 20 candidate person matches with confidence scores. Useful when you want to see all plausible matches rather than the single best one. Reference: https://docs.peopledatalabs.com/docs/identify-api-quickstart" + }, + { + "name": "Person Search", + "description": "Search the People Data Labs person dataset using SQL or Elasticsearch DSL. Returns up to 100 matching records per call." + }, + { + "name": "Bulk Person Enrich", + "description": "Enrich up to 100 person records in a single call. Provide a JSON array of request objects, each with a `params` object (and optional `metadata` echoed back). Reference: https://docs.peopledatalabs.com/docs/bulk-person-enrichment-api" + }, + { + "name": "Company Enrich", + "description": "Enrich a single company using People Data Labs. Match by name, website, LinkedIn URL, ticker, or PDL ID." + }, + { + "name": "Company Search", + "description": "Search the People Data Labs company dataset using SQL or Elasticsearch DSL. Returns up to 100 matching companies per call." + }, + { + "name": "Bulk Company Enrich", + "description": "Enrich up to 100 companies in a single call. Provide a JSON array of request objects, each with a `params` object. Reference: https://docs.peopledatalabs.com/docs/bulk-company-enrichment-api" + }, + { + "name": "Company Cleaner", + "description": "Normalize a company string into a canonical company record. Provide at least one of name, website, or profile (LinkedIn URL)." + }, + { + "name": "Location Cleaner", + "description": "Normalize a freeform location string into a structured locality/region/country record with coordinates." + }, + { + "name": "School Cleaner", + "description": "Normalize a school string into a canonical school record. Provide at least one of name, website, or profile (LinkedIn URL)." + }, + { + "name": "Autocomplete", + "description": "Get autocomplete suggestions for a PDL field (title, skill, company, industry, location, school, major, role, sub_role)." + } + ], + "operationCount": 11, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["sales"], + "tags": ["enrichment"] + }, { "type": "perplexity", "slug": "perplexity", @@ -10748,7 +10811,7 @@ }, { "name": "Refund Google Subscription", - "description": "Refund and optionally revoke a Google Play subscription (Google Play only)" + "description": "Refund a specific store transaction by its store transaction identifier and revoke access (subscription or non-subscription, last 365 days)" }, { "name": "Revoke Google Subscription", @@ -14173,7 +14236,7 @@ "description": "Hire a pre-hire into an employee position. Converts an applicant into an active employee record with position, start date, and manager assignment." }, { - "name": "Update Worker", + "name": "Update Personal Information", "description": "Update fields on an existing worker record in Workday." }, { diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 32f3a6a8311..e11b1b548e7 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -16,7 +16,6 @@ import { workflowsOrchestrationMock, workflowsOrchestrationMockFns, workflowsPersistenceUtilsMock, - workflowsPersistenceUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -28,7 +27,7 @@ const { mockCheckChatAccess } = vi.hoisted(() => ({ const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse const mockEncryptSecret = encryptionMockFns.mockEncryptSecret -const mockDeployWorkflow = workflowsPersistenceUtilsMockFns.mockDeployWorkflow +const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy const mockPerformChatUndeploy = workflowsOrchestrationMockFns.mockPerformChatUndeploy const mockNotifySocketDeploymentChanged = workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged @@ -73,7 +72,7 @@ describe('Chat Edit API Route', () => { }) mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }) - mockDeployWorkflow.mockResolvedValue({ success: true, version: 1 }) + mockPerformFullDeploy.mockResolvedValue({ success: true, version: 1 }) mockNotifySocketDeploymentChanged.mockResolvedValue(undefined) }) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 8a70bcb9775..f74a0e04094 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -11,12 +11,12 @@ import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { notifySocketDeploymentChanged, performChatUndeploy } from '@/lib/workflows/orchestration' -import { deployWorkflow } from '@/lib/workflows/persistence/utils' +import { performChatUndeploy, performFullDeploy } from '@/lib/workflows/orchestration' import { checkChatAccess } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' +export const maxDuration = 120 const logger = createLogger('ChatDetailAPI') @@ -126,23 +126,6 @@ export const PATCH = withRouteHandler( } } - // Redeploy the workflow to ensure latest version is active - const deployResult = await deployWorkflow({ - workflowId: existingChat[0].workflowId, - deployedBy: session.user.id, - }) - - if (!deployResult.success) { - logger.warn( - `Failed to redeploy workflow for chat update: ${deployResult.error}, continuing with chat update` - ) - } else { - logger.info( - `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` - ) - await notifySocketDeploymentChanged(existingChat[0].workflowId) - } - let encryptedPassword if (password) { @@ -156,6 +139,27 @@ export const PATCH = withRouteHandler( logger.info('Keeping existing password') } + // Redeploy the workflow to ensure latest version is active + const deployResult = await performFullDeploy({ + workflowId: existingChat[0].workflowId, + userId: session.user.id, + request, + }) + + if (!deployResult.success) { + logger.warn(`Failed to redeploy workflow for chat update: ${deployResult.error}`) + const status = + deployResult.errorCode === 'validation' + ? 400 + : deployResult.errorCode === 'not_found' + ? 404 + : 500 + return createErrorResponse(deployResult.error || 'Failed to redeploy workflow', status) + } + logger.info( + `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` + ) + const updateData: Record = { updatedAt: new Date(), } diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index ba5b4366803..cccbe6af1a1 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -86,7 +86,20 @@ export const GET = withRouteHandler( MAX_EMBEDDED_IMAGES ) - logger.info('Exporting markdown with embedded images', { id, imageCount: imageIds.length }) + logger.info('Exporting markdown', { id, imageCount: imageIds.length }) + + if (imageIds.length === 0) { + const mdName = safeFilename(record.originalName) + const mdBytes = Buffer.from(mdContent, 'utf-8') + return new NextResponse(new Uint8Array(mdBytes), { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Content-Disposition': `attachment; filename="${mdName}"`, + 'Content-Length': String(mdBytes.length), + }, + }) + } const fetchResults = await Promise.allSettled( imageIds.map(async (imageId) => { diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index 80213d54cbb..e61cbd543a8 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -158,6 +158,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 413 } ) } + } else if (context === 'mothership') { + const { generateWorkspaceFileKey } = await import( + '@/lib/uploads/contexts/workspace/workspace-file-manager' + ) + customKey = generateWorkspaceFileKey(workspaceId, fileName) + } else if (context === 'execution') { + const workflowId = (data as { workflowId?: unknown }).workflowId + const executionId = (data as { executionId?: unknown }).executionId + if (typeof workflowId !== 'string' || !workflowId.trim()) { + return NextResponse.json( + { error: 'workflowId is required for execution uploads' }, + { status: 400 } + ) + } + if (typeof executionId !== 'string' || !executionId.trim()) { + return NextResponse.json( + { error: 'executionId is required for execution uploads' }, + { status: 400 } + ) + } + const { generateExecutionFileKey } = await import( + '@/lib/uploads/contexts/execution/utils' + ) + customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) } let uploadId: string diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index f6c22bc4a5d..c8fb824b3c9 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -7,14 +7,25 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' +import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { validateFileType } from '@/lib/uploads/utils/validation' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createErrorResponse } from '@/app/api/files/utils' const logger = createLogger('PresignedUploadAPI') -const VALID_UPLOAD_TYPES = ['knowledge-base', 'chat', 'copilot', 'profile-pictures'] as const +const VALID_UPLOAD_TYPES = [ + 'knowledge-base', + 'chat', + 'copilot', + 'profile-pictures', + 'mothership', + 'workspace-logos', + 'execution', +] as const class PresignedUrlError extends Error { constructor( @@ -116,6 +127,101 @@ export const POST = withRouteHandler(async (request: NextRequest) => { error instanceof Error ? error.message : 'Copilot validation failed' ) } + } else if (uploadType === 'mothership') { + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId?.trim()) { + throw new ValidationError('workspaceId query parameter is required for mothership uploads') + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for mothership uploads' }, + { status: 403 } + ) + } + + const fileValidationError = validateFileType(fileName, contentType) + if (fileValidationError) { + throw new ValidationError(fileValidationError.message) + } + + const customKey = generateWorkspaceFileKey(workspaceId, fileName) + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'mothership', + userId: sessionUserId, + customKey, + expirationSeconds: 3600, + metadata: { workspaceId }, + }) + } else if (uploadType === 'execution') { + const workflowId = request.nextUrl.searchParams.get('workflowId') + const executionId = request.nextUrl.searchParams.get('executionId') + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) { + throw new ValidationError( + 'workflowId, executionId, and workspaceId query parameters are required for execution uploads' + ) + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json( + { error: 'Write or Admin access required for execution uploads' }, + { status: 403 } + ) + } + + const fileValidationError = validateFileType(fileName, contentType) + if (fileValidationError) { + throw new ValidationError(fileValidationError.message) + } + + const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName) + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'execution', + userId: sessionUserId, + customKey, + expirationSeconds: 3600, + metadata: { workspaceId, workflowId, executionId }, + }) + } else if (uploadType === 'workspace-logos') { + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId?.trim()) { + throw new ValidationError( + 'workspaceId query parameter is required for workspace-logos uploads' + ) + } + + const permission = await getUserEntityPermissions(sessionUserId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required for workspace logo uploads' }, + { status: 403 } + ) + } + + if (!isImageFileType(contentType)) { + throw new ValidationError( + 'Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for workspace logo uploads' + ) + } + + presignedUrlResponse = await generatePresignedUploadUrl({ + fileName, + contentType, + fileSize, + context: 'workspace-logos', + userId: sessionUserId, + expirationSeconds: 3600, + metadata: { workspaceId }, + }) } else { if (uploadType === 'profile-pictures') { if (!sessionUserId?.trim()) { diff --git a/apps/sim/app/api/form/route.test.ts b/apps/sim/app/api/form/route.test.ts new file mode 100644 index 00000000000..4be49a941eb --- /dev/null +++ b/apps/sim/app/api/form/route.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { + authMockFns, + dbChainMock, + dbChainMockFns, + resetDbChainMock, + workflowsApiUtilsMock, + workflowsApiUtilsMockFns, + workflowsOrchestrationMock, + workflowsOrchestrationMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckWorkflowAccessForFormCreation } = vi.hoisted(() => ({ + mockCheckWorkflowAccessForFormCreation: vi.fn(), +})) + +const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse +const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn(() => 'form-123'), +})) + +vi.mock('@/app/api/form/utils', () => ({ + checkWorkflowAccessForFormCreation: mockCheckWorkflowAccessForFormCreation, + DEFAULT_FORM_CUSTOMIZATIONS: {}, +})) + +vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isDev: true, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getEmailDomain: vi.fn(() => 'localhost:3000'), +})) + +vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) + +import { POST } from '@/app/api/form/route' + +describe('Form API Route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + + authMockFns.mockGetSession.mockResolvedValue({ + user: { + id: 'user-123', + email: 'user@example.com', + name: 'Test User', + }, + }) + mockCreateErrorResponse.mockImplementation((message, status = 500) => { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + }) + mockCheckWorkflowAccessForFormCreation.mockResolvedValue({ + hasAccess: true, + workflow: { + id: 'workflow-123', + isDeployed: false, + workspaceId: 'workspace-123', + }, + }) + dbChainMockFns.limit.mockResolvedValue([]) + }) + + it('cleans up inserted form when deploy throws', async () => { + mockPerformFullDeploy.mockRejectedValue(new Error('Deploy exploded')) + + const request = new NextRequest('http://localhost:3000/api/form', { + method: 'POST', + body: JSON.stringify({ + workflowId: 'workflow-123', + identifier: 'test-form', + title: 'Test Form', + }), + }) + + const response = await POST(request) + + expect(response.status).toBe(500) + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() + expect(mockCreateErrorResponse).toHaveBeenCalledWith('Deploy exploded', 500) + }) +}) diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts index 1abc86e264b..c0592bc366b 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -12,8 +12,7 @@ import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { notifySocketDeploymentChanged } from '@/lib/workflows/orchestration' -import { deployWorkflow } from '@/lib/workflows/persistence/utils' +import { performFullDeploy } from '@/lib/workflows/orchestration' import { checkWorkflowAccessForFormCreation, DEFAULT_FORM_CUSTOMIZATIONS, @@ -21,11 +20,20 @@ import { import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('FormAPI') +export const maxDuration = 120 function getErrorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback } +async function cleanupFormAfterDeployFailure(formId: string) { + try { + await db.delete(form).where(eq(form.id, formId)) + } catch (cleanupError) { + logger.error('Failed to clean up form after deploy failure:', cleanupError) + } +} + export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -106,21 +114,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return createErrorResponse('Workflow not found or access denied', 404) } - const result = await deployWorkflow({ - workflowId, - deployedBy: session.user.id, - }) - - if (!result.success) { - return createErrorResponse(result.error || 'Failed to deploy workflow', 500) - } - - logger.info( - `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for form (v${result.version})` - ) - - await notifySocketDeploymentChanged(workflowId) - let encryptedPassword = null if (authType === 'password' && password) { const { encrypted } = await encryptSecret(password) @@ -161,6 +154,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { updatedAt: new Date(), }) + let result: Awaited> + try { + result = await performFullDeploy({ + workflowId, + userId: session.user.id, + request, + }) + } catch (error) { + await cleanupFormAfterDeployFailure(id) + throw error + } + + if (!result.success) { + await cleanupFormAfterDeployFailure(id) + const status = + result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 + return createErrorResponse(result.error || 'Failed to deploy workflow', status) + } + + logger.info( + `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for form (v${result.version})` + ) + const baseDomain = getEmailDomain() const protocol = isDev ? 'http' : 'https' const formUrl = `${protocol}://${baseDomain}/form/${identifier}` diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index b565092187d..0b36497d0ad 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -24,7 +24,7 @@ const { mockHandleTagAndVectorSearch, mockGetQueryStrategy, mockGenerateSearchEmbedding, - mockGetDocumentNamesByIds, + mockGetDocumentMetadataByIds, } = vi.hoisted(() => ({ mockDbChain: { select: vi.fn().mockReturnThis(), @@ -43,7 +43,7 @@ const { mockHandleTagAndVectorSearch: vi.fn(), mockGetQueryStrategy: vi.fn(), mockGenerateSearchEmbedding: vi.fn(), - mockGetDocumentNamesByIds: vi.fn(), + mockGetDocumentMetadataByIds: vi.fn(), })) const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess @@ -101,7 +101,7 @@ vi.mock('./utils', () => ({ handleTagAndVectorSearch: mockHandleTagAndVectorSearch, getQueryStrategy: mockGetQueryStrategy, generateSearchEmbedding: mockGenerateSearchEmbedding, - getDocumentNamesByIds: mockGetDocumentNamesByIds, + getDocumentMetadataByIds: mockGetDocumentMetadataByIds, APIError: class APIError extends Error { public status: number constructor(message: string, status: number) { @@ -159,9 +159,9 @@ describe('Knowledge Search API Route', () => { singleQueryOptimized: true, }) mockGenerateSearchEmbedding.mockClear().mockResolvedValue([0.1, 0.2, 0.3, 0.4, 0.5]) - mockGetDocumentNamesByIds.mockClear().mockResolvedValue({ - doc1: 'Document 1', - doc2: 'Document 2', + mockGetDocumentMetadataByIds.mockClear().mockResolvedValue({ + doc1: { filename: 'Document 1', sourceUrl: null }, + doc2: { filename: 'Document 2', sourceUrl: null }, }) mockGetDocumentTagDefinitions.mockClear() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockClear().mockResolvedValue({ @@ -998,8 +998,11 @@ describe('Knowledge Search API Route', () => { }) mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) - mockGetDocumentNamesByIds.mockResolvedValue({ - 'doc-active': 'Active Document.pdf', + mockGetDocumentMetadataByIds.mockResolvedValue({ + 'doc-active': { + filename: 'Active Document.pdf', + sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345', + }, }) const mockTagDefs = { @@ -1023,6 +1026,9 @@ describe('Knowledge Search API Route', () => { expect(data.data.results).toHaveLength(1) expect(data.data.results[0].documentId).toBe('doc-active') expect(data.data.results[0].documentName).toBe('Active Document.pdf') + expect(data.data.results[0].sourceUrl).toBe( + 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345' + ) }) it('should exclude results from deleted documents in tag search', async () => { @@ -1067,8 +1073,8 @@ describe('Knowledge Search API Route', () => { singleQueryOptimized: true, }) - mockGetDocumentNamesByIds.mockResolvedValue({ - 'doc-active-tagged': 'Active Tagged Document.pdf', + mockGetDocumentMetadataByIds.mockResolvedValue({ + 'doc-active-tagged': { filename: 'Active Tagged Document.pdf', sourceUrl: null }, }) const mockTagDefs = { @@ -1140,8 +1146,8 @@ describe('Knowledge Search API Route', () => { }) mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) - mockGetDocumentNamesByIds.mockResolvedValue({ - 'doc-active-combined': 'Active Combined Search.pdf', + mockGetDocumentMetadataByIds.mockResolvedValue({ + 'doc-active-combined': { filename: 'Active Combined Search.pdf', sourceUrl: null }, }) const mockTagDefs = { diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 94c09f6c138..f93c0f5afe6 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -16,7 +16,7 @@ import type { StructuredFilter } from '@/lib/knowledge/types' import { estimateTokenCount } from '@/lib/tokenization/estimators' import { generateSearchEmbedding, - getDocumentNamesByIds, + getDocumentMetadataByIds, getQueryStrategy, handleTagAndVectorSearch, handleTagOnlySearch, @@ -413,7 +413,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) const documentIds = results.map((result) => result.documentId) - const documentNameMap = await getDocumentNamesByIds(documentIds) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) try { PlatformEvents.knowledgeBaseSearched({ @@ -449,9 +449,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) const rerankerScore = rerankedScores.get(result.id) + const docMeta = documentMetadataMap[result.documentId] return { documentId: result.documentId, - documentName: documentNameMap[result.documentId] || undefined, + documentName: docMeta?.filename || undefined, + sourceUrl: docMeta?.sourceUrl ?? null, content: result.content, chunkIndex: result.chunkIndex, metadata: tags, diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 9ebdbe89b3c..526fb12c73b 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -396,11 +396,11 @@ describe('Knowledge Search Utils', () => { }) }) - describe('getDocumentNamesByIds', () => { + describe('getDocumentMetadataByIds', () => { it('should handle empty input gracefully', async () => { - const { getDocumentNamesByIds } = await import('./utils') + const { getDocumentMetadataByIds } = await import('./utils') - const result = await getDocumentNamesByIds([]) + const result = await getDocumentMetadataByIds([]) expect(result).toEqual({}) }) diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index 8ca7e7c438a..afaff875b5b 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -3,9 +3,22 @@ import { document, embedding } from '@sim/db/schema' import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { StructuredFilter } from '@/lib/knowledge/types' -export async function getDocumentNamesByIds( +export interface DocumentMetadata { + filename: string + sourceUrl: string | null +} + +/** + * Batch-fetch display metadata for documents referenced by search results. + * Excludes documents that are user-excluded, archived, or soft-deleted — + * mirrors the visibility filters applied inside the search SQL itself, so + * the lookup will never surface metadata for a row a caller could not have + * legitimately matched. Returns a map keyed by document id; missing ids + * indicate the document is no longer visible and should be skipped. + */ +export async function getDocumentMetadataByIds( documentIds: string[] -): Promise> { +): Promise> { if (documentIds.length === 0) { return {} } @@ -15,6 +28,7 @@ export async function getDocumentNamesByIds( .select({ id: document.id, filename: document.filename, + sourceUrl: document.sourceUrl, }) .from(document) .where( @@ -26,12 +40,12 @@ export async function getDocumentNamesByIds( ) ) - const documentNameMap: Record = {} + const map: Record = {} documents.forEach((doc) => { - documentNameMap[doc.id] = doc.filename + map[doc.id] = { filename: doc.filename, sourceUrl: doc.sourceUrl ?? null } }) - return documentNameMap + return map } export interface SearchResult { diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index b814678caf6..2c817411b68 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' const logger = createLogger('LogsExportAPI') @@ -45,6 +46,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, } + if (params.folderIds) { + params.folderIds = await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) + } + const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId) const filterConditions = buildFilterConditions(params) const conditions = filterConditions diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index cb3690441d2..89f52048b72 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -32,6 +32,7 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' const logger = createLogger('LogsAPI') @@ -162,6 +163,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } } + if (params.folderIds) { + params.folderIds = await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) + } + const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) if (commonFilters) workflowConditions.push(commonFilters) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 17e6a592328..930e2e36d39 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -14,6 +14,7 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' const logger = createLogger('LogsStatsAPI') @@ -37,6 +38,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const workspaceFilter = eq(workflowExecutionLogs.workspaceId, params.workspaceId) + if (params.folderIds) { + params.folderIds = await expandFolderIdsWithDescendants( + params.workspaceId, + params.folderIds + ) + } + const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: true }) const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index d5c50c6c647..05434276654 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -20,6 +20,7 @@ const { mockExecuteJobInline, mockFeatureFlags, mockEnqueue, + mockGetJob, mockStartJob, mockCompleteJob, mockMarkJobFailed, @@ -34,6 +35,7 @@ const { isDev: true, }, mockEnqueue: vi.fn().mockResolvedValue('job-id-1'), + mockGetJob: vi.fn().mockResolvedValue(null), mockStartJob: vi.fn().mockResolvedValue(undefined), mockCompleteJob: vi.fn().mockResolvedValue(undefined), mockMarkJobFailed: vi.fn().mockResolvedValue(undefined), @@ -54,6 +56,7 @@ vi.mock('@/lib/core/config/feature-flags', () => mockFeatureFlags) vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ enqueue: mockEnqueue, + getJob: mockGetJob, startJob: mockStartJob, completeJob: mockCompleteJob, markJobFailed: mockMarkJobFailed, @@ -69,6 +72,7 @@ vi.mock('drizzle-orm', () => ({ ne: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ne' })), lte: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lte' })), lt: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lt' })), + inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values, type: 'inArray' })), not: vi.fn((condition: unknown) => ({ type: 'not', condition })), isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), @@ -166,6 +170,8 @@ function createMockRequest(): NextRequest { describe('Scheduled Workflow Execution API Route', () => { beforeEach(() => { vi.clearAllMocks() + dbChainMockFns.limit.mockReset() + dbChainMockFns.returning.mockReset() resetDbChainMock() requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id') workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({ @@ -180,6 +186,7 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should execute scheduled workflows with Trigger.dev disabled', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'schedule-1' }]).mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) const response = await GET(createMockRequest()) @@ -193,6 +200,7 @@ describe('Scheduled Workflow Execution API Route', () => { it('should queue schedules to Trigger.dev when enabled', async () => { mockFeatureFlags.isTriggerDevEnabled = true + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'schedule-1' }]).mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) const response = await GET(createMockRequest()) @@ -215,6 +223,9 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should execute multiple schedules in parallel', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([{ id: 'schedule-1' }, { id: 'schedule-2' }]) + .mockResolvedValueOnce([]) dbChainMockFns.returning.mockReturnValueOnce(MULTIPLE_SCHEDULES).mockReturnValueOnce([]) const response = await GET(createMockRequest()) @@ -225,7 +236,8 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should execute mothership jobs inline', async () => { - dbChainMockFns.returning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB) + dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'job-1' }]) + dbChainMockFns.returning.mockReturnValueOnce(SINGLE_JOB) const response = await GET(createMockRequest()) @@ -241,6 +253,7 @@ 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.returning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([]) const response = await GET(createMockRequest()) @@ -255,6 +268,8 @@ describe('Scheduled Workflow Execution API Route', () => { requestId: 'test-request-id', }), 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', diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 584425196f5..eb2d9bc96b5 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -1,11 +1,14 @@ import { db, workflowDeploymentVersion, workflowSchedule } from '@sim/db' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm' +import { Cron } from 'croner' +import { and, eq, inArray, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -15,8 +18,13 @@ import { } from '@/background/schedule-execution' export const dynamic = 'force-dynamic' +export const maxDuration = 3600 const logger = createLogger('ScheduledExecuteAPI') +const MAX_CRON_CLAIMS = 20 +const RESERVED_WORKFLOW_CLAIMS = 10 +const RESERVED_JOB_CLAIMS = MAX_CRON_CLAIMS - RESERVED_WORKFLOW_CLAIMS +const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout() const dueFilter = (queuedAt: Date) => and( @@ -26,31 +34,63 @@ const dueFilter = (queuedAt: Date) => ne(workflowSchedule.status, 'completed'), or( isNull(workflowSchedule.lastQueuedAt), - lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt) + lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt), + lt(workflowSchedule.lastQueuedAt, new Date(queuedAt.getTime() - STALE_SCHEDULE_CLAIM_MS)) ) ) -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) +const activeWorkflowDeploymentFilter = () => + sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)` - const authError = verifyCronAuth(request, 'Schedule execution') - if (authError) { - return authError - } +const workflowScheduleFilter = (queuedAt: Date) => + and( + dueFilter(queuedAt), + or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)), + activeWorkflowDeploymentFilter() + ) - const queuedAt = new Date() +const jobScheduleFilter = (queuedAt: Date) => + and(dueFilter(queuedAt), eq(workflowSchedule.sourceType, 'job')) - try { - // Workflow schedules (require active deployment) - const dueSchedules = await db +function buildScheduleExecutionJobId(schedule: { + id: string + nextRunAt?: Date | null + lastQueuedAt?: Date | null +}): string { + const occurrence = + schedule.nextRunAt?.toISOString() ?? schedule.lastQueuedAt?.toISOString() ?? 'due' + return `schedule_${sha256Hex(`${schedule.id}:${occurrence}`).slice(0, 32)}` +} + +function getNextRunFromCronExpression(cronExpression?: string | null): Date | null { + if (!cronExpression) return null + const cron = new Cron(cronExpression) + return cron.nextRun() +} + +async function claimWorkflowSchedules(queuedAt: Date, limit: number) { + if (limit <= 0) return [] + + return db.transaction(async (tx) => { + const rows = await tx + .select({ id: workflowSchedule.id }) + .from(workflowSchedule) + .where(workflowScheduleFilter(queuedAt)) + .for('update', { skipLocked: true }) + .limit(limit) + + if (rows.length === 0) return [] + + return tx .update(workflowSchedule) .set({ lastQueuedAt: queuedAt, updatedAt: queuedAt }) .where( and( - dueFilter(queuedAt), - or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)), - sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)` + workflowScheduleFilter(queuedAt), + inArray( + workflowSchedule.id, + rows.map((row) => row.id) + ) ) ) .returning({ @@ -62,14 +102,37 @@ export const GET = withRouteHandler(async (request: NextRequest) => { failedCount: workflowSchedule.failedCount, nextRunAt: workflowSchedule.nextRunAt, lastQueuedAt: workflowSchedule.lastQueuedAt, + deploymentVersionId: workflowSchedule.deploymentVersionId, sourceType: workflowSchedule.sourceType, }) + }) +} - // Jobs (no deployment, dispatch inline) - const dueJobs = await db +async function claimJobSchedules(queuedAt: Date, limit: number) { + if (limit <= 0) return [] + + return db.transaction(async (tx) => { + const rows = await tx + .select({ id: workflowSchedule.id }) + .from(workflowSchedule) + .where(jobScheduleFilter(queuedAt)) + .for('update', { skipLocked: true }) + .limit(limit) + + if (rows.length === 0) return [] + + return tx .update(workflowSchedule) .set({ lastQueuedAt: queuedAt, updatedAt: queuedAt }) - .where(and(dueFilter(queuedAt), eq(workflowSchedule.sourceType, 'job'))) + .where( + and( + jobScheduleFilter(queuedAt), + inArray( + workflowSchedule.id, + rows.map((row) => row.id) + ) + ) + ) .returning({ id: workflowSchedule.id, cronExpression: workflowSchedule.cronExpression, @@ -77,6 +140,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => { lastQueuedAt: workflowSchedule.lastQueuedAt, sourceType: workflowSchedule.sourceType, }) + }) +} + +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 + } + + const queuedAt = new Date() + + try { + const dueSchedules = await claimWorkflowSchedules(queuedAt, RESERVED_WORKFLOW_CLAIMS) + const dueJobs = await claimJobSchedules(queuedAt, RESERVED_JOB_CLAIMS) + const remainingClaimBudget = Math.max(0, MAX_CRON_CLAIMS - dueSchedules.length - dueJobs.length) + + if (remainingClaimBudget > 0 && dueSchedules.length === RESERVED_WORKFLOW_CLAIMS) { + dueSchedules.push(...(await claimWorkflowSchedules(queuedAt, remainingClaimBudget))) + } else if (remainingClaimBudget > 0 && dueJobs.length === RESERVED_JOB_CLAIMS) { + dueJobs.push(...(await claimJobSchedules(queuedAt, remainingClaimBudget))) + } const totalCount = dueSchedules.length + dueJobs.length logger.info( @@ -108,6 +195,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { requestId, correlation, blockId: schedule.blockId || undefined, + deploymentVersionId: schedule.deploymentVersionId || undefined, cronExpression: schedule.cronExpression || undefined, lastRanAt: schedule.lastRanAt?.toISOString(), failedCount: schedule.failedCount || 0, @@ -116,12 +204,40 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } try { + const scheduleJobId = buildScheduleExecutionJobId(schedule) + const existingJob = await jobQueue.getJob(scheduleJobId) + if (existingJob && ['pending', 'processing'].includes(existingJob.status)) { + logger.info(`[${requestId}] Schedule execution job already exists`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + status: existingJob.status, + }) + return + } + if (existingJob) { + logger.info(`[${requestId}] Releasing stale schedule claim for finished job`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + status: existingJob.status, + }) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Released stale schedule ${schedule.id} for finished job ${scheduleJobId}`, + getNextRunFromCronExpression(schedule.cronExpression) + ) + 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, metadata: { workflowId: schedule.workflowId ?? undefined, workspaceId: resolvedWorkspaceId ?? undefined, @@ -132,6 +248,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` ) + const queuedJob = await jobQueue.getJob(jobId) + if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) { + logger.info(`[${requestId}] Schedule execution job already finished`, { + scheduleId: schedule.id, + jobId, + status: queuedJob.status, + }) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Released stale schedule ${schedule.id} for finished job ${jobId}`, + getNextRunFromCronExpression(schedule.cronExpression) + ) + return + } + if (shouldExecuteInline()) { try { await jobQueue.startJob(jobId) diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts new file mode 100644 index 00000000000..eddfc416e0a --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { runColumnContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableRunColumnAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/table/[tableId]/columns/run */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(runColumnContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, groupIds, runMode, rowIds } = parsed.data.body + const access = await checkAccess(tableId, auth.userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + + // Dispatch in the background — large fan-outs (thousands of rows) issue + // sequential trigger.dev calls and would otherwise hold the HTTP response + // open for minutes, blocking the AI/copilot tool span and the UI mutation. + void runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + requestId, + }).catch((err) => { + logger.error(`[${requestId}] run-column dispatch failed:`, toError(err).message) + }) + + return NextResponse.json({ success: true, data: { triggered: null } }) + } catch (error) { + if (error instanceof Error && error.message === 'Invalid workspace ID') { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + logger.error(`run-column failed:`, error) + return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/events/stream/route.ts b/apps/sim/app/api/table/[tableId]/events/stream/route.ts new file mode 100644 index 00000000000..d938c80c3ec --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/events/stream/route.ts @@ -0,0 +1,161 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { type NextRequest, NextResponse } from 'next/server' +import { tableEventStreamContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { readTableEventsSince, type TableEventEntry } from '@/lib/table/events' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableEventStreamAPI') + +const POLL_INTERVAL_MS = 500 +const HEARTBEAT_INTERVAL_MS = 15_000 +const MAX_STREAM_DURATION_MS = 4 * 60 * 60 * 1000 // 4 hours; client reconnects past this + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteContext { + params: Promise<{ tableId: string }> +} + +/** GET /api/table/[tableId]/events/stream?from= + * + * SSE stream of cell-state transitions. Replay-on-reconnect via `from`. + * Pruning (buffer cap exceeded or TTL expired) sends a `pruned` event and + * closes; the client responds with a full row-query refetch and reconnects + * from the new earliest. */ +export const GET = withRouteHandler(async (req: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + const parsed = await parseRequest(tableEventStreamContract, req, context) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { from: fromEventId } = parsed.data.query + + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const access = await checkAccess(tableId, auth.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + + logger.info(`[${requestId}] Table event stream opened`, { tableId, fromEventId }) + + const encoder = new TextEncoder() + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + let lastEventId = fromEventId + const deadline = Date.now() + MAX_STREAM_DURATION_MS + let nextHeartbeatAt = Date.now() + HEARTBEAT_INTERVAL_MS + + const enqueue = (text: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(text)) + } catch { + closed = true + } + } + + const sendEvents = (events: TableEventEntry[]) => { + for (const entry of events) { + if (closed) return + enqueue(`data: ${JSON.stringify(entry)}\n\n`) + lastEventId = entry.eventId + } + } + + const sendPrunedAndClose = (earliestEventId: number | undefined) => { + enqueue( + `event: pruned\ndata: ${JSON.stringify({ earliestEventId: earliestEventId ?? null })}\n\n` + ) + if (!closed) { + closed = true + try { + controller.close() + } catch {} + } + } + + const sendHeartbeat = () => { + // SSE comment line — keeps proxies (ALB default 60s idle) from closing + // the connection during quiet periods. + enqueue(`: ping ${Date.now()}\n\n`) + } + + try { + // Initial replay from buffer. + const initial = await readTableEventsSince(tableId, lastEventId) + if (initial.status === 'pruned') { + sendPrunedAndClose(initial.earliestEventId) + return + } + if (initial.status === 'unavailable') { + throw new Error(`Table event buffer unavailable: ${initial.error}`) + } + sendEvents(initial.events) + + // Stream loop — poll the buffer and forward new events. Workflow + // execution stream uses the same shape; pub/sub wakeups are an + // optimization we can add later if 500ms polling becomes a problem. + while (!closed && Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS) + if (closed) return + + const result = await readTableEventsSince(tableId, lastEventId) + if (result.status === 'pruned') { + sendPrunedAndClose(result.earliestEventId) + return + } + if (result.status === 'unavailable') { + throw new Error(`Table event buffer unavailable: ${result.error}`) + } + if (result.events.length > 0) { + sendEvents(result.events) + } + + if (Date.now() >= nextHeartbeatAt) { + sendHeartbeat() + nextHeartbeatAt = Date.now() + HEARTBEAT_INTERVAL_MS + } + } + + // Reached the defensive duration ceiling — close cleanly so the client + // reconnects with the latest lastEventId. + if (!closed) { + enqueue(`event: rotate\ndata: {}\n\n`) + closed = true + try { + controller.close() + } catch {} + } + } catch (error) { + logger.error(`[${requestId}] Table event stream error`, { + tableId, + error: toError(error).message, + }) + if (!closed) { + try { + controller.error(error) + } catch {} + } + } + }, + cancel() { + closed = true + logger.info(`[${requestId}] Client disconnected from table event stream`, { tableId }) + }, + }) + + return new NextResponse(stream, { + headers: { ...SSE_HEADERS, 'X-Table-Id': tableId }, + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts b/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts deleted file mode 100644 index 80f80bb7945..00000000000 --- a/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { runWorkflowGroupContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { triggerWorkflowGroupRun } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableRunGroupAPI') - -interface RouteParams { - params: Promise<{ tableId: string; groupId: string }> -} - -/** - * POST /api/table/[tableId]/groups/[groupId]/run - * - * Manually triggers the workflow group for every eligible row in the table. - * Each eligible row's `executions[groupId]` is reset to `pending` so the - * scheduler picks it up and enqueues a per-cell trigger.dev job. Rows whose - * deps aren't satisfied or whose group is already running are skipped. - */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(runWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId, groupId } = parsed.data.params - const { workspaceId, runMode, rowIds } = parsed.data.body - - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result - - if (table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - const { triggered } = await triggerWorkflowGroupRun({ - tableId, - groupId, - workspaceId, - mode: runMode, - requestId, - rowIds, - }) - - return NextResponse.json({ success: true, data: { triggered } }) - } catch (error) { - if (error instanceof Error && error.message === 'Workflow group not found') { - return NextResponse.json({ error: 'Workflow group not found' }, { status: 404 }) - } - if (error instanceof Error && error.message === 'Invalid workspace ID') { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - logger.error(`run-group failed:`, error) - return NextResponse.json({ error: 'Failed to run group' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 847647fc397..bf74653212a 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -110,6 +110,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R ...(validated.newOutputColumns !== undefined ? { newOutputColumns: validated.newOutputColumns } : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), }, requestId ) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts deleted file mode 100644 index aee786d226d..00000000000 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { runRowWorkflowGroupContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { RowExecutionMetadata } from '@/lib/table' -import { updateRow } from '@/lib/table' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableRunWorkflowGroupAPI') - -interface RouteParams { - params: Promise<{ tableId: string; rowId: string }> -} - -/** - * POST /api/table/[tableId]/rows/[rowId]/run-workflow-group - * - * Manually (re-)runs a workflow group for a single row by force-resetting - * `executions[groupId]` to `pending`. The `updateRow` call fires the - * scheduler which enqueues the cell job. - */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(runRowWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId, rowId } = parsed.data.params - const { workspaceId, groupId } = parsed.data.body - - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result - - if (table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - const group = (table.schema.workflowGroups ?? []).find((g) => g.id === groupId) - if (!group) { - return NextResponse.json({ error: 'Workflow group not found' }, { status: 404 }) - } - - const executionId = generateId() - const pendingExec: RowExecutionMetadata = { - status: 'pending', - executionId, - jobId: null, - workflowId: group.workflowId, - error: null, - } - /** - * Clear the group's output cells so the rerun starts visually fresh — - * otherwise stale values from the previous run linger in the UI until the - * new run writes new ones (or doesn't, on error/router-skip). - */ - const clearedData = Object.fromEntries(group.outputs.map((o) => [o.columnName, null])) - const updated = await updateRow( - { - tableId, - rowId, - data: clearedData, - workspaceId, - executionsPatch: { [groupId]: pendingExec }, - }, - table, - requestId - ) - if (updated === null) { - // The cell-task cancellation guard rejected the write — typically a - // racing stop click that already wrote `cancelled` for this run. - // Surface 409 so the caller doesn't poll indefinitely for a run that - // was never enqueued. - return NextResponse.json( - { error: 'Run was cancelled before it could be scheduled' }, - { status: 409 } - ) - } - - return NextResponse.json({ success: true, data: { executionId } }) - } catch (error) { - if (error instanceof Error && error.message === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - logger.error(`run-workflow-group failed:`, error) - return NextResponse.json({ error: 'Failed to run workflow group' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/workday/get-compensation/route.ts b/apps/sim/app/api/tools/workday/get-compensation/route.ts index ce6f03e41f8..9ed21ecdc64 100644 --- a/apps/sim/app/api/tools/workday/get-compensation/route.ts +++ b/apps/sim/app/api/tools/workday/get-compensation/route.ts @@ -9,6 +9,7 @@ import { createWorkdaySoapClient, extractRefId, normalizeSoapArray, + parseSoapNumber, type WorkdayCompensationDataSoap, type WorkdayCompensationPlanSoap, type WorkdayWorkerSoap, @@ -60,7 +61,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const mapPlan = (p: WorkdayCompensationPlanSoap) => ({ id: extractRefId(p.Compensation_Plan_Reference) ?? null, planName: p.Compensation_Plan_Reference?.attributes?.Descriptor ?? null, - amount: p.Amount ?? p.Per_Unit_Amount ?? p.Individual_Target_Amount ?? null, + amount: + parseSoapNumber(p.Amount) ?? + parseSoapNumber(p.Per_Unit_Amount) ?? + parseSoapNumber(p.Individual_Target_Amount) ?? + null, currency: extractRefId(p.Currency_Reference) ?? null, frequency: extractRefId(p.Frequency_Reference) ?? null, }) diff --git a/apps/sim/app/api/tools/workday/get-organizations/route.ts b/apps/sim/app/api/tools/workday/get-organizations/route.ts index e6e03fbba86..adbf5304242 100644 --- a/apps/sim/app/api/tools/workday/get-organizations/route.ts +++ b/apps/sim/app/api/tools/workday/get-organizations/route.ts @@ -9,6 +9,8 @@ import { createWorkdaySoapClient, extractRefId, normalizeSoapArray, + parseSoapBoolean, + parseSoapNumber, type WorkdayOrganizationSoap, } from '@/tools/workday/soap' @@ -63,15 +65,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { | undefined ) - const organizations = orgsArray.map((o) => ({ - id: extractRefId(o.Organization_Reference) ?? null, - descriptor: o.Organization_Descriptor ?? null, - type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null, - subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null, - isActive: o.Organization_Data?.Inactive != null ? !o.Organization_Data.Inactive : null, - })) + const organizations = orgsArray.map((o) => { + const inactive = parseSoapBoolean(o.Organization_Data?.Inactive) + return { + id: extractRefId(o.Organization_Reference) ?? null, + descriptor: o.Organization_Descriptor ?? null, + type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null, + subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null, + isActive: inactive == null ? null : !inactive, + } + }) - const total = result?.Response_Results?.Total_Results ?? organizations.length + const total = parseSoapNumber(result?.Response_Results?.Total_Results) ?? organizations.length return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/workday/list-workers/route.ts b/apps/sim/app/api/tools/workday/list-workers/route.ts index 9fb4406f475..2d7f943d475 100644 --- a/apps/sim/app/api/tools/workday/list-workers/route.ts +++ b/apps/sim/app/api/tools/workday/list-workers/route.ts @@ -9,6 +9,7 @@ import { createWorkdaySoapClient, extractRefId, normalizeSoapArray, + parseSoapNumber, type WorkdayWorkerSoap, } from '@/tools/workday/soap' @@ -61,7 +62,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { employmentData: w.Worker_Data?.Employment_Data ?? null, })) - const total = result?.Response_Results?.Total_Results ?? workers.length + const total = parseSoapNumber(result?.Response_Results?.Total_Results) ?? workers.length return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 0a542820179..13dafe7db70 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -645,6 +645,7 @@ export interface AdminDeployResult { export interface AdminUndeployResult { isDeployed: boolean + warnings?: string[] } // ============================================================================= diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index 281c9836caf..0f088980489 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -18,6 +18,7 @@ import { import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types' const logger = createLogger('AdminWorkflowDeployAPI') +export const maxDuration = 120 interface RouteParams { id: string @@ -109,6 +110,7 @@ export const DELETE = withRouteHandler( const response: AdminUndeployResult = { isDeployed: false, + warnings: result.warnings, } return singleResponse(response) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index aee76a0599b..686cbc71211 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -14,6 +14,7 @@ import { } from '@/app/api/v1/admin/responses' const logger = createLogger('AdminWorkflowActivateVersionAPI') +export const maxDuration = 120 interface RouteParams { id: string diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index beaaa02ea59..8c9842ca0b6 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -15,7 +15,7 @@ const { mockHandleTagAndVectorSearch, mockGetQueryStrategy, mockGenerateSearchEmbedding, - mockGetDocumentNamesByIds, + mockGetDocumentMetadataByIds, mockAuthenticateRequest, mockValidateWorkspaceAccess, } = vi.hoisted(() => ({ @@ -24,7 +24,7 @@ const { mockHandleTagAndVectorSearch: vi.fn(), mockGetQueryStrategy: vi.fn(), mockGenerateSearchEmbedding: vi.fn(), - mockGetDocumentNamesByIds: vi.fn(), + mockGetDocumentMetadataByIds: vi.fn(), mockAuthenticateRequest: vi.fn(), mockValidateWorkspaceAccess: vi.fn(), })) @@ -35,7 +35,7 @@ vi.mock('@/app/api/knowledge/search/utils', () => ({ handleTagAndVectorSearch: mockHandleTagAndVectorSearch, getQueryStrategy: mockGetQueryStrategy, generateSearchEmbedding: mockGenerateSearchEmbedding, - getDocumentNamesByIds: mockGetDocumentNamesByIds, + getDocumentMetadataByIds: mockGetDocumentMetadataByIds, })) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) @@ -81,7 +81,7 @@ describe('v1 knowledge search route — per-KB embedding model', () => { mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 0.5 }) mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) mockHandleVectorOnlySearch.mockResolvedValue([]) - mockGetDocumentNamesByIds.mockResolvedValue({}) + mockGetDocumentMetadataByIds.mockResolvedValue({}) }) it('passes the KB embedding model into generateSearchEmbedding', async () => { @@ -127,6 +127,42 @@ describe('v1 knowledge search route — per-KB embedding model', () => { expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() }) + it('surfaces sourceUrl from document metadata in search results', async () => { + mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ + hasAccess: true, + knowledgeBase: baseKb('kb-confluence', 'text-embedding-3-small'), + }) + mockHandleVectorOnlySearch.mockResolvedValue([ + { + documentId: 'doc-confluence', + knowledgeBaseId: 'kb-confluence', + content: 'page content', + chunkIndex: 0, + distance: 0.1, + }, + ]) + mockGetDocumentMetadataByIds.mockResolvedValue({ + 'doc-confluence': { + filename: 'Runbook.md', + sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345', + }, + }) + + const req = createMockRequest('POST', { + workspaceId: 'ws-1', + knowledgeBaseIds: 'kb-confluence', + query: 'runbook', + }) + const res = await POST(req) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data.results[0].sourceUrl).toBe( + 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345' + ) + expect(body.data.results[0].documentName).toBe('Runbook.md') + }) + it('allows tag-only search across mixed embedding models', async () => { mockHandleTagOnlySearch.mockResolvedValue([]) mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index fdfc1fb2f1e..32679d24b6b 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -8,7 +8,7 @@ import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/ import type { StructuredFilter } from '@/lib/knowledge/types' import { generateSearchEmbedding, - getDocumentNamesByIds, + getDocumentMetadataByIds, getQueryStrategy, handleTagAndVectorSearch, handleTagOnlySearch, @@ -205,7 +205,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) const documentIds = results.map((r) => r.documentId) - const documentNameMap = await getDocumentNamesByIds(documentIds) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) return NextResponse.json({ success: true, @@ -222,9 +222,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } }) + const docMeta = documentMetadataMap[result.documentId] return { documentId: result.documentId, - documentName: documentNameMap[result.documentId] || undefined, + documentName: docMeta?.filename || undefined, + sourceUrl: docMeta?.sourceUrl ?? null, content: result.content, chunkIndex: result.chunkIndex, metadata: tags, diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index 6a5f2b385ee..4ac098c3a2d 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -6,6 +6,7 @@ import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers' import { processOutboxEvents } from '@/lib/core/outbox/service' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' const logger = createLogger('OutboxProcessorAPI') @@ -14,6 +15,7 @@ export const maxDuration = 120 const handlers = { ...billingOutboxHandlers, + ...workflowDeploymentOutboxHandlers, } as const export const GET = withRouteHandler(async (request: NextRequest) => { @@ -25,7 +27,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return authError } - const result = await processOutboxEvents(handlers, { batchSize: 20 }) + const result = await processOutboxEvents(handlers, { + batchSize: 20, + maxRuntimeMs: 110_000, + minRemainingMs: 95_000, + }) logger.info('Outbox processing completed', { requestId, ...result }) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 5a188ba9443..1ea8721a825 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -24,6 +24,7 @@ const logger = createLogger('WorkflowDeployAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +export const maxDuration = 120 export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { @@ -240,6 +241,7 @@ export const DELETE = withRouteHandler( isDeployed: false, deployedAt: null, apiKey: null, + warnings: result.warnings, }) } catch (error: unknown) { if (error instanceof WorkflowLockedError) { diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index e48390dcd12..f8a4113021a 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -15,6 +15,7 @@ const logger = createLogger('WorkflowDeploymentVersionAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' +export const maxDuration = 120 export const GET = withRouteHandler( async ( diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 6ccae0bfe7e..68a0ae3e57d 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -27,8 +27,12 @@ const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById const mockAuthorizeWorkflowByWorkspacePermission = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow -const mockDbUpdate = vi.fn() -const mockDbSelect = vi.fn() + +const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({ + mockDbUpdate: vi.fn(), + mockDbSelect: vi.fn(), + mockDbTransaction: vi.fn(), +})) /** * Helper to set mock auth state consistently across getSession and hybrid auth. @@ -65,6 +69,7 @@ vi.mock('@sim/db', () => ({ db: { update: () => mockDbUpdate(), select: () => mockDbSelect(), + transaction: mockDbTransaction, }, workflow: {}, })) @@ -80,6 +85,18 @@ describe('Workflow By ID API Route', () => { }) mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) + mockDbTransaction.mockImplementation(async (callback) => + callback({ + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }) + ) }) describe('GET /api/workflows/[id]', () => { diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 6feac3d767d..20c9c896ba5 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -8,7 +8,7 @@ import { FolderLockedError, WorkflowLockedError, } from '@sim/workflow-authz' -import { and, eq, isNull, ne } from 'drizzle-orm' +import { and, eq, isNull, ne, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkflowContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -85,7 +85,15 @@ export const GET = withRouteHandler( } } - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + const snapshot = await db.transaction(async (tx) => { + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) + const [normalizedData, [workflowRecord]] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId, tx), + tx.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1), + ]) + return { normalizedData, workflowRecord } + }) + const responseWorkflowData = snapshot.workflowRecord ?? workflowData // Stamp `workflowId` from the path param on each variable so the // global client-side variables store can filter by workflow without @@ -93,7 +101,7 @@ export const GET = withRouteHandler( // The persisted blob may or may not include `workflowId` depending on // when the variable was last written; the path param is authoritative. const persistedVariables = - (workflowData.variables as Record>) || {} + (responseWorkflowData.variables as Record>) || {} const stampedVariables: Record> = {} for (const [variableId, variable] of Object.entries(persistedVariables)) { if (variable && typeof variable === 'object') { @@ -101,20 +109,20 @@ export const GET = withRouteHandler( } } - if (normalizedData) { + if (snapshot.normalizedData) { const finalWorkflowData = { - ...workflowData, + ...responseWorkflowData, state: { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, + blocks: snapshot.normalizedData.blocks, + edges: snapshot.normalizedData.edges, + loops: snapshot.normalizedData.loops, + parallels: snapshot.normalizedData.parallels, lastSaved: Date.now(), - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt, + isDeployed: responseWorkflowData.isDeployed || false, + deployedAt: responseWorkflowData.deployedAt, metadata: { - name: workflowData.name, - description: workflowData.description, + name: responseWorkflowData.name, + description: responseWorkflowData.description, }, }, variables: stampedVariables, @@ -128,18 +136,18 @@ export const GET = withRouteHandler( } const emptyWorkflowData = { - ...workflowData, + ...responseWorkflowData, state: { blocks: {}, edges: [], loops: {}, parallels: {}, lastSaved: Date.now(), - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt, + isDeployed: responseWorkflowData.isDeployed || false, + deployedAt: responseWorkflowData.deployedAt, metadata: { - name: workflowData.name, - description: workflowData.description, + name: responseWorkflowData.name, + description: responseWorkflowData.description, }, }, variables: stampedVariables, diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index a28da778e3d..5aa8010084a 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -7,7 +7,7 @@ import { authorizeWorkflowByWorkspacePermission, WorkflowLockedError, } from '@sim/workflow-authz' -import { eq } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { putWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -52,8 +52,20 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { + const snapshot = await db.transaction(async (tx) => { + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) + const [normalized, [workflowRecord]] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId, tx), + tx + .select({ variables: workflow.variables }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), + ]) + return { normalized, variables: workflowRecord?.variables } + }) + + if (!snapshot.normalized) { return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) } @@ -62,7 +74,7 @@ export const GET = withRouteHandler( // requiring clients to thread the path param through. The read // contract requires this server-stamped field. const persistedVariables = - (authorization.workflow?.variables as Record>) || {} + (snapshot.variables as Record>) || {} const variables: Record> = {} for (const [variableId, variable] of Object.entries(persistedVariables)) { if (variable && typeof variable === 'object') { @@ -71,10 +83,10 @@ export const GET = withRouteHandler( } return NextResponse.json({ - blocks: normalized.blocks, - edges: normalized.edges, - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, + blocks: snapshot.normalized.blocks, + edges: snapshot.normalized.edges, + loops: snapshot.normalized.loops || {}, + parallels: snapshot.normalized.parallels || {}, variables, }) } catch (error) { @@ -185,10 +197,41 @@ export const PUT = withRouteHandler( deployedAt: state.deployedAt, } - const saveResult = await saveWorkflowToNormalizedTables( - workflowId, - workflowState as WorkflowState - ) + const saveResult = await db.transaction(async (tx) => { + await tx + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + .for('update') + + const result = await saveWorkflowToNormalizedTables( + workflowId, + workflowState as WorkflowState, + tx + ) + + if (!result.success) return result + + // Update workflow's lastSynced timestamp and variables if provided + const updateData: { + lastSynced: Date + updatedAt: Date + variables?: typeof state.variables + } = { + lastSynced: new Date(), + updatedAt: new Date(), + } + + // If variables are provided in the state, update them in the workflow record + if (state.variables !== undefined) { + updateData.variables = state.variables + } + + await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) + + return result + }) if (!saveResult.success) { logger.error( @@ -235,19 +278,6 @@ export const PUT = withRouteHandler( logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) } - // Update workflow's lastSynced timestamp and variables if provided - const updateData: any = { - lastSynced: new Date(), - updatedAt: new Date(), - } - - // If variables are provided in the state, update them in the workflow record - if (state.variables !== undefined) { - updateData.variables = state.variables - } - - await db.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) - const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index f460f551b1f..223f6b1e02a 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -1,9 +1,9 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { hasWorkflowChanged } from '@/lib/workflows/comparison' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -46,25 +46,10 @@ export async function checkNeedsRedeployment(workflowId: string): Promise }) => { - const parsed = await parseRequest(workspaceFileStyleContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(workspaceFileStyleContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) if (!membership) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) @@ -42,13 +44,20 @@ export const GET = withRouteHandler( } const rawExt = fileRecord.name.split('.').pop()?.toLowerCase() - if (rawExt !== 'docx' && rawExt !== 'pptx') { + if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { return NextResponse.json( - { error: 'Style extraction only supports .docx and .pptx files' }, + { error: 'Style extraction supports .docx, .pptx, and .pdf files' }, + { status: 422 } + ) + } + const ext: 'docx' | 'pptx' | 'pdf' = rawExt + + if (fileRecord.size > MAX_STYLE_FILE_BYTES) { + return NextResponse.json( + { error: 'File is too large for style extraction (limit: 100 MB)' }, { status: 422 } ) } - const ext: 'docx' | 'pptx' = rawExt let buffer: Buffer try { @@ -66,17 +75,13 @@ export const GET = withRouteHandler( return NextResponse.json( { error: - 'File is not a compiled binary document — style extraction requires an uploaded or compiled .docx/.pptx file', + 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information', }, { status: 422 } ) } - logger.info('Extracted style summary via API', { - fileId, - format: ext, - themeName: summary.theme.name, - }) + logger.info('Extracted style summary via API', { fileId, format: ext }) return NextResponse.json(summary, { headers: { 'Cache-Control': 'private, max-age=300' }, diff --git a/apps/sim/app/changelog/components/timeline-list.tsx b/apps/sim/app/changelog/components/timeline-list.tsx index 26c91197345..a506c61f394 100644 --- a/apps/sim/app/changelog/components/timeline-list.tsx +++ b/apps/sim/app/changelog/components/timeline-list.tsx @@ -195,7 +195,7 @@ export default function ChangelogList({ initialEntries }: Props) { ), inlineCode: ({ children }) => ( - + {children} ), diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx index 38e9f746bc2..9bac9d9fc74 100644 --- a/apps/sim/app/templates/[id]/template.tsx +++ b/apps/sim/app/templates/[id]/template.tsx @@ -887,7 +887,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template ), li: ({ children }) =>
  • {children}
  • , inlineCode: ({ children }) => ( - + {children} ), diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 22686115782..9b4392d0110 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -55,6 +55,8 @@ interface ResourceHeaderProps { breadcrumbs?: BreadcrumbItem[] create?: CreateAction actions?: HeaderAction[] + /** Arbitrary content rendered in the right-aligned actions row, before `actions`. */ + leadingActions?: React.ReactNode /** Arbitrary content rendered in the right-aligned actions row, before the Create button. */ trailingActions?: React.ReactNode /** @@ -71,6 +73,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, create, actions, + leadingActions, trailingActions, createTrigger, }: ResourceHeaderProps) { @@ -106,6 +109,7 @@ export const ResourceHeader = memo(function ResourceHeader({ )}
    + {leadingActions} {actions?.map((action) => { const ActionIcon = action.icon return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 2c6fa99d5e2..0d79bee8d04 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ComponentPropsWithoutRef, useEffect, useMemo, useRef } from 'react' +import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef } from 'react' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import 'prismjs/components/prism-typescript' @@ -217,7 +217,7 @@ const MARKDOWN_COMPONENTS = { }, inlineCode({ children }: { children?: React.ReactNode }) { return ( - + {children} ) @@ -237,7 +237,7 @@ interface ChatContentProps { onWorkspaceResourceSelect?: (resource: MothershipResource) => void } -export function ChatContent({ +function ChatContentInner({ content, isStreaming = false, onOptionSelect, @@ -335,3 +335,5 @@ export function ChatContent({
    ) } + +export const ChatContent = memo(ChatContentInner) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 73c5b371948..b512908ec0a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1,5 +1,6 @@ 'use client' +import { memo, useMemo } from 'react' import { Read as ReadTool, ToolSearchToolRegex, @@ -407,14 +408,14 @@ interface MessageContentProps { onWorkspaceResourceSelect?: (resource: MothershipResource) => void } -export function MessageContent({ +function MessageContentInner({ blocks, fallbackContent, isStreaming = false, onOptionSelect, onWorkspaceResourceSelect, }: MessageContentProps) { - const parsed = blocks.length > 0 ? parseBlocks(blocks) : [] + const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks]) const segments: MessageSegment[] = parsed.length > 0 @@ -537,3 +538,5 @@ export function MessageContent({ ) } + +export const MessageContent = memo(MessageContentInner) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 4693b19de4a..809c190ffea 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { useLayoutEffect, useRef } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { cn } from '@/lib/core/utils/cn' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' @@ -17,6 +17,9 @@ import { import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content' import type { ChatMessage, + ChatMessageAttachment, + ChatMessageContext, + ContentBlock, FileAttachmentForApi, MothershipResource, QueuedMessage, @@ -78,6 +81,100 @@ const LAYOUT_STYLES = { }, } as const +const EMPTY_BLOCKS: ContentBlock[] = [] + +interface UserMessageRowProps { + content: string + contexts?: ChatMessageContext[] + attachments?: ChatMessageAttachment[] + rowClassName: string + bubbleClassName: string + attachmentWidthClassName: string +} + +const UserMessageRow = memo(function UserMessageRow({ + content, + contexts, + attachments, + rowClassName, + bubbleClassName, + attachmentWidthClassName, +}: UserMessageRowProps) { + const hasAttachments = Boolean(attachments?.length) + return ( +
    + {hasAttachments && ( + + )} +
    + +
    +
    + ) +}) + +interface AssistantMessageRowProps { + message: ChatMessage + isStreaming: boolean + precedingUserContent?: string + chatId?: string + rowClassName: string + onOptionSelect?: (id: string) => void + onWorkspaceResourceSelect?: (resource: MothershipResource) => void +} + +const AssistantMessageRow = memo(function AssistantMessageRow({ + message, + isStreaming, + precedingUserContent, + chatId, + rowClassName, + onOptionSelect, + onWorkspaceResourceSelect, +}: AssistantMessageRowProps) { + const blocks = message.contentBlocks ?? EMPTY_BLOCKS + const hasAnyBlocks = blocks.length > 0 + const trimmedContent = message.content?.trim() ?? '' + + if (!hasAnyBlocks && !trimmedContent && isStreaming) { + return + } + + const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '') + if (!hasRenderableAssistant && !trimmedContent && !isStreaming) { + return null + } + + const showActions = !isStreaming && (message.content || hasAnyBlocks) + + return ( +
    + + {showActions && ( +
    + +
    + )} +
    + ) +}) + export function MothershipChat({ messages, isSending, @@ -111,17 +208,31 @@ export function MothershipChat({ const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey) const stagedMessageCount = stagedMessages.length const stagedOffset = messages.length - stagedMessages.length - const precedingUserContentByIndex: Array = [] - let lastUserContent: string | undefined - for (const [index, message] of messages.entries()) { - precedingUserContentByIndex[index] = lastUserContent - if (message.role === 'user') { - lastUserContent = message.content + const precedingUserContentByIndex = useMemo(() => { + const out: Array = [] + let lastUserContent: string | undefined + for (const [index, message] of messages.entries()) { + out[index] = lastUserContent + if (message.role === 'user') lastUserContent = message.content } - } + return out + }, [messages]) const initialScrollDoneRef = useRef(false) const userInputRef = useRef(null) + const onSubmitRef = useRef(onSubmit) + const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) + useEffect(() => { + onSubmitRef.current = onSubmit + onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect + }, [onSubmit, onWorkspaceResourceSelect]) + const stableOnOptionSelect = useCallback((id: string) => { + onSubmitRef.current(id) + }, []) + const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => { + onWorkspaceResourceSelectRef.current?.(resource) + }, []) + function handleSendQueuedHead() { const topMessage = messageQueue[0] if (!topMessage) return @@ -164,63 +275,31 @@ export function MothershipChat({ {stagedMessages.map((msg, localIndex) => { const index = stagedOffset + localIndex if (msg.role === 'user') { - const hasAttachments = Boolean(msg.attachments?.length) return ( -
    - {hasAttachments && ( - - )} -
    - -
    -
    + ) } - const hasAnyBlocks = Boolean(msg.contentBlocks?.length) - const hasRenderableAssistant = assistantMessageHasRenderableContent( - msg.contentBlocks ?? [], - msg.content ?? '' - ) - const isLastAssistant = index === messages.length - 1 - const isThisStreaming = isStreamActive && isLastAssistant - - if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) { - return - } - - if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) { - return null - } - - const isLastMessage = index === messages.length - 1 - const precedingUserContent = precedingUserContentByIndex[index] - + const isLast = index === messages.length - 1 return ( -
    - - {!isThisStreaming && (msg.content || msg.contentBlocks?.length) && ( -
    - -
    - )} -
    + ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 1adfb0f3445..e93fe37cd6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -2,7 +2,6 @@ import { lazy, memo, Suspense, useEffect, useMemo, useRef } from 'react' import { createLogger } from '@sim/logger' -import { Square } from 'lucide-react' import { useRouter } from 'next/navigation' import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' import { @@ -10,6 +9,7 @@ import { FileX, Folder as FolderIcon, Library, + Square, SquareArrowUpRight, WorkflowX, } from '@/components/emcn/icons' @@ -43,7 +43,7 @@ import { useUserPermissionsContext, useWorkspacePermissionsContext, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' +import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { useFolders } from '@/hooks/queries/folders' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx index 5403aa76fbf..077727a005a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx @@ -89,6 +89,7 @@ export const PlusMenuDropdown = React.memo( items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item })) ) }, [isMention, mentionQuery, search, availableResources]) + const isRootMenu = !isMention && filteredItems === null const filteredItemsRef = useRef(filteredItems) filteredItemsRef.current = filteredItems @@ -248,6 +249,7 @@ export const PlusMenuDropdown = React.memo( collisionPadding={8} className={cn( 'flex flex-col overflow-hidden', + isRootMenu && 'max-h-none', // Plus-click shows short fixed labels (Workflows, Tables, …) — let it size // to its content via the emcn DropdownMenuContent default max-w. // Mention mode renders resource names directly, so widen for breathing room. diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts new file mode 100644 index 00000000000..df2631a16f7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -0,0 +1,234 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { + MothershipStreamV1EventType, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' +import { + getReplayCompletedWorkflowToolCallIds, + reconcileLiveAssistantTurn, + selectReconnectReplayState, +} from '@/app/workspace/[workspaceId]/home/hooks/use-chat' +import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/workspace/workspace-1/home', + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + }), +})) + +function userMessage(id: string): PersistedMessage { + return { + id, + role: 'user', + content: 'Question', + timestamp: '2026-05-08T00:00:00.000Z', + } +} + +function assistantMessage(id: string, content: string): PersistedMessage { + return { + id, + role: 'assistant', + content, + timestamp: '2026-05-08T00:00:01.000Z', + } +} + +function toolBatchEvent( + eventId: number, + toolCallId: string, + toolName: string, + phase: MothershipStreamV1ToolPhase +): StreamBatchEvent { + return { + eventId, + streamId: 'stream-1', + event: { + v: 1, + seq: eventId, + ts: '2026-05-08T00:00:00.000Z', + type: MothershipStreamV1EventType.tool, + stream: { streamId: 'stream-1' }, + payload: { + phase, + toolCallId, + toolName, + }, + }, + } as StreamBatchEvent +} + +describe('reconcileLiveAssistantTurn', () => { + it('replaces the live assistant for the active stream owner', () => { + const liveAssistant = assistantMessage('live-assistant:stream-1', 'updated') + const messages = [userMessage('stream-1'), assistantMessage('live-assistant:stream-1', 'old')] + + const result = reconcileLiveAssistantTurn({ + messages, + streamId: 'stream-1', + liveAssistant, + activeStreamId: 'stream-1', + }) + + expect(result).toEqual([userMessage('stream-1'), liveAssistant]) + }) + + it('replaces the generated assistant after the owner while the stream is active', () => { + const liveAssistant = assistantMessage('live-assistant:stream-1', 'live content') + + const result = reconcileLiveAssistantTurn({ + messages: [userMessage('stream-1'), assistantMessage('final-1', 'persisted content')], + streamId: 'stream-1', + liveAssistant, + activeStreamId: 'stream-1', + }) + + expect(result).toEqual([userMessage('stream-1'), liveAssistant]) + }) + + it('leaves a terminal persisted assistant alone when the stream is no longer active', () => { + const messages = [userMessage('stream-1'), assistantMessage('final-1', 'persisted content')] + + const result = reconcileLiveAssistantTurn({ + messages, + streamId: 'stream-1', + liveAssistant: assistantMessage('live-assistant:stream-1', 'stale live content'), + activeStreamId: null, + }) + + expect(result).toBe(messages) + }) + + it('removes stale live assistant duplicates when a terminal persisted assistant exists', () => { + const finalAssistant = assistantMessage('final-1', 'persisted content') + const staleLiveAssistant = assistantMessage('live-assistant:stream-1', 'stale live content') + + const result = reconcileLiveAssistantTurn({ + messages: [ + userMessage('stream-1'), + finalAssistant, + userMessage('next-user'), + staleLiveAssistant, + ], + streamId: 'stream-1', + liveAssistant: staleLiveAssistant, + activeStreamId: null, + }) + + expect(result).toEqual([userMessage('stream-1'), finalAssistant, userMessage('next-user')]) + }) + + it('inserts the live assistant immediately after its owner', () => { + const nextUser = userMessage('next-user') + const liveAssistant = assistantMessage('live-assistant:stream-1', 'live content') + + const result = reconcileLiveAssistantTurn({ + messages: [userMessage('stream-1'), nextUser], + streamId: 'stream-1', + liveAssistant, + activeStreamId: 'stream-1', + }) + + expect(result).toEqual([userMessage('stream-1'), liveAssistant, nextUser]) + }) +}) + +describe('selectReconnectReplayState', () => { + it('hydrates nonzero cursor replay from a cached live assistant that is ahead', () => { + const cachedBlock: ContentBlock = { type: 'text', content: 'Hello world' } + + const result = selectReconnectReplayState({ + afterCursor: '4', + cachedLiveAssistant: { + content: 'Hello world', + contentBlocks: [cachedBlock], + }, + currentContent: 'Hello', + currentBlocks: [], + }) + + expect(result).toEqual({ + afterCursor: '4', + content: 'Hello world', + contentBlocks: [cachedBlock], + preserveExistingState: true, + source: 'cache', + }) + }) + + it('resets to replay from the beginning when a nonzero cursor has no usable live cache', () => { + const result = selectReconnectReplayState({ + afterCursor: '4', + cachedLiveAssistant: null, + currentContent: '', + currentBlocks: [], + }) + + expect(result).toEqual({ + afterCursor: '0', + content: '', + contentBlocks: [], + preserveExistingState: false, + source: 'reset', + }) + }) + + it('resets when cached live content diverges from the local prefix', () => { + const result = selectReconnectReplayState({ + afterCursor: '4', + cachedLiveAssistant: { + content: 'Goodbye world', + contentBlocks: [{ type: 'text', content: 'Goodbye world' }], + }, + currentContent: 'Hello', + currentBlocks: [{ type: 'text', content: 'Hello' }], + }) + + expect(result).toEqual({ + afterCursor: '0', + content: '', + contentBlocks: [], + preserveExistingState: false, + source: 'reset', + }) + }) + + it('resets current state for cursor zero replay', () => { + const currentBlock: ContentBlock = { type: 'text', content: 'Hello' } + + const result = selectReconnectReplayState({ + afterCursor: '0', + cachedLiveAssistant: null, + currentContent: 'Hello', + currentBlocks: [currentBlock], + }) + + expect(result).toEqual({ + afterCursor: '0', + content: '', + contentBlocks: [], + preserveExistingState: false, + source: 'reset', + }) + }) +}) + +describe('getReplayCompletedWorkflowToolCallIds', () => { + it('suppresses only workflow tool starts that already have results in the replay batch', () => { + const result = getReplayCompletedWorkflowToolCallIds([ + toolBatchEvent(1, 'workflow-active', 'run_workflow', MothershipStreamV1ToolPhase.call), + toolBatchEvent(2, 'search-complete', 'tool_search', MothershipStreamV1ToolPhase.result), + toolBatchEvent(3, 'workflow-complete', 'run_workflow', MothershipStreamV1ToolPhase.result), + ]) + + expect(result).toEqual(new Set(['workflow-complete'])) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8e6d5bc49d2..4e7d5138e69 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1140,6 +1140,167 @@ function isAlreadyProcessedStreamCursor( ) } +function isZeroStreamCursor(cursor: string): boolean { + const sequence = Number(cursor) + return Number.isFinite(sequence) && sequence <= 0 +} + +function isPersistedAssistantMessage(message: PersistedMessage, liveAssistantId: string): boolean { + return ( + message.role === 'assistant' && + message.id !== liveAssistantId && + !message.id.startsWith('live-assistant:') + ) +} + +function findStreamOwnerIndex(messages: PersistedMessage[], streamId: string): number { + return messages.findIndex((message) => message.role === 'user' && message.id === streamId) +} + +function findAssistantAfterOwner(messages: PersistedMessage[], ownerIndex: number): number { + for (let index = ownerIndex + 1; index < messages.length; index++) { + const message = messages[index] + if (message.role === 'user') return -1 + if (message.role === 'assistant') return index + } + return -1 +} + +function hasTerminalPersistedAssistantForStream( + messages: PersistedMessage[], + streamId: string, + liveAssistantId: string +): boolean { + const ownerIndex = findStreamOwnerIndex(messages, streamId) + if (ownerIndex === -1) return false + + const assistantIndex = findAssistantAfterOwner(messages, ownerIndex) + if (assistantIndex === -1) return false + + return isPersistedAssistantMessage(messages[assistantIndex], liveAssistantId) +} + +export function reconcileLiveAssistantTurn(params: { + messages: PersistedMessage[] + streamId: string + liveAssistant: PersistedMessage + activeStreamId: string | null +}): PersistedMessage[] { + const { messages, streamId, liveAssistant, activeStreamId } = params + const ownerIndex = findStreamOwnerIndex(messages, streamId) + if (ownerIndex === -1) { + return [...messages.filter((message) => message.id !== liveAssistant.id), liveAssistant] + } + + const assistantIndex = findAssistantAfterOwner(messages, ownerIndex) + const existingAssistant = assistantIndex >= 0 ? messages[assistantIndex] : undefined + if ( + activeStreamId !== streamId && + existingAssistant && + isPersistedAssistantMessage(existingAssistant, liveAssistant.id) + ) { + const withoutStaleLiveAssistant = messages.filter((message) => message.id !== liveAssistant.id) + return withoutStaleLiveAssistant.length === messages.length + ? messages + : withoutStaleLiveAssistant + } + + const withoutDuplicateLiveAssistant = messages.filter( + (message, index) => index === assistantIndex || message.id !== liveAssistant.id + ) + const adjustedOwnerIndex = withoutDuplicateLiveAssistant.findIndex( + (message) => message.role === 'user' && message.id === streamId + ) + const adjustedAssistantIndex = + adjustedOwnerIndex >= 0 + ? findAssistantAfterOwner(withoutDuplicateLiveAssistant, adjustedOwnerIndex) + : -1 + + if (adjustedAssistantIndex >= 0) { + return withoutDuplicateLiveAssistant.map((message, index) => + index === adjustedAssistantIndex ? liveAssistant : message + ) + } + + if (adjustedOwnerIndex >= 0) { + return [ + ...withoutDuplicateLiveAssistant.slice(0, adjustedOwnerIndex + 1), + liveAssistant, + ...withoutDuplicateLiveAssistant.slice(adjustedOwnerIndex + 1), + ] + } + + return [...withoutDuplicateLiveAssistant, liveAssistant] +} + +export interface ReconnectReplaySelection { + afterCursor: string + content: string + contentBlocks: ContentBlock[] + preserveExistingState: boolean + source: 'cache' | 'reset' +} + +export function selectReconnectReplayState(params: { + afterCursor: string + cachedLiveAssistant?: Pick | null + currentContent: string + currentBlocks: ContentBlock[] +}): ReconnectReplaySelection { + const { afterCursor, cachedLiveAssistant, currentContent, currentBlocks } = params + if (isZeroStreamCursor(afterCursor)) { + return { + afterCursor, + content: '', + contentBlocks: [], + preserveExistingState: false, + source: 'reset', + } + } + + const cachedContent = cachedLiveAssistant?.content ?? '' + const cachedBlocks = cachedLiveAssistant?.contentBlocks ?? [] + const cachedHasLiveState = cachedContent.length > 0 || cachedBlocks.length > 0 + const cachedIsAhead = + cachedHasLiveState && + cachedContent.length >= currentContent.length && + cachedContent.startsWith(currentContent) && + cachedBlocks.length >= currentBlocks.length + + if (cachedIsAhead) { + return { + afterCursor, + content: cachedContent, + contentBlocks: [...cachedBlocks], + preserveExistingState: true, + source: 'cache', + } + } + + return { + afterCursor: '0', + content: '', + contentBlocks: [], + preserveExistingState: false, + source: 'reset', + } +} + +export function getReplayCompletedWorkflowToolCallIds(events: StreamBatchEvent[]): Set { + const completedToolCallIds = new Set() + for (const entry of events) { + const event = entry.event + if (event.type !== MothershipStreamV1EventType.tool) continue + const payload = event.payload + if (!('phase' in payload)) continue + if (payload.phase !== MothershipStreamV1ToolPhase.result) continue + if (typeof payload.toolCallId === 'string' && isWorkflowToolName(payload.toolName)) { + completedToolCallIds.add(payload.toolCallId) + } + } + return completedToolCallIds +} + function buildRecoverySubjectKey( chatId: string | undefined, selectedChatId: string | undefined @@ -1556,7 +1717,7 @@ export function useChat( expectedGen?: number, options?: { preserveExistingState?: boolean - suppressWorkflowToolStarts?: boolean + suppressedWorkflowToolStartIds?: ReadonlySet targetChatId?: string shouldContinue?: () => boolean } @@ -1735,6 +1896,45 @@ export function useChat( streamingBlocksRef.current = [] }, []) + const applyReconnectReplaySelection = useCallback( + ( + streamId: string, + assistantId: string, + afterCursor: string, + options?: { targetChatId?: string; chatHistory?: TaskChatHistory } + ): ReconnectReplaySelection => { + const cachedHistory = + options?.chatHistory ?? + (options?.targetChatId + ? queryClient.getQueryData(taskKeys.detail(options.targetChatId)) + : undefined) + const cachedLiveAssistant = cachedHistory?.messages.find( + (message) => message.id === assistantId + ) + const selection = selectReconnectReplayState({ + afterCursor, + cachedLiveAssistant: cachedLiveAssistant ? toDisplayMessage(cachedLiveAssistant) : null, + currentContent: streamingContentRef.current, + currentBlocks: streamingBlocksRef.current, + }) + + streamingContentRef.current = selection.content + streamingBlocksRef.current = selection.contentBlocks + lastCursorRef.current = selection.afterCursor + + if (selection.afterCursor === '0' && afterCursor !== '0') { + logger.info('Resetting stream replay cursor after reconnect state mismatch', { + streamId, + targetChatId: options?.targetChatId ?? cachedHistory?.id, + previousCursor: afterCursor, + }) + } + + return selection + }, + [queryClient] + ) + const clearActiveTurn = useCallback(() => { activeTurnRef.current = null pendingUserMsgRef.current = null @@ -2075,12 +2275,32 @@ export function useChat( const previousStreamId = streamIdRef.current ?? activeTurnRef.current?.userMessageId const reconnectAfterCursor = previousStreamId === activeStreamId ? lastCursorRef.current || '0' : '0' + cancelActiveStreamRecovery() + const replacedController = abortControllerRef.current + if (replacedController && !replacedController.signal.aborted) { + replacedController.abort('superseded_chat_history_reconnect') + } + cancelActiveStreamReader() abortControllerRef.current = abortController streamIdRef.current = activeStreamId - lastCursorRef.current = reconnectAfterCursor setTransportReconnecting() const assistantId = getLiveAssistantMessageId(activeStreamId) + let snapshotReplayAfterCursor: string + if (snapshotEvents.length > 0) { + streamingContentRef.current = '' + streamingBlocksRef.current = [] + lastCursorRef.current = '0' + snapshotReplayAfterCursor = '0' + } else { + const replaySelection = applyReconnectReplaySelection( + activeStreamId, + assistantId, + reconnectAfterCursor, + { targetChatId: chatHistory.id, chatHistory } + ) + snapshotReplayAfterCursor = replaySelection.afterCursor + } const reconnect = async () => { const initialSnapshot = chatHistory.streamSnapshot @@ -2091,7 +2311,8 @@ export function useChat( let reconnectResult: Awaited> | null = null const replaySnapshotEvents = snapshotEvents.filter( - (entry) => !isAlreadyProcessedStreamCursor(String(entry.eventId), reconnectAfterCursor) + (entry) => + !isAlreadyProcessedStreamCursor(String(entry.eventId), snapshotReplayAfterCursor) ) if (replaySnapshotEvents.length > 0) { try { @@ -2105,7 +2326,7 @@ export function useChat( previewSessions: snapshotPreviewSessions, status: initialSnapshot?.status ?? 'unknown', }, - afterCursor: reconnectAfterCursor, + afterCursor: snapshotReplayAfterCursor, targetChatId: chatHistory.id, }) } catch (error) { @@ -2150,9 +2371,12 @@ export function useChat( }, [ chatHistory, workspaceId, + cancelActiveStreamReader, + cancelActiveStreamRecovery, queryClient, recoverPendingClientWorkflowTools, seedPreviewSessions, + applyReconnectReplaySelection, setTransportIdle, setTransportReconnecting, ]) @@ -2164,7 +2388,7 @@ export function useChat( expectedGen?: number, options?: { preserveExistingState?: boolean - suppressWorkflowToolStarts?: boolean + suppressedWorkflowToolStartIds?: ReadonlySet targetChatId?: string shouldContinue?: () => boolean } @@ -2372,14 +2596,27 @@ export function useChat( contentBlocks: blocks, ...(streamRequestId ? { requestId: streamRequestId } : {}), }) - upsertTaskChatHistory(activeChatId, (current) => ({ - ...current, - messages: [ - ...current.messages.filter((message) => message.id !== assistantId), - assistantMessage, - ], - activeStreamId: streamIdRef.current ?? current.activeStreamId, - })) + upsertTaskChatHistory(activeChatId, (current) => { + const streamId = streamIdRef.current ?? current.activeStreamId ?? assistantId + const terminalPersistedAssistantExists = + current.activeStreamId !== streamId && + hasTerminalPersistedAssistantForStream(current.messages, streamId, assistantMessage.id) + const reconciledMessages = reconcileLiveAssistantTurn({ + messages: current.messages, + streamId, + liveAssistant: assistantMessage, + activeStreamId: current.activeStreamId, + }) + const skippedTerminalLiveWrite = reconciledMessages === current.messages + return { + ...current, + messages: reconciledMessages, + activeStreamId: + skippedTerminalLiveWrite || terminalPersistedAssistantExists + ? current.activeStreamId + : (streamIdRef.current ?? current.activeStreamId), + } + }) } const flushText = () => { @@ -2951,7 +3188,7 @@ export function useChat( if (isWorkflowToolName(name) && !isPartial) { const shouldStartWorkflowTool = - !options?.suppressWorkflowToolStarts && + !options?.suppressedWorkflowToolStartIds?.has(id) && (isNewToolCall || (existingToolCall?.status === ToolCallStatus.executing && !existingToolCall.result)) @@ -3392,10 +3629,6 @@ export function useChat( targetChatId, shouldContinue, } = opts - let latestCursor = afterCursor - let seedEvents = opts.initialBatch?.events ?? [] - let streamStatus = opts.initialBatch?.status ?? 'unknown' - let suppressSeedWorkflowStarts = seedEvents.length > 0 const isStaleReconnect = () => streamGenRef.current !== expectedGen || @@ -3406,6 +3639,20 @@ export function useChat( return { error: false, aborted: true } } + const initialReplaySelection: Pick< + ReconnectReplaySelection, + 'afterCursor' | 'preserveExistingState' + > = opts.initialBatch + ? { afterCursor, preserveExistingState: true } + : applyReconnectReplaySelection(streamId, assistantId, afterCursor, { + ...(targetChatId ? { targetChatId } : {}), + }) + let latestCursor = initialReplaySelection.afterCursor + let preserveNextReplayState = initialReplaySelection.preserveExistingState + let seedEvents = opts.initialBatch?.events ?? [] + let streamStatus = opts.initialBatch?.status ?? 'unknown' + let suppressedSeedWorkflowToolStartIds = getReplayCompletedWorkflowToolCallIds(seedEvents) + setTransportReconnecting() setError(null) @@ -3417,8 +3664,8 @@ export function useChat( assistantId, expectedGen, { - preserveExistingState: true, - suppressWorkflowToolStarts: suppressSeedWorkflowStarts, + preserveExistingState: preserveNextReplayState, + suppressedWorkflowToolStartIds: suppressedSeedWorkflowToolStartIds, ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), } @@ -3429,7 +3676,8 @@ export function useChat( latestCursor = String(seedEvents[seedEvents.length - 1]?.eventId ?? latestCursor) lastCursorRef.current = latestCursor seedEvents = [] - suppressSeedWorkflowStarts = false + preserveNextReplayState = true + suppressedSeedWorkflowToolStartIds = new Set() if (replayResult.sawStreamError) { return { error: true, aborted: false } @@ -3475,11 +3723,12 @@ export function useChat( assistantId, expectedGen, { - preserveExistingState: true, + preserveExistingState: preserveNextReplayState, ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), } ) + preserveNextReplayState = true if (liveResult.sawStreamError) { return { error: true, aborted: false } @@ -3509,6 +3758,7 @@ export function useChat( seedStreamBatchPreviewSessions(batch) seedEvents = batch.events streamStatus = batch.status + suppressedSeedWorkflowToolStartIds = getReplayCompletedWorkflowToolCallIds(seedEvents) if (batch.events.length > 0) { latestCursor = String(batch.events[batch.events.length - 1].eventId) @@ -3538,6 +3788,7 @@ export function useChat( } }, [ + applyReconnectReplaySelection, fetchStreamBatch, seedStreamBatchPreviewSessions, setTransportIdle, @@ -3559,7 +3810,12 @@ export function useChat( }): Promise => { const { streamId, assistantId, gen, afterCursor, signal, targetChatId, shouldContinue } = opts - const batch = await fetchStreamBatch(streamId, afterCursor, signal) + if (streamGenRef.current !== gen || signal?.aborted || shouldContinue?.() === false) return + + const replaySelection = applyReconnectReplaySelection(streamId, assistantId, afterCursor, { + ...(targetChatId ? { targetChatId } : {}), + }) + const batch = await fetchStreamBatch(streamId, replaySelection.afterCursor, signal) if (streamGenRef.current !== gen || shouldContinue?.() === false) return seedStreamBatchPreviewSessions(batch) @@ -3570,7 +3826,8 @@ export function useChat( assistantId, gen, { - preserveExistingState: true, + preserveExistingState: replaySelection.preserveExistingState, + suppressedWorkflowToolStartIds: getReplayCompletedWorkflowToolCallIds(batch.events), ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), } @@ -3594,7 +3851,7 @@ export function useChat( afterCursor: batch.events.length > 0 ? String(batch.events[batch.events.length - 1].eventId) - : afterCursor, + : replaySelection.afterCursor, }) if ( @@ -3615,7 +3872,13 @@ export function useChat( setTransportIdle() } }, - [fetchStreamBatch, seedStreamBatchPreviewSessions, attachToExistingStream, setTransportIdle] + [ + applyReconnectReplaySelection, + fetchStreamBatch, + seedStreamBatchPreviewSessions, + attachToExistingStream, + setTransportIdle, + ] ) const retryReconnect = useCallback( @@ -3782,6 +4045,8 @@ export function useChat( } const recoveryGen = observedGeneration + 1 + const previousStreamId = streamIdRef.current ?? activeTurnRef.current?.userMessageId + const afterCursor = previousStreamId === streamId ? lastCursorRef.current || '0' : '0' streamGenRef.current = recoveryGen setTransportReconnecting() streamIdRef.current = streamId @@ -3821,7 +4086,6 @@ export function useChat( if (locallyTerminalStreamIdRef.current === streamId) return const assistantId = getLiveAssistantMessageId(streamId) - const afterCursor = lastCursorRef.current || '0' try { await resumeOrFinalize({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 5fe6c2dbfe1..5d253320ce1 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,3 +1,4 @@ +import { redirect } from 'next/navigation' import { ToastProvider } from '@/components/emcn' import { getSession } from '@/lib/auth' import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour' @@ -13,8 +14,11 @@ import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding' export default async function WorkspaceLayout({ children }: { children: React.ReactNode }) { const session = await getSession() + if (!session?.user) { + redirect('/login') + } // The organization plugin is conditionally spread so TS can't infer activeOrganizationId on the base session type. - const orgId = (session?.session as { activeOrganizationId?: string } | null)?.activeOrganizationId + const orgId = (session.session as { activeOrganizationId?: string } | null)?.activeOrganizationId const initialOrgSettings = orgId ? await getOrgWhitelabelSettings(orgId) : null return ( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx index 23670cf2c02..9a2e09ce471 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx @@ -5,31 +5,20 @@ import { createLogger } from '@sim/logger' import { ArrowDown } from 'lucide-react' import { useRouter } from 'next/navigation' import { Button, Loader } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils' +import type { UserFile } from '@/executor/types' const logger = createLogger('FileCards') -interface FileData { - id?: string - name: string - size: number - type: string - key: string - url: string - uploadedAt: string - expiresAt: string - storageProvider?: 's3' | 'blob' | 'local' - bucketName?: string -} - interface FileCardsProps { - files: FileData[] + files: UserFile[] isExecutionFile?: boolean workspaceId?: string } interface FileCardProps { - file: FileData + file: UserFile isExecutionFile?: boolean workspaceId?: string } @@ -157,7 +146,7 @@ export function FileDownload({ className, workspaceId, }: { - file: FileData + file: UserFile isExecutionFile?: boolean className?: string workspaceId?: string @@ -220,7 +209,7 @@ export function FileDownload({ return ( + + +
    +
    + Column name + { + setNameInput(e.target.value) + if (nameError) setNameError(null) + }} + spellCheck={false} + autoComplete='off' + aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} + /> + {showValidation && !trimmedName && } + {nameError && !(showValidation && !trimmedName) && } +
    + + {config.mode === 'edit' && ( + <> + +
    + Type + ({ + label: o.label, + value: o.type, + icon: o.icon, + }))} + value={typeInput} + onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} + placeholder='Select type' + maxHeight={260} + /> +
    + + )} + + +
    +
    + + setUniqueInput(!!v)} + /> +
    +
    +
    + +
    + + +
    + + ) +} + +function RequiredLabel({ htmlFor, children }: { htmlFor?: string; children: React.ReactNode }) { + return ( + + ) +} + +function FieldError({ message }: { message: string }) { + return

    {message}

    +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts similarity index 66% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index 10e392e82a1..6c9f31ade67 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -10,9 +10,9 @@ import { import type { ColumnDefinition } from '@/lib/table' /** - * UI-only column type. `'workflow'` is a virtual selection that lets the user - * configure a workflow group from the sidebar; on save, it expands into N real - * scalar columns + one workflow group, none of which carry a `'workflow'` type. + * UI-only column type. `'workflow'` is the virtual entry users pick from the + * "+ New column" dropdown to spawn a workflow group; the resulting columns are + * stored as scalar types under the hood (none carry `'workflow'`). */ export type SidebarColumnType = ColumnDefinition['type'] | 'workflow' @@ -30,3 +30,6 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ { type: 'json', label: 'JSON', icon: TypeJson }, { type: 'workflow', label: 'Workflow', icon: PlayOutline }, ] + +/** Plain column types (no workflow). Used by ``'s type combobox in edit mode. */ +export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts new file mode 100644 index 00000000000..e458001136d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts @@ -0,0 +1,8 @@ +export type { ColumnConfig } from './column-config-sidebar' +export { ColumnConfigSidebar } from './column-config-sidebar' +export { + COLUMN_TYPE_OPTIONS, + type ColumnTypeOption, + PLAIN_COLUMN_TYPE_OPTIONS, + type SidebarColumnType, +} from './column-types' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx deleted file mode 100644 index 73017fc25ec..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx +++ /dev/null @@ -1,1314 +0,0 @@ -'use client' - -import type React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { - ChevronDown, - ChevronRight, - ExternalLink, - Loader2, - Plus, - RepeatIcon, - SplitIcon, - X, -} from 'lucide-react' -import { - Button, - Checkbox, - Combobox, - Expandable, - ExpandableContent, - Input, - Label, - Switch, - Tooltip, - toast, -} from '@/components/emcn' -import { requestJson } from '@/lib/api/client/request' -import type { - AddWorkflowGroupBodyInput, - UpdateWorkflowGroupBodyInput, -} from '@/lib/api/contracts/tables' -import { - putWorkflowNormalizedStateContract, - type WorkflowStateContractInput, -} from '@/lib/api/contracts/workflows' -import { cn } from '@/lib/core/utils/cn' -import type { - ColumnDefinition, - WorkflowGroup, - WorkflowGroupDependencies, - WorkflowGroupOutput, -} from '@/lib/table' -import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { - type FlattenOutputsBlockInput, - type FlattenOutputsEdgeInput, - flattenWorkflowOutputs, - getBlockExecutionOrder, -} from '@/lib/workflows/blocks/flatten-outputs' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format' -import { TriggerUtils } from '@/lib/workflows/triggers/triggers' -import type { InputFormatField } from '@/lib/workflows/types' -import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' -import { getBlock } from '@/blocks' -import { - useAddTableColumn, - useAddWorkflowGroup, - useUpdateColumn, - useUpdateWorkflowGroup, -} from '@/hooks/queries/tables' -import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { COLUMN_SIDEBAR_WIDTH_CSS } from '../table/constants' -import { COLUMN_TYPE_OPTIONS, type SidebarColumnType } from './column-types' - -export type ColumnConfigState = - | { mode: 'edit'; columnName: string } - | { mode: 'new'; columnName: string; workflowId: string; proposedName: string } - | { - mode: 'create' - columnName: string - proposedName: string - /** When present, the sidebar opens with the workflow type pre-selected. */ - workflowId?: string - } - | null - -interface ColumnSidebarProps { - configState: ColumnConfigState - onClose: () => void - /** The current column record for edit mode. Null for new mode or closed. */ - existingColumn: ColumnDefinition | null - allColumns: ColumnDefinition[] - workflowGroups: WorkflowGroup[] - workflows: WorkflowMetadata[] | undefined - workspaceId: string - tableId: string -} - -const OUTPUT_VALUE_SEPARATOR = '::' - -/** Shared dashed-divider style — mirrors the workflow editor's subblock divider. */ -const DASHED_DIVIDER_STYLE = { - backgroundImage: - 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)', -} as const - -/** Encodes blockId + path so duplicate field names across blocks stay distinct in the picker UI. */ -const encodeOutputValue = (blockId: string, path: string) => - `${blockId}${OUTPUT_VALUE_SEPARATOR}${path}` - -/** Splits an encoded `${blockId}::${path}` into its components for persistence. */ -const decodeOutputValue = (value: string): { blockId: string; path: string } => { - const idx = value.indexOf(OUTPUT_VALUE_SEPARATOR) - if (idx === -1) return { blockId: '', path: value } - return { blockId: value.slice(0, idx), path: value.slice(idx + OUTPUT_VALUE_SEPARATOR.length) } -} - -interface BlockOutputGroup { - blockId: string - blockName: string - blockType: string - blockIcon: string | React.ComponentType<{ className?: string }> - blockColor: string - paths: string[] -} - -/** - * Loose shape of `useWorkflowState` data — we only need the fields we round-trip - * through PUT /state. Typed locally to avoid pulling the heavy `WorkflowState` - * generic from `@/stores/workflows/workflow/types`. - */ -interface WorkflowStatePayload { - blocks: Record< - string, - { - type: string - subBlocks?: Record - } & Record - > - edges: unknown[] - loops: unknown - parallels: unknown - lastSaved?: number - isDeployed?: boolean -} - -function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefined): string { - switch (colType) { - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'json': - return 'object' - default: - return 'string' - } -} - -const TagIcon: React.FC<{ - icon: string | React.ComponentType<{ className?: string }> - color: string -}> = ({ icon, color }) => ( -
    - {typeof icon === 'string' ? ( - {icon} - ) : ( - (() => { - const IconComponent = icon - return - })() - )} -
    -) - -function FieldDivider() { - return ( -
    -
    -
    - ) -} - -/** Mirrors the workflow editor's required-field label: title + asterisk. */ -function FieldLabel({ - htmlFor, - required, - children, -}: { - htmlFor?: string - required?: boolean - children: React.ReactNode -}) { - return ( - - ) -} - -/** Inline validation message styled like the workflow editor's destructive text. */ -function FieldError({ message }: { message: string }) { - return

    {message}

    -} - -/** - * Tinted inline warning row with a message on the left and an action button - * on the right. Stacks naturally — render multiple in sequence and they line - * up. Color mirrors the group-header deploy badge: `red` for blocking states, - * `amber` for soft warnings. - */ -function WarningRow({ - tone, - message, - action, -}: { - tone: 'red' | 'amber' - message: string - action: React.ReactNode -}) { - return ( -
    - - {message} - -
    {action}
    -
    - ) -} - -/** - * Collapsible "Run settings" section. Collapsed by default since outputs are - * the primary focus of the workflow flow — most users never need to touch - * the trigger conditions. The header shows a one-line summary of when the - * group will fire so the current state is visible without expanding. - */ -function RunSettingsSection({ - open, - onOpenChange, - summary, - scalarDepColumns, - groupDepOptions, - deps, - groupDeps, - workflows, - onToggleDep, - onToggleGroupDep, -}: { - open: boolean - onOpenChange: (open: boolean) => void - summary: string - scalarDepColumns: ColumnDefinition[] - groupDepOptions: WorkflowGroup[] - deps: string[] - groupDeps: string[] - workflows: WorkflowMetadata[] | undefined - onToggleDep: (name: string) => void - onToggleGroupDep: (groupId: string) => void -}) { - return ( -
    - - - -
    - {scalarDepColumns.length === 0 && groupDepOptions.length === 0 ? ( -
    - No upstream columns or groups. -
    - ) : ( - <> - {scalarDepColumns.map((c, idx) => { - const checked = deps.includes(c.name) - const isLast = idx === scalarDepColumns.length - 1 && groupDepOptions.length === 0 - return ( -
    onToggleDep(c.name)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault() - onToggleDep(c.name) - } - }} - className={cn( - 'flex h-[36px] flex-shrink-0 cursor-pointer items-center gap-2.5 px-2.5 hover:bg-[var(--surface-2)]', - !isLast && 'border-[var(--border)] border-b' - )} - > - - - {c.name} - - - {c.type} - -
    - ) - })} - {groupDepOptions.map((g, idx) => { - const checked = groupDeps.includes(g.id) - const isLast = idx === groupDepOptions.length - 1 - const wf = workflows?.find((w) => w.id === g.workflowId) - const color = wf?.color ?? 'var(--text-muted)' - const label = g.name ?? wf?.name ?? 'Workflow' - return ( -
    onToggleGroupDep(g.id)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault() - onToggleGroupDep(g.id) - } - }} - className={cn( - 'flex h-[36px] flex-shrink-0 cursor-pointer items-center gap-2.5 px-2.5 hover:bg-[var(--surface-2)]', - !isLast && 'border-[var(--border)] border-b' - )} - > - -
    - ) - })} - - )} -
    -
    -
    -
    - ) -} - -/** - * Right-edge configuration panel for any column. - * - * Shows name / type / unique for every column, plus workflow-specific fields - * (workflow picker, output field, dependencies, run concurrency) when the - * selected type is `'workflow'`. - * - * Three modes: - * - 'edit': modify an existing column. PATCH sends a unified updates payload. - * - 'new': user picked a workflow via Change type → Workflow → [pick]. Nothing - * is persisted yet. Save writes type + workflowConfig + renames in one PATCH. - * - 'create': user picked a workflow from "Add column"; the column doesn't exist yet - * and Save creates it. - * - * Visual styling mirrors the workflow editor's subblock panel (label above - * control, dashed dividers between fields). - */ -export function ColumnSidebar({ - configState, - onClose, - existingColumn, - allColumns, - workflowGroups, - workflows, - workspaceId, - tableId, -}: ColumnSidebarProps) { - const updateColumn = useUpdateColumn({ workspaceId, tableId }) - const addColumn = useAddTableColumn({ workspaceId, tableId }) - const addWorkflowGroup = useAddWorkflowGroup({ workspaceId, tableId }) - const updateWorkflowGroup = useUpdateWorkflowGroup({ workspaceId, tableId }) - const open = configState !== null - - const columnName = configState ? configState.columnName : '' - - /** - * If the column being edited is a workflow output, resolve its parent group - * so we can populate workflow / outputs / dependencies state from it. - */ - const existingGroup = useMemo(() => { - if (!existingColumn?.workflowGroupId) return undefined - return workflowGroups.find((g) => g.id === existingColumn.workflowGroupId) - }, [existingColumn, workflowGroups]) - - const [nameInput, setNameInput] = useState('') - const [typeInput, setTypeInput] = useState('string') - - const isWorkflow = !!existingGroup || configState?.mode === 'new' || typeInput === 'workflow' - - /** - * Show the Column name field whenever a *specific* column is open: scalar - * columns (create or edit) and per-output workflow columns (edit only). Hide - * it when the surface is the workflow-group as a whole — i.e. creating a - * brand-new workflow column where individual output names are auto-derived. - */ - const showColumnNameField = - !isWorkflow || configState?.mode === 'edit' || configState?.mode === 'new' - - /** - * Columns to the left of the current column — these are the only valid trigger - * dependencies, since a workflow column can't depend on values that haven't been - * filled yet. For 'create' mode the column doesn't exist yet, so every existing - * column counts as left of it. - */ - const otherColumns = useMemo(() => { - if (!configState) return [] - if (configState.mode === 'create') return allColumns - const idx = allColumns.findIndex((c) => c.name === configState.columnName) - if (idx === -1) return allColumns.filter((c) => c.name !== configState.columnName) - return allColumns.slice(0, idx) - }, [configState, allColumns]) - - /** - * Split `otherColumns` into the two dep buckets: - * - `scalarDepColumns` — plain columns; tickable into `dependencies.columns`. - * - `groupDepOptions` — producing workflow groups whose outputs land left of the - * current column; tickable into `dependencies.workflowGroups`. A group only - * shows up here when at least one of its output columns is left-of-current. - * The current group itself is excluded so we never depend on ourselves. - */ - const scalarDepColumns = useMemo( - () => otherColumns.filter((c) => !c.workflowGroupId), - [otherColumns] - ) - const groupDepOptions = useMemo(() => { - const seen = new Set() - const result: WorkflowGroup[] = [] - for (const c of otherColumns) { - if (!c.workflowGroupId) continue - if (seen.has(c.workflowGroupId)) continue - if (existingGroup && c.workflowGroupId === existingGroup.id) continue - const g = workflowGroups.find((gg) => gg.id === c.workflowGroupId) - if (!g) continue - seen.add(c.workflowGroupId) - result.push(g) - } - return result - }, [otherColumns, workflowGroups, existingGroup]) - - const [uniqueInput, setUniqueInput] = useState(false) - const [selectedWorkflowId, setSelectedWorkflowId] = useState('') - /** Plain (non-workflow-output) column names this group waits on. */ - const [deps, setDeps] = useState([]) - /** Producing workflow group ids this group waits on. Workflow-output columns are - * represented by their parent group, since the schema validator forbids depending - * on a workflow-output column directly (`workflow-columns.ts` enforces this). */ - const [groupDeps, setGroupDeps] = useState([]) - /** Encoded `${blockId}::${path}` values — disambiguates duplicate paths in the picker. */ - const [selectedOutputs, setSelectedOutputs] = useState([]) - /** Surfaces required-field errors only after a save attempt, matching the workflow editor's deploy flow. */ - const [showValidation, setShowValidation] = useState(false) - /** Save-time error (network/validation thrown by the mutation). Rendered inline next to the footer - * buttons so it isn't covered by the toaster, which sits over the bottom-right of the panel. */ - const [saveError, setSaveError] = useState(null) - /** Run settings (the trigger-deps picker) starts collapsed — outputs are the - * primary task; configuring run timing is rare. */ - const [runSettingsOpen, setRunSettingsOpen] = useState(false) - - const existingColumnRef = useRef(existingColumn) - existingColumnRef.current = existingColumn - const allColumnsRef = useRef(allColumns) - allColumnsRef.current = allColumns - - useEffect(() => { - if (!open || !configState) return - setShowValidation(false) - setSaveError(null) - setRunSettingsOpen(false) - const existing = existingColumnRef.current - const cols = allColumnsRef.current - const leftOfCurrent = (() => { - if (configState.mode === 'create') return cols - const idx = cols.findIndex((c) => c.name === configState.columnName) - if (idx === -1) return cols.filter((c) => c.name !== configState.columnName) - return cols.slice(0, idx) - })() - // Default deps when there's no persisted group yet: tick every left-of-current - // scalar column + every left-of-current producing group. - const defaultScalarDeps = leftOfCurrent.filter((c) => !c.workflowGroupId).map((c) => c.name) - const defaultGroupDeps = (() => { - const seen = new Set() - for (const c of leftOfCurrent) { - if (c.workflowGroupId) seen.add(c.workflowGroupId) - } - return Array.from(seen) - })() - if (configState.mode === 'edit') { - const group = existing?.workflowGroupId - ? workflowGroups.find((g) => g.id === existing.workflowGroupId) - : undefined - // Surface workflow-typed columns as `'workflow'` in the combobox even - // though they're stored as scalar columns under the hood. - setTypeInput(group ? 'workflow' : (existing?.type ?? 'string')) - setUniqueInput(!!existing?.unique) - setNameInput(existing?.name ?? configState.columnName) - if (group) { - setSelectedWorkflowId(group.workflowId) - // Sanitize legacy persisted deps: any workflow-output column names that - // sneaked into `dependencies.columns` (writes from before the schema - // validator forbade them) are lifted into `workflowGroups` here so the - // sidebar surfaces a re-saveable state. - const persistedCols = group.dependencies?.columns - const persistedGroups = group.dependencies?.workflowGroups - if (persistedCols !== undefined || persistedGroups !== undefined) { - const liftedGroupIds = new Set(persistedGroups ?? []) - const cleanCols: string[] = [] - for (const colName of persistedCols ?? []) { - const c = cols.find((cc) => cc.name === colName) - if (c?.workflowGroupId) liftedGroupIds.add(c.workflowGroupId) - else cleanCols.push(colName) - } - setDeps(cleanCols) - setGroupDeps(Array.from(liftedGroupIds)) - } else { - setDeps(defaultScalarDeps) - setGroupDeps(defaultGroupDeps) - } - setSelectedOutputs([]) // re-encoded against current workflow blocks below - } else { - setSelectedWorkflowId('') - setDeps([]) - setGroupDeps([]) - setSelectedOutputs([]) - } - } else { - const workflowId = - 'workflowId' in configState && configState.workflowId ? configState.workflowId : '' - setTypeInput(workflowId ? 'workflow' : 'string') - setUniqueInput(false) - setNameInput(configState.proposedName) - setSelectedWorkflowId(workflowId) - setDeps(defaultScalarDeps) - setGroupDeps(defaultGroupDeps) - setSelectedOutputs([]) - } - }, [open, configState, workflowGroups]) - - const workflowState = useWorkflowState( - open && isWorkflow && selectedWorkflowId ? selectedWorkflowId : undefined - ) - - /** - * Resolves the unified Start block id and its current `inputFormat` field - * names. The "Add inputs" mutation only adds rows for table columns that - * aren't already represented in the start block — clicking the button when - * everything's covered does nothing, so we hide it in that case. - */ - const startBlockInputs = useMemo<{ - blockId: string | null - existingNames: Set - existing: InputFormatField[] - }>(() => { - const blocks = (workflowState.data as { blocks?: Record } | null) - ?.blocks - if (!blocks) return { blockId: null, existingNames: new Set(), existing: [] } - const candidate = TriggerUtils.findStartBlock(blocks, 'manual') - if (!candidate) return { blockId: null, existingNames: new Set(), existing: [] } - const block = blocks[candidate.blockId] as - | { subBlocks?: Record } - | undefined - const existing = normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value) - return { - blockId: candidate.blockId, - existingNames: new Set(existing.map((f) => f.name).filter((n): n is string => !!n)), - existing, - } - }, [workflowState.data]) - - const missingInputColumnNames = useMemo(() => { - if (!startBlockInputs.blockId) return [] - return allColumns - .filter( - (c) => - c.name !== columnName && !c.workflowGroupId && !startBlockInputs.existingNames.has(c.name) - ) - .map((c) => c.name) - }, [allColumns, columnName, startBlockInputs]) - - const queryClient = useQueryClient() - const addInputsMutation = useMutation({ - mutationFn: async () => { - const wfId = selectedWorkflowId - const startBlockId = startBlockInputs.blockId - const state = workflowState.data as WorkflowStatePayload | null | undefined - if (!wfId || !startBlockId || !state || missingInputColumnNames.length === 0) { - throw new Error('Nothing to add') - } - const startBlock = state.blocks[startBlockId] - if (!startBlock) throw new Error('Start block missing from workflow') - - const newFields: InputFormatField[] = missingInputColumnNames.map((name) => { - const col = allColumns.find((c) => c.name === name) - return { - id: generateId(), - name, - type: tableColumnTypeToInputType(col?.type), - value: '', - collapsed: false, - } as InputFormatField & { id: string; collapsed: boolean } - }) - - const updatedSubBlock = { - ...(startBlock.subBlocks?.inputFormat ?? { id: 'inputFormat', type: 'input-format' }), - value: [...startBlockInputs.existing, ...newFields], - } - const updatedBlocks = { - ...state.blocks, - [startBlockId]: { - ...startBlock, - subBlocks: { ...startBlock.subBlocks, inputFormat: updatedSubBlock }, - }, - } - - const rawBody = { - blocks: updatedBlocks, - edges: state.edges, - loops: state.loops, - parallels: state.parallels, - lastSaved: state.lastSaved ?? Date.now(), - isDeployed: state.isDeployed ?? false, - } - // double-cast-allowed: WorkflowStatePayload is the loose local view of - // useWorkflowState; we round-trip it back to the strict PUT body shape. - const body = rawBody as unknown as WorkflowStateContractInput - await requestJson(putWorkflowNormalizedStateContract, { - params: { id: wfId }, - body, - }) - return missingInputColumnNames.length - }, - onSuccess: (added) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) }) - toast.success(`Added ${added} input${added === 1 ? '' : 's'} to start block`) - }, - onError: (err) => { - toast.error(toError(err).message) - }, - }) - - const blockOutputGroups = useMemo(() => { - const state = workflowState.data as - | { - blocks?: Record - edges?: FlattenOutputsEdgeInput[] - } - | null - | undefined - if (!state?.blocks) return [] - - const blocks = Object.values(state.blocks) - const edges = state.edges ?? [] - const flat = flattenWorkflowOutputs(blocks, edges) - if (flat.length === 0) return [] - - const groupsByBlockId = new Map() - for (const f of flat) { - let group = groupsByBlockId.get(f.blockId) - if (!group) { - const blockConfig = getBlock(f.blockType) - const blockColor = blockConfig?.bgColor || '#2F55FF' - let blockIcon: string | React.ComponentType<{ className?: string }> = f.blockName - .charAt(0) - .toUpperCase() - if (blockConfig?.icon) blockIcon = blockConfig.icon - else if (f.blockType === 'loop') blockIcon = RepeatIcon - else if (f.blockType === 'parallel') blockIcon = SplitIcon - group = { - blockId: f.blockId, - blockName: f.blockName, - blockType: f.blockType, - blockIcon, - blockColor, - paths: [], - } - groupsByBlockId.set(f.blockId, group) - } - group.paths.push(f.path) - } - // Sort the picker by execution order (start block first) so it matches the - // saved-column ordering. Unreachable blocks sink to the end. - const distances = getBlockExecutionOrder(blocks, edges) - return Array.from(groupsByBlockId.values()).sort((a, b) => { - const da = distances[a.blockId] - const db = distances[b.blockId] - const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da - const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db - return sa - sb - }) - }, [workflowState.data]) - - /** - * Re-encode persisted `{blockId, path}` entries into the picker's encoded form - * once the workflow's blocks are loaded. Stale entries (block deleted or path - * removed) are dropped silently — the user can re-pick on save. - */ - useEffect(() => { - if (!existingGroup?.outputs.length) return - if (selectedOutputs.length > 0) return - if (blockOutputGroups.length === 0) return - const encoded: string[] = [] - for (const entry of existingGroup.outputs) { - const match = blockOutputGroups.find( - (g) => g.blockId === entry.blockId && g.paths.includes(entry.path) - ) - if (match) encoded.push(encodeOutputValue(entry.blockId, entry.path)) - } - if (encoded.length > 0) setSelectedOutputs(encoded) - }, [blockOutputGroups, selectedOutputs.length, existingGroup]) - - const toggleDep = (name: string) => { - setDeps((prev) => (prev.includes(name) ? prev.filter((d) => d !== name) : [...prev, name])) - } - - const toggleGroupDep = (groupId: string) => { - setGroupDeps((prev) => - prev.includes(groupId) ? prev.filter((d) => d !== groupId) : [...prev, groupId] - ) - } - - const toggleOutput = (encoded: string) => { - setSelectedOutputs((prev) => - prev.includes(encoded) ? prev.filter((v) => v !== encoded) : [...prev, encoded] - ) - } - - const typeOptions = useMemo( - () => - COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow' || !!existingGroup).map((o) => ({ - label: o.label, - value: o.type, - icon: o.icon, - })), - [existingGroup] - ) - - /** - * One-line summary of the trigger picker shown when Run settings is collapsed. - * Lists the dep names ("Run when X, Y, are filled") so the user can see at a - * glance whether anything's gating the group without expanding the section. - */ - const runSettingsSummary = useMemo(() => { - const names: string[] = [...deps] - for (const gid of groupDeps) { - const g = workflowGroups.find((gg) => gg.id === gid) - const wf = workflows?.find((w) => w.id === g?.workflowId) - const label = g?.name ?? wf?.name ?? 'workflow' - names.push(label) - } - if (names.length === 0) return 'Runs as soon as the group is added' - return `Runs when ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} filled` - }, [deps, groupDeps, workflowGroups, workflows]) - - /** - * Builds the ordered, deduplicated `(blockId, path)` list from the picker - * state, sorted by execution order. Empty array if the user hasn't picked - * anything. - */ - const buildOrderedPickedOutputs = (): Array<{ - blockId: string - path: string - leafType?: string - }> => { - const seen = new Set() - const outputs: Array<{ blockId: string; path: string; leafType?: string }> = [] - for (const encoded of selectedOutputs) { - if (seen.has(encoded)) continue - seen.add(encoded) - outputs.push(decodeOutputValue(encoded)) - } - const wfState = workflowState.data as - | { - blocks?: Record - edges?: FlattenOutputsEdgeInput[] - } - | null - | undefined - if (wfState?.blocks) { - const blocks = Object.values(wfState.blocks) - const edges = wfState.edges ?? [] - const distances = getBlockExecutionOrder(blocks, edges) - const flat = flattenWorkflowOutputs(blocks, edges) - const indexInFlat = new Map( - flat.map((f, i) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, i]) - ) - const leafTypeByKey = new Map( - flat.map((f) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, f.leafType]) - ) - for (const o of outputs) { - o.leafType = leafTypeByKey.get(`${o.blockId}${OUTPUT_VALUE_SEPARATOR}${o.path}`) - } - outputs.sort((a, b) => { - const da = distances[a.blockId] - const db = distances[b.blockId] - const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da - const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db - if (sa !== sb) return sa - sb - const ia = - indexInFlat.get(`${a.blockId}${OUTPUT_VALUE_SEPARATOR}${a.path}`) ?? - Number.POSITIVE_INFINITY - const ib = - indexInFlat.get(`${b.blockId}${OUTPUT_VALUE_SEPARATOR}${b.path}`) ?? - Number.POSITIVE_INFINITY - return ia - ib - }) - } - return outputs - } - - const handleSave = async () => { - if (!configState) return - setSaveError(null) - const trimmedName = nameInput.trim() - // Name is required iff the field is shown — when configuring a whole - // workflow group at creation time, per-output column names are auto-derived - // and the field is hidden, so don't gate save on it. - const missing: string[] = [] - if (showColumnNameField && !trimmedName) missing.push('a column name') - if (isWorkflow && !selectedWorkflowId) missing.push('a workflow') - if (isWorkflow && selectedWorkflowId && selectedOutputs.length === 0) { - missing.push('at least one output column') - } - if (missing.length > 0) { - setShowValidation(true) - // Surface a short summary near the Save button too — the inline FieldError - // can be scrolled out of view when the panel content is tall. - setSaveError(`Add ${missing.join(' and ')} before saving.`) - return - } - - try { - if (isWorkflow) { - const orderedOutputs = buildOrderedPickedOutputs() - const dependencies: WorkflowGroupDependencies = { - columns: deps, - ...(groupDeps.length > 0 ? { workflowGroups: groupDeps } : {}), - } - - if (existingGroup) { - // Update path: diff outputs, derive new column names for added entries, - // call updateWorkflowGroup so service handles add/remove transactionally. - // If the sidebar was opened on a *specific* workflow-output column and - // the user renamed it, propagate that into the group's `outputs` ref - // (the column rename itself goes through `updateColumn` below, which - // server-side cascades into outputs/deps — but our outgoing payload - // also has to use the new name so the group update doesn't undo it). - const editedColumnName = configState.mode === 'edit' ? configState.columnName : null - const renamedColumn = - editedColumnName && trimmedName && trimmedName !== editedColumnName - ? { from: editedColumnName, to: trimmedName } - : null - const oldKeys = new Set(existingGroup.outputs.map((o) => `${o.blockId}::${o.path}`)) - const taken = new Set( - allColumns.map((c) => - renamedColumn && c.name === renamedColumn.from ? renamedColumn.to : c.name - ) - ) - const fullOutputs: WorkflowGroupOutput[] = [] - const newOutputColumns: NonNullable = [] - for (const o of orderedOutputs) { - const key = `${o.blockId}::${o.path}` - const existing = existingGroup.outputs.find( - (e) => e.blockId === o.blockId && e.path === o.path - ) - if (existing) { - fullOutputs.push( - renamedColumn && existing.columnName === renamedColumn.from - ? { ...existing, columnName: renamedColumn.to } - : existing - ) - } else { - const colName = deriveOutputColumnName(o.path, taken) - taken.add(colName) - fullOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) - newOutputColumns.push({ - name: colName, - type: columnTypeForLeaf(o.leafType), - required: false, - unique: false, - workflowGroupId: existingGroup.id, - }) - } - oldKeys.delete(key) - } - if (renamedColumn) { - await updateColumn.mutateAsync({ - columnName: renamedColumn.from, - updates: { name: renamedColumn.to }, - }) - } - await updateWorkflowGroup.mutateAsync({ - groupId: existingGroup.id, - workflowId: selectedWorkflowId, - name: existingGroup.name, - dependencies, - outputs: fullOutputs, - ...(newOutputColumns.length > 0 ? { newOutputColumns } : {}), - }) - toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`) - } else { - // Create path: build a fresh group with auto-derived column names. - const groupId = generateId() - const taken = new Set(allColumns.map((c) => c.name)) - const newOutputColumns: AddWorkflowGroupBodyInput['outputColumns'] = [] - const groupOutputs: WorkflowGroupOutput[] = [] - for (const o of orderedOutputs) { - const colName = deriveOutputColumnName(o.path, taken) - taken.add(colName) - newOutputColumns.push({ - name: colName, - type: columnTypeForLeaf(o.leafType), - required: false, - unique: false, - workflowGroupId: groupId, - }) - groupOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) - } - const workflowName = - workflows?.find((w) => w.id === selectedWorkflowId)?.name ?? 'Workflow' - const group: WorkflowGroup = { - id: groupId, - workflowId: selectedWorkflowId, - name: workflowName, - dependencies, - outputs: groupOutputs, - } - await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns }) - toast.success(`Added "${workflowName}"`) - } - } else if (configState.mode === 'create') { - // `isWorkflow` is false here, so `typeInput` is a real ColumnDefinition type. - const scalarType = typeInput as ColumnDefinition['type'] - await addColumn.mutateAsync({ - name: trimmedName, - type: scalarType, - }) - toast.success(`Added "${trimmedName}"`) - } else { - const existing = existingColumnRef.current - const scalarType = typeInput as ColumnDefinition['type'] - const renamed = trimmedName !== configState.columnName - const typeChanged = !!existing && existing.type !== scalarType - const uniqueChanged = !!existing && !!existing.unique !== uniqueInput - - const updates: { - name?: string - type?: ColumnDefinition['type'] - unique?: boolean - } = { - ...(renamed ? { name: trimmedName } : {}), - ...(typeChanged ? { type: scalarType } : {}), - ...(uniqueChanged ? { unique: uniqueInput } : {}), - } - - if (Object.keys(updates).length === 0) { - onClose() - return - } - - await updateColumn.mutateAsync({ - columnName: configState.columnName, - updates, - }) - toast.success(`Saved "${trimmedName}"`) - } - - onClose() - } catch (err) { - setSaveError(toError(err).message) - } - } - - const saveDisabled = updateColumn.isPending || addColumn.isPending - - return ( - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index dfe0523ba8d..f7c6f4a27a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -5,7 +5,17 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowDown, ArrowUp, Duplicate, Eye, Pencil, Trash } from '@/components/emcn/icons' +import { + ArrowDown, + ArrowUp, + Duplicate, + Eye, + Pencil, + PlayOutline, + RefreshCw, + Square, + Trash, +} from '@/components/emcn/icons' import type { ContextMenuState } from '../../types' interface ContextMenuProps { @@ -20,6 +30,18 @@ interface ContextMenuProps { canViewExecution?: boolean canEditCell?: boolean selectedRowCount?: number + /** Fires every workflow group on the row(s), skipping already-completed + * cells. Mirrors the action bar's Play. */ + onRunWorkflows?: () => void + /** Re-runs every workflow group on the row(s), including already-completed + * cells. Mirrors the action bar's Refresh. */ + onRefreshWorkflows?: () => void + /** Cancels every running/queued execution on the row(s) the context menu is acting on. */ + onStopWorkflows?: () => void + /** Total running/queued executions across the row(s) under the context menu. Drives the Stop label and visibility. */ + runningInSelectionCount?: number + /** Whether the table has any workflow columns; gates the run-workflows item. */ + hasWorkflowColumns?: boolean disableEdit?: boolean disableInsert?: boolean disableDelete?: boolean @@ -37,11 +59,26 @@ export function ContextMenu({ canViewExecution = false, canEditCell = true, selectedRowCount = 1, + onRunWorkflows, + onRefreshWorkflows, + onStopWorkflows, + runningInSelectionCount = 0, + hasWorkflowColumns = false, disableEdit = false, disableInsert = false, disableDelete = false, }: ContextMenuProps) { const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row' + const runLabel = + selectedRowCount > 1 + ? `Run empty or failed cells on ${selectedRowCount} rows` + : 'Run empty or failed cells' + const refreshLabel = + selectedRowCount > 1 ? `Re-run all cells on ${selectedRowCount} rows` : 'Re-run all cells' + const stopLabel = + runningInSelectionCount === 1 + ? 'Stop running workflow' + : `Stop ${runningInSelectionCount} running workflows` return ( )} + {hasWorkflowColumns && onRunWorkflows && ( + + + {runLabel} + + )} + {hasWorkflowColumns && onRefreshWorkflows && ( + + + {refreshLabel} + + )} + {hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && ( + + + {stopLabel} + + )} Insert row above diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts index bc0da8a0717..0fca186c0c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -1,4 +1,9 @@ +export * from './column-config-sidebar' export * from './context-menu' +export * from './new-column-dropdown' export * from './row-modal' -export * from './table' +export * from './run-status-control' +export * from './table-action-bar' export * from './table-filter' +export * from './table-grid' +export * from './workflow-sidebar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts new file mode 100644 index 00000000000..026d9ff58f1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts @@ -0,0 +1 @@ +export { NewColumnDropdown } from './new-column-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx new file mode 100644 index 00000000000..773e4ba0cf4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -0,0 +1,84 @@ +'use client' + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/emcn' +import { Plus } from '@/components/emcn/icons' +import { isWorkflowColumnsEnabledClient } from '@/lib/core/config/feature-flags' +import type { ColumnDefinition } from '@/lib/table' +import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar' + +const VISIBLE_COLUMN_TYPE_OPTIONS = isWorkflowColumnsEnabledClient + ? COLUMN_TYPE_OPTIONS + : COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') + +const CELL_HEADER = + 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' + +const HEADER_ADD_COLUMN_ICON = + +interface NewColumnDropdownProps { + /** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders + * the in-table column-header `` trigger. Same dropdown content either way. */ + trigger: 'header' | 'inline-header' + disabled: boolean + onPickType: (type: ColumnDefinition['type']) => void + onPickWorkflow: () => void +} + +/** + * "+ New column" dropdown — the single entry point for creating a column. + * Lists every column type plus "Workflow"; picking a type opens the right + * sidebar pre-seeded. + */ +export function NewColumnDropdown({ + trigger, + disabled, + onPickType, + onPickWorkflow, +}: NewColumnDropdownProps) { + const menu = ( + + + {trigger === 'header' ? ( + + ) : ( + + )} + + + {VISIBLE_COLUMN_TYPE_OPTIONS.map((option) => { + const Icon = option.icon + const onSelect = + option.type === 'workflow' + ? onPickWorkflow + : () => onPickType(option.type as ColumnDefinition['type']) + return ( + + + {option.label} + + ) + })} + + + ) + + // The in-table trigger lives inside a `` so it must be a ``. The + // header trigger lives in the page header so it sits inline. + return trigger === 'inline-header' ? {menu} : menu +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx new file mode 100644 index 00000000000..43640d2d8ae --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx @@ -0,0 +1,41 @@ +'use client' + +import { memo } from 'react' +import { Button } from '@/components/emcn' +import { Loader, Square } from '@/components/emcn/icons' + +interface RunStatusControlProps { + running: number + onStopAll: () => void + isStopping: boolean +} + +/** + * Run-status + Stop-all control rendered in the page header's leading actions + * row when any workflow runs are active. Matches the in-cell running indicator + * (Loader + tertiary text) for consistency. + */ +export const RunStatusControl = memo(function RunStatusControl({ + running, + onStopAll, + isStopping, +}: RunStatusControlProps) { + return ( +
    +
    + + {running} + running +
    + +
    + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts new file mode 100644 index 00000000000..1e8041624b8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts @@ -0,0 +1 @@ +export { TableActionBar } from './table-action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx new file mode 100644 index 00000000000..d807b32a022 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx @@ -0,0 +1,172 @@ +'use client' + +import { AnimatePresence, motion } from 'framer-motion' +import { Button, Tooltip } from '@/components/emcn' +import { Eye, PlayOutline, RefreshCw, Square } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' + +interface TableActionBarProps { + /** Number of (row × group) cells the run/stop buttons would target. Drives + * the bar's leading label ("N cells"). */ + selectedCellCount: number + /** Total running/queued workflow cells in the selection. Drives Stop. */ + runningCount: number + /** Whether the table has any workflow columns. The bar hides entirely when + * there are none — Run/Stop have nothing to act on. */ + hasWorkflowColumns: boolean + /** Show the Play (incomplete-mode) button — true when any selected cell is + * empty / errored / cancelled. */ + showPlay: boolean + /** Show the Refresh (all-mode) button — true when any selected cell is + * already completed. */ + showRefresh: boolean + /** Smart run: fire workflows only on cells that are empty / errored / + * cancelled. Maps to server `runMode: 'incomplete'`. */ + onPlay: () => void + /** Forceful re-run: fire workflows on every selected cell, including + * completed ones. Maps to server `runMode: 'all'`. */ + onRefresh: () => void + /** Cancel running/queued cells in the selection. */ + onStopWorkflows: () => void + /** When the user has highlighted exactly one workflow cell (or N adjacent + * cells in the same row + group), surface a "View execution" affordance + * alongside the run buttons. Omit when no single-execution view applies. */ + onViewExecution?: () => void + /** Disables actions while a bulk mutation is in flight. */ + isLoading?: boolean + /** Additional className for the floating wrapper — used to lift the bar + * above bottom-anchored UI like a pagination row. */ + className?: string +} + +/** + * Floating action bar shown at the bottom of the table when one or more + * workflow cells are highlighted. Play / Refresh visibility is data-driven: + * Play appears when there's anything empty/failed in the selection; Refresh + * appears when there's anything already completed; both when the selection is + * mixed. + * + * Rendered with `position: absolute` inside the table's container (not + * `fixed`) so it scopes to the table's bounds — important for embedded mode, + * where the table sits inside a panel and a fixed-positioned bar would land + * centered on the whole viewport instead of the panel. + */ +export function TableActionBar({ + selectedCellCount, + runningCount, + hasWorkflowColumns, + showPlay, + showRefresh, + onPlay, + onRefresh, + onStopWorkflows, + onViewExecution, + isLoading = false, + className, +}: TableActionBarProps) { + const visible = + hasWorkflowColumns && + selectedCellCount > 0 && + (showPlay || showRefresh || runningCount > 0 || Boolean(onViewExecution)) + const stopLabel = + runningCount === 1 ? 'Stop running workflow' : `Stop ${runningCount} running workflows` + const playLabel = + selectedCellCount === 1 ? 'Run cell' : `Run ${selectedCellCount} empty or failed cells` + const refreshLabel = selectedCellCount === 1 ? 'Re-run cell' : `Re-run ${selectedCellCount} cells` + + return ( + + {visible && ( + +
    + + {selectedCellCount === 1 + ? 'Selected 1 workflow cell' + : `Selected ${selectedCellCount} workflow cells`} + + +
    + {showPlay && ( + + + + + {playLabel} + + )} + + {showRefresh && ( + + + + + {refreshLabel} + + )} + + {runningCount > 0 && ( + + + + + {stopLabel} + + )} + + {onViewExecution && ( + + + + + View execution + + )} +
    +
    +
    + )} +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx new file mode 100644 index 00000000000..2fbbe78f194 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { RowExecutionMetadata } from '@/lib/table' +import type { SaveReason } from '../../../types' +import type { DisplayColumn } from '../types' +import { CellRender, resolveCellRender } from './cell-render' +import { InlineEditor } from './inline-editors' + +interface CellContentProps { + value: unknown + exec?: RowExecutionMetadata + column: DisplayColumn + isEditing: boolean + initialCharacter?: string | null + onSave: (value: unknown, reason: SaveReason) => void + onCancel: () => void + /** + * Human-readable labels for unmet deps on this row+group, used to render a + * "Waiting" pill when the cell hasn't run because something it depends on + * is empty. `undefined` (or empty) means no waiting state. + */ + waitingOnLabels?: string[] +} + +/** + * Glue layer: maps cell inputs to a typed `CellRenderKind` (via the pure + * resolver) and renders the corresponding JSX (via the dumb renderer). The + * inline editor sits on top when `isEditing` is true. Adding a new cell + * appearance is a three-step mechanical change in the colocated files. + */ +export function CellContent({ + value, + exec, + column, + isEditing, + initialCharacter, + onSave, + onCancel, + waitingOnLabels, +}: CellContentProps) { + const kind = resolveCellRender({ value, exec, column, waitingOnLabels }) + + return ( + <> + {isEditing && ( +
    + +
    + )} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx new file mode 100644 index 00000000000..2de71e2a9e2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -0,0 +1,273 @@ +'use client' + +import type React from 'react' +import { Badge, Checkbox, Tooltip } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { RowExecutionMetadata } from '@/lib/table' +import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' +import { storageToDisplay } from '../../../utils' +import type { DisplayColumn } from '../types' + +/** + * Discriminated union describing every shape a table cell can take. + * + * Workflow-output cells follow a status state machine: they always render + * *something* (a value, a status pill, or a dash), driven by the combination + * of `executions[groupId]` state and dep satisfaction. Plain (non-workflow) + * cells just render the typed value or empty. + * + * `'empty'` is the universal fallback used by both workflow cells (no exec, + * no value, no waiting) and plain cells (null/undefined value). + * + * Adding a new cell appearance is a three-step mechanical change: add a + * variant here, pick it in `resolveCellRender`, render it in `CellRender`. + * TypeScript's exhaustiveness check on the renderer's `switch` (the + * unreachable default) flags any branch you forgot. + */ +export type CellRenderKind = + // Workflow-output cells + | { kind: 'value'; text: string } + | { kind: 'block-error' } + | { kind: 'running' } + | { kind: 'pending-upstream' } + | { kind: 'queued' } + | { kind: 'cancelled' } + | { kind: 'error' } + | { kind: 'waiting'; labels: string[] } + // Plain typed cells + | { kind: 'boolean'; checked: boolean } + | { kind: 'json'; text: string } + | { kind: 'date'; text: string } + | { kind: 'text'; text: string } + // Universal fallback + | { kind: 'empty' } + +interface ResolveCellRenderInput { + value: unknown + exec: RowExecutionMetadata | undefined + column: DisplayColumn + /** Empty / undefined → not waiting; non-empty → render the Waiting pill. */ + waitingOnLabels: string[] | undefined +} + +/** + * Decide which `CellRenderKind` to render for a cell. Pure — easily + * unit-testable in isolation, no JSX involved. + * + * Order matters for workflow cells: block-error wins over a value (the user + * cares about the failure), value wins over running/queued (we have data + * already), and the running/queued branch deliberately collapses pre-enqueue + * `pending` and post-enqueue `queued` into one `Queued` pill so the cell + * doesn't flicker as the row transitions from one to the other. + */ +export function resolveCellRender({ + value, + exec, + column, + waitingOnLabels, +}: ResolveCellRenderInput): CellRenderKind { + const isNull = value === null || value === undefined + + if (column.workflowGroupId) { + const blockId = column.outputBlockId + const blockError = blockId ? exec?.blockErrors?.[blockId] : undefined + const blockRunning = blockId ? (exec?.runningBlockIds?.includes(blockId) ?? false) : false + const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) + + if (blockError) return { kind: 'block-error' } + + // Active re-run of THIS column wins over its prior value — the value is + // about to be overwritten and the user should see the cell is changing. + const inFlight = + exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending' + if (inFlight && blockRunning) return { kind: 'running' } + + // Value wins over `pending-upstream`: once this column's output has + // landed, the cell is done from the user's perspective — even if the + // group is still running other blocks downstream. Without this, mid-run + // partial-write events (`status: 'running'` carrying outputs but tagging + // a different block as running) would flip a finished column back to the + // amber Pending pill until the terminal `completed` event arrives. + if (!isNull) return { kind: 'value', text: stringifyValue(value) } + + if (inFlight && !(groupHasBlockErrors && !blockRunning)) { + if (exec?.status === 'queued' || exec?.status === 'pending') return { kind: 'queued' } + // `running` with this block not in `runningBlockIds` and no value yet = + // upstream block still going; surface as the amber Pending pill. + return { kind: 'pending-upstream' } + } + + // Waiting wins over a stale terminal state: if deps are unmet right now, + // the prior `cancelled` / `error` is informational at best — the cell + // can't actually run until the user fills the missing input. Surface the + // actionable state instead of the stale one. + if (waitingOnLabels && waitingOnLabels.length > 0) { + return { kind: 'waiting', labels: waitingOnLabels } + } + if (exec?.status === 'cancelled') return { kind: 'cancelled' } + if (exec?.status === 'error') return { kind: 'error' } + return { kind: 'empty' } + } + + if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } + if (isNull) return { kind: 'empty' } + if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) } + if (column.type === 'date') return { kind: 'date', text: String(value) } + return { kind: 'text', text: stringifyValue(value) } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + return JSON.stringify(value) +} + +interface CellRenderProps { + kind: CellRenderKind + /** When true the static content sits underneath the InlineEditor overlay + * and should be visually hidden (but kept in flow to preserve cell size). */ + isEditing: boolean +} + +/** + * Pure renderer: takes a `CellRenderKind` and returns the JSX. No business + * logic — adding a new cell appearance means adding a new `case` here. The + * exhaustiveness check on the `switch` (the unreachable default) flags any + * variant you forgot to handle. + */ +export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null { + switch (kind.kind) { + case 'value': + return ( + + {kind.text} + + ) + + case 'block-error': + case 'error': + return ( + + + + ) + + case 'running': + return ( + + + + ) + + case 'pending-upstream': + return ( + + + + ) + + case 'cancelled': + return ( + + + + ) + + case 'queued': + return ( + + + Queued + + + ) + + case 'waiting': + return ( + + + + + + Waiting + + + + + Waiting on {kind.labels.map((l) => `"${l}"`).join(', ')} + + + + ) + + case 'boolean': + return ( +
    + +
    + ) + + case 'json': + return ( + + {kind.text} + + ) + + case 'date': + return ( + + {storageToDisplay(kind.text)} + + ) + + case 'text': + return ( + + {kind.text} + + ) + + case 'empty': + return null + + default: { + // Exhaustiveness guard: TypeScript flags this branch if a new + // `CellRenderKind` variant is added without a matching `case` above. + const _exhaustive: never = kind + return _exhaustive + } + } +} + +/** + * Workflow-output cells are hand-editable; while editing, the static content + * must stay in flow (so the cell doesn't collapse) but be visually hidden so + * the InlineEditor overlay shows through. Plain wrapper around any non-text + * variant. + */ +function Wrap({ isEditing, children }: { isEditing: boolean; children: React.ReactNode }) { + if (!isEditing) return <>{children} + return
    {children}
    +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts new file mode 100644 index 00000000000..c54286afa5f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts @@ -0,0 +1,4 @@ +export { CellContent } from './cell-content' +export { CellRender, type CellRenderKind, resolveCellRender } from './cell-render' +export { ExpandedCellPopover } from './expanded-cell-popover' +export { InlineEditor } from './inline-editors' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/inline-editors.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts similarity index 61% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts index 28aead32657..69db8b7b4f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts @@ -5,5 +5,6 @@ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]' * been measured yet and as the initial width for newly-added columns. */ export const COL_WIDTH = 160 -/** Column config sidebar width: roomy by default, bounded on narrow screens. */ -export const COLUMN_SIDEBAR_WIDTH_CSS = 'min(480px, calc(100vw - 48px))' +/** Column config sidebar width in pixels — drives both the sidebar's own width + * and the table's reserved padding-right while a sidebar is open. */ +export const COLUMN_SIDEBAR_WIDTH = 400 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx similarity index 83% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index da955ee1322..d7a80f4a507 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -35,8 +35,13 @@ interface ColumnHeaderMenuProps { onDragLeave?: () => void workflows?: WorkflowMetadata[] workflowGroups?: WorkflowGroup[] + /** Source-info entry for workflow-output columns; supplies the producing + * block's icon component. The block's color is intentionally not used. */ sourceInfo?: ColumnSourceInfo onOpenConfig: (columnName: string) => void + /** Opens a popup preview of the column's underlying workflow. Surfaced in + * the chevron menu for workflow-output columns. */ + onViewWorkflow?: (workflowId: string) => void } /** @@ -70,6 +75,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ workflowGroups, sourceInfo, onOpenConfig, + onViewWorkflow, }: ColumnHeaderMenuProps) { const renameInputRef = useRef(null) const didDragRef = useRef(false) @@ -90,10 +96,6 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ ? 'Hide column' : 'Delete workflow' : undefined - const workflowColor = configuredWorkflow?.color - const blockIconInfo = sourceInfo?.blockIconInfo - const blockName = sourceInfo?.blockName - useEffect(() => { if (isRenaming && renameInputRef.current) { renameInputRef.current.focus() @@ -142,8 +144,13 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', column.name) + // Workflow-output columns drag as a whole group, so the ghost shows + // the group's name (falling back to the workflow's name, then the + // column slug) rather than the individual column slug. + const ghostLabel = ownGroup?.name ?? configuredWorkflow?.name ?? column.name + const ghost = document.createElement('div') - ghost.textContent = column.name + ghost.textContent = ghostLabel ghost.style.cssText = 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' document.body.appendChild(ghost) @@ -152,7 +159,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onDragStart?.(column.name) }, - [column.name, readOnly, isRenaming, onDragStart] + [column.name, ownGroup, configuredWorkflow, readOnly, isRenaming, onDragStart] ) const handleDragOver = useCallback( @@ -181,6 +188,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ const th = e.currentTarget as HTMLElement const related = e.relatedTarget as Node | null if (related && th.contains(related)) return + // Don't clear when the cursor is moving to another column header — the + // next dragover will set the right target. Clearing here causes the + // drop indicator to flicker between sibling columns of a workflow + // group (and any adjacent column hop in general). + if (related && related instanceof Element && related.closest('th')) return onDragLeave?.() }, [onDragLeave] @@ -238,8 +250,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
    - {column.workflowGroupId ? ( -
    - {blockName && ( - - {blockName} - - )} - - {column.headerLabel} - -
    - ) : ( - - {column.name} - - )} + + {column.workflowGroupId ? column.headerLabel : column.name} +
    ) : (
    @@ -288,25 +287,12 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ > - {column.workflowGroupId ? ( -
    - {blockName && ( - - {blockName} - - )} - - {column.headerLabel} - -
    - ) : ( - - {column.name} - - )} + + {column.workflowGroupId ? column.headerLabel : column.name} + onViewWorkflow(ownGroup.workflowId) : undefined + } />
    )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx new file mode 100644 index 00000000000..e4e4fc51b24 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx @@ -0,0 +1,50 @@ +'use client' + +import type React from 'react' +import { + Calendar as CalendarIcon, + PlayOutline, + TypeBoolean, + TypeJson, + TypeNumber, + TypeText, +} from '@/components/emcn/icons' +import type { BlockIconInfo } from '../types' + +export const COLUMN_TYPE_ICONS: Record = { + string: TypeText, + number: TypeNumber, + boolean: TypeBoolean, + date: CalendarIcon, + json: TypeJson, +} + +interface ColumnTypeIconProps { + type: string + /** True for workflow-output columns; renders the producing block's icon + * (or a workflow fallback) instead of the scalar type icon. Workflow + * columns ARE stored as scalar types, so without this `type` would + * otherwise resolve to e.g. `string` and read identically to a plain + * text column. */ + isWorkflowColumn?: boolean + /** Block-icon info from the source-info builder, used for workflow columns + * to surface the producing block's icon. The block's color is intentionally + * ignored — icons render in the plain `text-[var(--text-icon)]` tone like + * every other column-type icon, no per-block tint. */ + blockIconInfo?: BlockIconInfo +} + +/** + * Tiny icon shown next to a column header. Workflow-output columns get the + * producing block's icon (falling back to `PlayOutline`); plain columns get + * their scalar type icon. Both render in the same `text-[var(--text-icon)]` + * tone — no per-workflow color, no colored swatch. + */ +export function ColumnTypeIcon({ type, isWorkflowColumn, blockIconInfo }: ColumnTypeIconProps) { + if (isWorkflowColumn) { + const Icon = blockIconInfo?.icon ?? PlayOutline + return + } + const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts new file mode 100644 index 00000000000..8c8ef9f9dc2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts @@ -0,0 +1,3 @@ +export { ColumnHeaderMenu } from './column-header-menu' +export { COLUMN_TYPE_ICONS, ColumnTypeIcon } from './column-type-icon' +export { ColumnOptionsMenu, WorkflowGroupMetaCell } from './workflow-group-meta-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx similarity index 59% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index 8b3403053f6..51788975ec9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -12,7 +12,16 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowLeft, ArrowRight, EyeOff, Pencil, PlayOutline, Trash } from '@/components/emcn/icons' +import { + ArrowLeft, + ArrowRight, + Eye, + EyeOff, + Pencil, + PlayOutline, + Trash, +} from '@/components/emcn/icons' +import type { RunMode } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { SELECTION_TINT_BG } from '../constants' @@ -40,8 +49,14 @@ interface ColumnOptionsMenuProps { onDeleteGroup?: () => void /** When provided, the menu is being opened from a workflow-group header and * exposes group-level run actions above the column actions. */ - onRunGroupAll?: () => void - onRunGroupIncomplete?: () => void + onRunColumnAll?: () => void + onRunColumnIncomplete?: () => void + /** When set, surfaces a "Run N selected rows" item above Run all. */ + onRunColumnSelected?: () => void + selectedRowCount?: number + /** When set, the menu surfaces a "View workflow" item that opens a popup + * preview of the configured workflow. */ + onViewWorkflow?: () => void } /** @@ -62,10 +77,14 @@ export function ColumnOptionsMenu({ onInsertRight, onDeleteColumn, onDeleteGroup, - onRunGroupAll, - onRunGroupIncomplete, + onRunColumnAll, + onRunColumnIncomplete, + onRunColumnSelected, + selectedRowCount = 0, + onViewWorkflow, }: ColumnOptionsMenuProps) { - const showRunActions = Boolean(onRunGroupAll && onRunGroupIncomplete) + const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) + const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 return ( @@ -97,8 +116,15 @@ export function ColumnOptionsMenu({ Run - onRunGroupAll?.()}>Run all rows - onRunGroupIncomplete?.()}> + {showRunSelected && ( + onRunColumnSelected?.()}> + {`Run ${selectedRowCount} selected ${selectedRowCount === 1 ? 'row' : 'rows'}`} + + )} + onRunColumnAll?.()}> + Run all rows + + onRunColumnIncomplete?.()}> Run empty rows @@ -106,6 +132,12 @@ export function ColumnOptionsMenu({ )} + {onViewWorkflow && ( + onViewWorkflow()}> + + View workflow + + )} onOpenConfig(column.name)}> Edit column @@ -143,12 +175,26 @@ interface WorkflowGroupMetaCellProps { isGroupSelected: boolean onSelectGroup: (startColIndex: number, size: number) => void onOpenConfig: (columnName: string) => void - onRunGroup?: (groupId: string, workflowId: string, mode?: 'all' | 'incomplete') => void + onRunColumn?: (groupId: string, mode?: RunMode, rowIds?: string[]) => void onInsertLeft?: (columnName: string) => void onInsertRight?: (columnName: string) => void onDeleteColumn?: (columnName: string) => void /** Right-click delete on the group header drops the entire workflow group. */ onDeleteGroup?: (groupId: string) => void + /** Row ids in the user's current multi-row selection; when non-empty the + * run menu adds a "Run N selected rows" option. */ + selectedRowIds?: string[] | null + /** Opens a popup preview of the underlying workflow. */ + onViewWorkflow?: (workflowId: string) => void + /** When set, the meta cell becomes draggable and forwards events through + * the same column-reorder pipeline used by individual workflow column + * headers. The whole group moves together because downstream code groups + * fan-out siblings by `workflowGroupId`. */ + onDragStart?: (columnName: string) => void + onDragOver?: (columnName: string, side: 'left' | 'right') => void + onDragEnd?: () => void + onDragLeave?: () => void + readOnly?: boolean } /** @@ -167,11 +213,18 @@ export function WorkflowGroupMetaCell({ isGroupSelected, onSelectGroup, onOpenConfig, - onRunGroup, + onRunColumn, onInsertLeft, onInsertRight, onDeleteColumn, onDeleteGroup, + selectedRowIds, + onViewWorkflow, + onDragStart, + onDragOver, + onDragEnd, + onDragLeave, + readOnly, }: WorkflowGroupMetaCellProps) { const wf = workflows?.find((w) => w.id === workflowId) const color = wf?.color ?? 'var(--text-muted)' @@ -180,14 +233,23 @@ export function WorkflowGroupMetaCell({ const [optionsMenuOpen, setOptionsMenuOpen] = useState(false) const [optionsMenuPosition, setOptionsMenuPosition] = useState({ x: 0, y: 0 }) const [runMenuOpen, setRunMenuOpen] = useState(false) + const didDragRef = useRef(false) + + const selectedCount = selectedRowIds?.length ?? 0 const handleRunAll = useCallback(() => { - if (groupId && workflowId) onRunGroup?.(groupId, workflowId, 'all') - }, [groupId, workflowId, onRunGroup]) + if (groupId) onRunColumn?.(groupId, 'all') + }, [groupId, onRunColumn]) const handleRunIncomplete = useCallback(() => { - if (groupId && workflowId) onRunGroup?.(groupId, workflowId, 'incomplete') - }, [groupId, workflowId, onRunGroup]) + if (groupId) onRunColumn?.(groupId, 'incomplete') + }, [groupId, onRunColumn]) + + const handleRunSelected = useCallback(() => { + if (groupId && selectedRowIds && selectedRowIds.length > 0) { + onRunColumn?.(groupId, 'all', selectedRowIds) + } + }, [groupId, onRunColumn, selectedRowIds]) const handleContextMenu = useCallback( (e: React.MouseEvent) => { @@ -207,18 +269,88 @@ export function WorkflowGroupMetaCell({ // should select the group + open the config sidebar. const target = e.target as HTMLElement if (target.closest('button, [role="menuitem"], [role="menu"]')) return + // Drag-vs-click guard: when a drag just ended on this cell, swallow the + // synthetic click so we don't accidentally pop open the sidebar. + if (didDragRef.current) { + didDragRef.current = false + return + } onSelectGroup(startColIndex, size) if (columnName) onOpenConfig(columnName) }, [columnName, onOpenConfig, onSelectGroup, size, startColIndex] ) + const handleDragStart = useCallback( + (e: React.DragEvent) => { + if (readOnly || !onDragStart || !columnName) { + e.preventDefault() + return + } + didDragRef.current = true + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', columnName) + + const ghost = document.createElement('div') + ghost.textContent = name + ghost.style.cssText = + 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' + document.body.appendChild(ghost) + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost)) + + onDragStart(columnName) + }, + [columnName, name, onDragStart, readOnly] + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (!onDragOver || !columnName) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const midX = rect.left + rect.width / 2 + const side = e.clientX < midX ? 'left' : 'right' + onDragOver(columnName, side) + }, + [columnName, onDragOver] + ) + + const handleDragEnd = useCallback(() => { + didDragRef.current = false + onDragEnd?.() + }, [onDragEnd]) + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + const th = e.currentTarget as HTMLElement + const related = e.relatedTarget as Node | null + if (related && th.contains(related)) return + if (related && related instanceof Element && related.closest('th')) return + onDragLeave?.() + }, + [onDragLeave] + ) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + const isDraggable = !readOnly && Boolean(onDragStart) + return (
    {name} - {onRunGroup && ( + {onRunColumn && (