From 06b68f7463bf71377b3325ecddea5293bbf71815 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Apr 2026 14:03:09 -0700 Subject: [PATCH 01/19] feat(trigger): add Google Sheets, Drive, and Calendar polling triggers Add polling triggers for Google Sheets (new rows), Google Drive (file changes via changes.list API), and Google Calendar (event updates via updatedMin). Each includes OAuth credential support, configurable filters (event type, MIME type, folder, search term, render options), idempotency, and first-poll seeding. Wire triggers into block configs and regenerate integrations.json. Update add-trigger skill with polling instructions and versioned block wiring guidance. Co-Authored-By: Claude Opus 4.6 --- .claude/commands/add-trigger.md | 161 ++++++- .cursor/commands/add-trigger.md | 159 ++++++- .../integrations/data/integrations.json | 30 +- apps/sim/blocks/blocks/google_calendar.ts | 6 + apps/sim/blocks/blocks/google_drive.ts | 6 + apps/sim/blocks/blocks/google_sheets.ts | 6 + .../lib/webhooks/polling/google-calendar.ts | 347 ++++++++++++++ apps/sim/lib/webhooks/polling/google-drive.ts | 386 ++++++++++++++++ .../sim/lib/webhooks/polling/google-sheets.ts | 435 ++++++++++++++++++ apps/sim/lib/webhooks/polling/registry.ts | 6 + apps/sim/triggers/constants.ts | 10 +- apps/sim/triggers/google-calendar/index.ts | 1 + apps/sim/triggers/google-calendar/poller.ts | 200 ++++++++ apps/sim/triggers/google-sheets/index.ts | 1 + apps/sim/triggers/google-sheets/poller.ts | 185 ++++++++ apps/sim/triggers/google_drive/index.ts | 1 + apps/sim/triggers/google_drive/poller.ts | 167 +++++++ apps/sim/triggers/registry.ts | 6 + helm/sim/values.yaml | 27 ++ 19 files changed, 2114 insertions(+), 26 deletions(-) create mode 100644 apps/sim/lib/webhooks/polling/google-calendar.ts create mode 100644 apps/sim/lib/webhooks/polling/google-drive.ts create mode 100644 apps/sim/lib/webhooks/polling/google-sheets.ts create mode 100644 apps/sim/triggers/google-calendar/index.ts create mode 100644 apps/sim/triggers/google-calendar/poller.ts create mode 100644 apps/sim/triggers/google-sheets/index.ts create mode 100644 apps/sim/triggers/google-sheets/poller.ts create mode 100644 apps/sim/triggers/google_drive/index.ts create mode 100644 apps/sim/triggers/google_drive/poller.ts diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index 9cbeca68a3e..e12eb393ba7 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -1,17 +1,17 @@ --- -description: Create webhook triggers for a Sim integration using the generic trigger builder +description: Create webhook or polling triggers for a Sim integration argument-hint: --- # Add Trigger -You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks. +You are an expert at creating webhook and polling triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, polling infrastructure, and how triggers connect to blocks. ## Your Task -1. Research what webhook events the service supports -2. Create the trigger files using the generic builder -3. Create a provider handler if custom auth, formatting, or subscriptions are needed +1. Research what webhook events the service supports — if the service lacks reliable webhooks, use polling +2. Create the trigger files using the generic builder (webhook) or manual config (polling) +3. Create a provider handler (webhook) or polling handler (polling) 4. Register triggers and connect them to the block ## Directory Structure @@ -146,23 +146,37 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { ### Block file (`apps/sim/blocks/blocks/{service}.ts`) +Wire triggers into the block so the trigger UI appears and `generate-docs.ts` discovers them. Two changes are needed: + +1. **Spread trigger subBlocks** at the end of the block's `subBlocks` array +2. **Add `triggers` property** after `outputs` with `enabled: true` and `available: [...]` + ```typescript import { getTrigger } from '@/triggers' export const {Service}Block: BlockConfig = { // ... - triggers: { - enabled: true, - available: ['{service}_event_a', '{service}_event_b'], - }, subBlocks: [ // Regular tool subBlocks first... ...getTrigger('{service}_event_a').subBlocks, ...getTrigger('{service}_event_b').subBlocks, ], + // ... tools, inputs, outputs ... + triggers: { + enabled: true, + available: ['{service}_event_a', '{service}_event_b'], + }, } ``` +**Versioned blocks (V1 + V2):** Many integrations have a hidden V1 block and a visible V2 block. Where you add the trigger wiring depends on how V2 inherits from V1: + +- **V2 uses `...V1Block` spread** (e.g., Google Calendar): Add trigger to V1 — V2 inherits both `subBlocks` and `triggers` automatically. +- **V2 defines its own `subBlocks`** (e.g., Google Sheets): Add trigger to V2 (the visible block). V1 is hidden and doesn't need it. +- **Single block, no V2** (e.g., Google Drive): Add trigger directly. + +`generate-docs.ts` deduplicates by base type (first match wins). If V1 is processed first without triggers, the V2 triggers won't appear in `integrations.json`. Always verify by checking the output after running the script. + ## Provider Handler All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. @@ -327,6 +341,122 @@ export function buildOutputs(): Record { } ``` +## Polling Triggers + +Use polling when the service lacks reliable webhooks (e.g., Google Sheets, Google Drive, Google Calendar, Gmail, RSS, IMAP). Polling triggers do NOT use `buildTriggerSubBlocks` — they define subBlocks manually. + +### Directory Structure + +``` +apps/sim/triggers/{service}/ +├── index.ts # Barrel export +└── poller.ts # TriggerConfig with polling: true + +apps/sim/lib/webhooks/polling/ +└── {service}.ts # PollingProviderHandler implementation +``` + +### Polling Handler (`apps/sim/lib/webhooks/polling/{service}.ts`) + +```typescript +import { pollingIdempotency } from '@/lib/core/idempotency/service' +import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' +import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' +import { processPolledWebhookEvent } from '@/lib/webhooks/processor' + +export const {service}PollingHandler: PollingProviderHandler = { + provider: '{service}', + label: '{Service}', + + async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { + const { webhookData, workflowData, requestId, logger } = ctx + const webhookId = webhookData.id + + try { + // For OAuth services: + const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId, logger) + const config = webhookData.providerConfig as unknown as {Service}WebhookConfig + + // First poll: seed state, emit nothing + if (!config.lastCheckedTimestamp) { + await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: new Date().toISOString() }, logger) + await markWebhookSuccess(webhookId, logger) + return 'success' + } + + // Fetch changes since last poll, process with idempotency + // ... + + await markWebhookSuccess(webhookId, logger) + return 'success' + } catch (error) { + logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error) + await markWebhookFailed(webhookId, logger) + return 'failure' + } + }, +} +``` + +**Key patterns:** +- First poll seeds state and emits nothing (avoids flooding with existing data) +- Use `pollingIdempotency.executeWithIdempotency(provider, key, callback)` for dedup +- Use `processPolledWebhookEvent(webhookData, workflowData, payload, requestId)` to fire the workflow +- Use `updateWebhookProviderConfig(webhookId, partialConfig, logger)` for read-merge-write on state +- Use the latest server-side timestamp from API responses (not wall clock) to avoid clock skew + +### Trigger Config (`apps/sim/triggers/{service}/poller.ts`) + +```typescript +import { {Service}Icon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' + +export const {service}PollingTrigger: TriggerConfig = { + id: '{service}_poller', + name: '{Service} Trigger', + provider: '{service}', + description: 'Triggers when ...', + version: '1.0.0', + icon: {Service}Icon, + polling: true, // REQUIRED — routes to polling infrastructure + + subBlocks: [ + { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger', supportsCredentialSets: true }, + // ... service-specific config fields (dropdowns, inputs, switches) ... + { id: 'triggerSave', type: 'trigger-save', title: '', hideFromPreview: true, mode: 'trigger', triggerId: '{service}_poller' }, + { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, + ], + + outputs: { + // Must match the payload shape from processPolledWebhookEvent + }, +} +``` + +### Registration (3 places) + +1. **`apps/sim/triggers/constants.ts`** — add provider to `POLLING_PROVIDERS` Set +2. **`apps/sim/lib/webhooks/polling/registry.ts`** — import handler, add to `POLLING_HANDLERS` +3. **`apps/sim/triggers/registry.ts`** — import trigger config, add to `TRIGGER_REGISTRY` + +### Helm Cron Job + +Add to `helm/sim/values.yaml` under the existing polling cron jobs: + +```yaml +{service}WebhookPoll: + schedule: "*/1 * * * *" + concurrencyPolicy: Forbid + url: "http://sim:3000/api/webhooks/poll/{service}" +``` + +### Reference Implementations + +- Simple: `apps/sim/lib/webhooks/polling/rss.ts` + `apps/sim/triggers/rss/poller.ts` +- Complex (OAuth, attachments): `apps/sim/lib/webhooks/polling/gmail.ts` + `apps/sim/triggers/gmail/poller.ts` +- Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts` +- Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts` + ## Checklist ### Trigger Definition @@ -352,7 +482,18 @@ export function buildOutputs(): Record { - [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` - [ ] API key field uses `password: true` +### Polling Trigger (if applicable) +- [ ] Handler implements `PollingProviderHandler` at `lib/webhooks/polling/{service}.ts` +- [ ] Trigger config has `polling: true` and defines subBlocks manually (no `buildTriggerSubBlocks`) +- [ ] Provider string matches across: trigger config, handler, `POLLING_PROVIDERS`, polling registry +- [ ] `triggerSave` subBlock `triggerId` matches trigger config `id` +- [ ] First poll seeds state and emits nothing +- [ ] Added provider to `POLLING_PROVIDERS` in `triggers/constants.ts` +- [ ] Added handler to `POLLING_HANDLERS` in `lib/webhooks/polling/registry.ts` +- [ ] Added cron job to `helm/sim/values.yaml` +- [ ] Payload shape matches trigger `outputs` schema + ### Testing - [ ] `bun run type-check` passes -- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys +- [ ] Manually verify output keys match trigger `outputs` keys - [ ] Trigger UI shows correctly in the block diff --git a/.cursor/commands/add-trigger.md b/.cursor/commands/add-trigger.md index 2d243827e3e..ae19f0f295b 100644 --- a/.cursor/commands/add-trigger.md +++ b/.cursor/commands/add-trigger.md @@ -1,12 +1,12 @@ # Add Trigger -You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks. +You are an expert at creating webhook and polling triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, polling infrastructure, and how triggers connect to blocks. ## Your Task -1. Research what webhook events the service supports -2. Create the trigger files using the generic builder -3. Create a provider handler if custom auth, formatting, or subscriptions are needed +1. Research what webhook events the service supports — if the service lacks reliable webhooks, use polling +2. Create the trigger files using the generic builder (webhook) or manual config (polling) +3. Create a provider handler (webhook) or polling handler (polling) 4. Register triggers and connect them to the block ## Directory Structure @@ -141,23 +141,37 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { ### Block file (`apps/sim/blocks/blocks/{service}.ts`) +Wire triggers into the block so the trigger UI appears and `generate-docs.ts` discovers them. Two changes are needed: + +1. **Spread trigger subBlocks** at the end of the block's `subBlocks` array +2. **Add `triggers` property** after `outputs` with `enabled: true` and `available: [...]` + ```typescript import { getTrigger } from '@/triggers' export const {Service}Block: BlockConfig = { // ... - triggers: { - enabled: true, - available: ['{service}_event_a', '{service}_event_b'], - }, subBlocks: [ // Regular tool subBlocks first... ...getTrigger('{service}_event_a').subBlocks, ...getTrigger('{service}_event_b').subBlocks, ], + // ... tools, inputs, outputs ... + triggers: { + enabled: true, + available: ['{service}_event_a', '{service}_event_b'], + }, } ``` +**Versioned blocks (V1 + V2):** Many integrations have a hidden V1 block and a visible V2 block. Where you add the trigger wiring depends on how V2 inherits from V1: + +- **V2 uses `...V1Block` spread** (e.g., Google Calendar): Add trigger to V1 — V2 inherits both `subBlocks` and `triggers` automatically. +- **V2 defines its own `subBlocks`** (e.g., Google Sheets): Add trigger to V2 (the visible block). V1 is hidden and doesn't need it. +- **Single block, no V2** (e.g., Google Drive): Add trigger directly. + +`generate-docs.ts` deduplicates by base type (first match wins). If V1 is processed first without triggers, the V2 triggers won't appear in `integrations.json`. Always verify by checking the output after running the script. + ## Provider Handler All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. @@ -322,6 +336,122 @@ export function buildOutputs(): Record { } ``` +## Polling Triggers + +Use polling when the service lacks reliable webhooks (e.g., Google Sheets, Google Drive, Google Calendar, Gmail, RSS, IMAP). Polling triggers do NOT use `buildTriggerSubBlocks` — they define subBlocks manually. + +### Directory Structure + +``` +apps/sim/triggers/{service}/ +├── index.ts # Barrel export +└── poller.ts # TriggerConfig with polling: true + +apps/sim/lib/webhooks/polling/ +└── {service}.ts # PollingProviderHandler implementation +``` + +### Polling Handler (`apps/sim/lib/webhooks/polling/{service}.ts`) + +```typescript +import { pollingIdempotency } from '@/lib/core/idempotency/service' +import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' +import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' +import { processPolledWebhookEvent } from '@/lib/webhooks/processor' + +export const {service}PollingHandler: PollingProviderHandler = { + provider: '{service}', + label: '{Service}', + + async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { + const { webhookData, workflowData, requestId, logger } = ctx + const webhookId = webhookData.id + + try { + // For OAuth services: + const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId, logger) + const config = webhookData.providerConfig as unknown as {Service}WebhookConfig + + // First poll: seed state, emit nothing + if (!config.lastCheckedTimestamp) { + await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: new Date().toISOString() }, logger) + await markWebhookSuccess(webhookId, logger) + return 'success' + } + + // Fetch changes since last poll, process with idempotency + // ... + + await markWebhookSuccess(webhookId, logger) + return 'success' + } catch (error) { + logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error) + await markWebhookFailed(webhookId, logger) + return 'failure' + } + }, +} +``` + +**Key patterns:** +- First poll seeds state and emits nothing (avoids flooding with existing data) +- Use `pollingIdempotency.executeWithIdempotency(provider, key, callback)` for dedup +- Use `processPolledWebhookEvent(webhookData, workflowData, payload, requestId)` to fire the workflow +- Use `updateWebhookProviderConfig(webhookId, partialConfig, logger)` for read-merge-write on state +- Use the latest server-side timestamp from API responses (not wall clock) to avoid clock skew + +### Trigger Config (`apps/sim/triggers/{service}/poller.ts`) + +```typescript +import { {Service}Icon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' + +export const {service}PollingTrigger: TriggerConfig = { + id: '{service}_poller', + name: '{Service} Trigger', + provider: '{service}', + description: 'Triggers when ...', + version: '1.0.0', + icon: {Service}Icon, + polling: true, // REQUIRED — routes to polling infrastructure + + subBlocks: [ + { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger', supportsCredentialSets: true }, + // ... service-specific config fields (dropdowns, inputs, switches) ... + { id: 'triggerSave', type: 'trigger-save', title: '', hideFromPreview: true, mode: 'trigger', triggerId: '{service}_poller' }, + { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, + ], + + outputs: { + // Must match the payload shape from processPolledWebhookEvent + }, +} +``` + +### Registration (3 places) + +1. **`apps/sim/triggers/constants.ts`** — add provider to `POLLING_PROVIDERS` Set +2. **`apps/sim/lib/webhooks/polling/registry.ts`** — import handler, add to `POLLING_HANDLERS` +3. **`apps/sim/triggers/registry.ts`** — import trigger config, add to `TRIGGER_REGISTRY` + +### Helm Cron Job + +Add to `helm/sim/values.yaml` under the existing polling cron jobs: + +```yaml +{service}WebhookPoll: + schedule: "*/1 * * * *" + concurrencyPolicy: Forbid + url: "http://sim:3000/api/webhooks/poll/{service}" +``` + +### Reference Implementations + +- Simple: `apps/sim/lib/webhooks/polling/rss.ts` + `apps/sim/triggers/rss/poller.ts` +- Complex (OAuth, attachments): `apps/sim/lib/webhooks/polling/gmail.ts` + `apps/sim/triggers/gmail/poller.ts` +- Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts` +- Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts` + ## Checklist ### Trigger Definition @@ -347,7 +477,18 @@ export function buildOutputs(): Record { - [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` - [ ] API key field uses `password: true` +### Polling Trigger (if applicable) +- [ ] Handler implements `PollingProviderHandler` at `lib/webhooks/polling/{service}.ts` +- [ ] Trigger config has `polling: true` and defines subBlocks manually (no `buildTriggerSubBlocks`) +- [ ] Provider string matches across: trigger config, handler, `POLLING_PROVIDERS`, polling registry +- [ ] `triggerSave` subBlock `triggerId` matches trigger config `id` +- [ ] First poll seeds state and emits nothing +- [ ] Added provider to `POLLING_PROVIDERS` in `triggers/constants.ts` +- [ ] Added handler to `POLLING_HANDLERS` in `lib/webhooks/polling/registry.ts` +- [ ] Added cron job to `helm/sim/values.yaml` +- [ ] Payload shape matches trigger `outputs` schema + ### Testing - [ ] `bun run type-check` passes -- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys +- [ ] Manually verify output keys match trigger `outputs` keys - [ ] Trigger UI shows correctly in the block diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index d367a801885..1d4be47d57b 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -4421,8 +4421,14 @@ } ], "operationCount": 10, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "google_calendar_poller", + "name": "Google Calendar Event Trigger", + "description": "Triggers when events are created, updated, or cancelled in Google Calendar" + } + ], + "triggerCount": 1, "authType": "oauth", "category": "tools", "integrationType": "productivity", @@ -4570,8 +4576,14 @@ } ], "operationCount": 14, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "google_drive_poller", + "name": "Google Drive File Trigger", + "description": "Triggers when files are created, modified, or deleted in Google Drive" + } + ], + "triggerCount": 1, "authType": "oauth", "category": "tools", "integrationType": "file-storage", @@ -4927,8 +4939,14 @@ } ], "operationCount": 11, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "google_sheets_poller", + "name": "Google Sheets New Row Trigger", + "description": "Triggers when new rows are added to a Google Sheet" + } + ], + "triggerCount": 1, "authType": "oauth", "category": "tools", "integrationType": "documents", diff --git a/apps/sim/blocks/blocks/google_calendar.ts b/apps/sim/blocks/blocks/google_calendar.ts index 2dac7053fe9..0c984503c9a 100644 --- a/apps/sim/blocks/blocks/google_calendar.ts +++ b/apps/sim/blocks/blocks/google_calendar.ts @@ -4,6 +4,7 @@ import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { createVersionedToolSelector, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' import type { GoogleCalendarResponse } from '@/tools/google_calendar/types' +import { getTrigger } from '@/triggers' export const GoogleCalendarBlock: BlockConfig = { type: 'google_calendar', @@ -488,6 +489,7 @@ Return ONLY the natural language event text - no explanations.`, { label: 'None (no emails sent)', id: 'none' }, ], }, + ...getTrigger('google_calendar_poller').subBlocks, ], tools: { access: [ @@ -644,6 +646,10 @@ Return ONLY the natural language event text - no explanations.`, content: { type: 'string', description: 'Operation response content' }, metadata: { type: 'json', description: 'Event or calendar metadata' }, }, + triggers: { + enabled: true, + available: ['google_calendar_poller'], + }, } export const GoogleCalendarV2Block: BlockConfig = { diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index cd54480d770..79ab814e04e 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -4,6 +4,7 @@ import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' import type { GoogleDriveResponse } from '@/tools/google_drive/types' +import { getTrigger } from '@/triggers' export const GoogleDriveBlock: BlockConfig = { type: 'google_drive', @@ -719,6 +720,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr required: true, }, // Get Drive Info has no additional fields (just needs credential) + ...getTrigger('google_drive_poller').subBlocks, ], tools: { access: [ @@ -939,4 +941,8 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr deleted: { type: 'boolean', description: 'Whether file was deleted' }, removed: { type: 'boolean', description: 'Whether permission was removed' }, }, + triggers: { + enabled: true, + available: ['google_drive_poller'], + }, } diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts index 0c4eb3371f1..bf9445bb048 100644 --- a/apps/sim/blocks/blocks/google_sheets.ts +++ b/apps/sim/blocks/blocks/google_sheets.ts @@ -4,6 +4,7 @@ import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { createVersionedToolSelector, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' import type { GoogleSheetsResponse, GoogleSheetsV2Response } from '@/tools/google_sheets/types' +import { getTrigger } from '@/triggers' // Legacy block - hidden from toolbar export const GoogleSheetsBlock: BlockConfig = { @@ -716,6 +717,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, condition: { field: 'operation', value: 'copy_sheet' }, required: true, }, + ...getTrigger('google_sheets_poller').subBlocks, ], tools: { access: [ @@ -1068,4 +1070,8 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, }, }, }, + triggers: { + enabled: true, + available: ['google_sheets_poller'], + }, } diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts new file mode 100644 index 00000000000..abb06c8243c --- /dev/null +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -0,0 +1,347 @@ +import { pollingIdempotency } from '@/lib/core/idempotency/service' +import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' +import { + markWebhookFailed, + markWebhookSuccess, + resolveOAuthCredential, + updateWebhookProviderConfig, +} from '@/lib/webhooks/polling/utils' +import { processPolledWebhookEvent } from '@/lib/webhooks/processor' + +const CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3' +const MAX_EVENTS_PER_POLL = 50 +const MAX_PAGES = 10 + +type CalendarEventTypeFilter = '' | 'created' | 'updated' | 'cancelled' + +interface GoogleCalendarWebhookConfig { + calendarId: string + eventTypeFilter?: CalendarEventTypeFilter + searchTerm?: string + lastCheckedTimestamp?: string + maxEventsPerPoll?: number +} + +interface CalendarEventAttendee { + email: string + displayName?: string + responseStatus?: string + self?: boolean + organizer?: boolean +} + +interface CalendarEventPerson { + email: string + displayName?: string + self?: boolean +} + +interface CalendarEventTime { + dateTime?: string + date?: string + timeZone?: string +} + +interface CalendarEvent { + id: string + status: string + htmlLink?: string + created?: string + updated?: string + summary?: string + description?: string + location?: string + start?: CalendarEventTime + end?: CalendarEventTime + attendees?: CalendarEventAttendee[] + creator?: CalendarEventPerson + organizer?: CalendarEventPerson + recurringEventId?: string +} + +interface SimplifiedCalendarEvent { + id: string + status: string + eventType: 'created' | 'updated' | 'cancelled' + summary: string | null + eventDescription: string | null + location: string | null + htmlLink: string | null + start: CalendarEventTime | null + end: CalendarEventTime | null + created: string | null + updated: string | null + attendees: CalendarEventAttendee[] | null + creator: CalendarEventPerson | null + organizer: CalendarEventPerson | null +} + +export interface GoogleCalendarWebhookPayload { + event: SimplifiedCalendarEvent + calendarId: string + timestamp: string +} + +export const googleCalendarPollingHandler: PollingProviderHandler = { + provider: 'google-calendar', + label: 'Google Calendar', + + async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { + const { webhookData, workflowData, requestId, logger } = ctx + const webhookId = webhookData.id + + try { + const accessToken = await resolveOAuthCredential( + webhookData, + 'google-calendar', + requestId, + logger + ) + + const config = webhookData.providerConfig as unknown as GoogleCalendarWebhookConfig + const calendarId = config.calendarId || 'primary' + const now = new Date() + + // First poll: seed timestamp, emit nothing + if (!config.lastCheckedTimestamp) { + await updateWebhookProviderConfig( + webhookId, + { lastCheckedTimestamp: now.toISOString() }, + logger + ) + await markWebhookSuccess(webhookId, logger) + logger.info(`[${requestId}] First poll for webhook ${webhookId}, seeded timestamp`) + return 'success' + } + + // Fetch changed events since last poll + const events = await fetchChangedEvents(accessToken, calendarId, config, requestId, logger) + + if (!events.length) { + await updateWebhookProviderConfig( + webhookId, + { lastCheckedTimestamp: now.toISOString() }, + logger + ) + await markWebhookSuccess(webhookId, logger) + logger.info(`[${requestId}] No changed events for webhook ${webhookId}`) + return 'success' + } + + logger.info(`[${requestId}] Found ${events.length} changed events for webhook ${webhookId}`) + + const { processedCount, failedCount, latestUpdated } = await processEvents( + events, + calendarId, + config.eventTypeFilter, + webhookData, + workflowData, + requestId, + logger + ) + + // Use the latest `updated` value from response to avoid clock skew + const newTimestamp = latestUpdated || now.toISOString() + await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: newTimestamp }, logger) + + if (failedCount > 0 && processedCount === 0) { + await markWebhookFailed(webhookId, logger) + logger.warn( + `[${requestId}] All ${failedCount} events failed to process for webhook ${webhookId}` + ) + return 'failure' + } + + await markWebhookSuccess(webhookId, logger) + logger.info( + `[${requestId}] Successfully processed ${processedCount} events for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + ) + return 'success' + } catch (error) { + logger.error(`[${requestId}] Error processing Google Calendar webhook ${webhookId}:`, error) + await markWebhookFailed(webhookId, logger) + return 'failure' + } + }, +} + +async function fetchChangedEvents( + accessToken: string, + calendarId: string, + config: GoogleCalendarWebhookConfig, + requestId: string, + logger: ReturnType +): Promise { + const allEvents: CalendarEvent[] = [] + const maxEvents = config.maxEventsPerPoll || MAX_EVENTS_PER_POLL + let pageToken: string | undefined + let pages = 0 + + do { + pages++ + const params = new URLSearchParams({ + updatedMin: config.lastCheckedTimestamp!, + singleEvents: 'true', + showDeleted: 'true', + orderBy: 'updated', + maxResults: String(Math.min(maxEvents, 250)), + }) + + if (pageToken) { + params.set('pageToken', pageToken) + } + + if (config.searchTerm) { + params.set('q', config.searchTerm) + } + + const encodedCalendarId = encodeURIComponent(calendarId) + const url = `${CALENDAR_API_BASE}/calendars/${encodedCalendarId}/events?${params.toString()}` + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const status = response.status + const errorData = await response.json().catch(() => ({})) + + if (status === 403 || status === 429) { + throw new Error( + `Calendar API rate limit (${status}) — skipping to retry next poll cycle: ${JSON.stringify(errorData)}` + ) + } + + throw new Error(`Failed to fetch calendar events: ${status} - ${JSON.stringify(errorData)}`) + } + + const data = await response.json() + const events = (data.items || []) as CalendarEvent[] + allEvents.push(...events) + + pageToken = data.nextPageToken as string | undefined + + // Stop if we have enough events or hit the page limit + if (allEvents.length >= maxEvents || pages >= MAX_PAGES) { + break + } + } while (pageToken) + + return allEvents.slice(0, maxEvents) +} + +function determineEventType(event: CalendarEvent): 'created' | 'updated' | 'cancelled' { + if (event.status === 'cancelled') { + return 'cancelled' + } + + // If created and updated are within 5 seconds, treat as newly created + if (event.created && event.updated) { + const createdTime = new Date(event.created).getTime() + const updatedTime = new Date(event.updated).getTime() + if (Math.abs(updatedTime - createdTime) < 5000) { + return 'created' + } + } + + return 'updated' +} + +function simplifyEvent( + event: CalendarEvent, + eventType?: 'created' | 'updated' | 'cancelled' +): SimplifiedCalendarEvent { + return { + id: event.id, + status: event.status, + eventType: eventType ?? determineEventType(event), + summary: event.summary ?? null, + eventDescription: event.description ?? null, + location: event.location ?? null, + htmlLink: event.htmlLink ?? null, + start: event.start ?? null, + end: event.end ?? null, + created: event.created ?? null, + updated: event.updated ?? null, + attendees: event.attendees ?? null, + creator: event.creator ?? null, + organizer: event.organizer ?? null, + } +} + +async function processEvents( + events: CalendarEvent[], + calendarId: string, + eventTypeFilter: CalendarEventTypeFilter | undefined, + webhookData: PollWebhookContext['webhookData'], + workflowData: PollWebhookContext['workflowData'], + requestId: string, + logger: ReturnType +): Promise<{ processedCount: number; failedCount: number; latestUpdated: string | null }> { + let processedCount = 0 + let failedCount = 0 + let latestUpdated: string | null = null + + for (const event of events) { + // Track the latest `updated` timestamp for clock-skew-free state tracking + if (event.updated) { + if (!latestUpdated || event.updated > latestUpdated) { + latestUpdated = event.updated + } + } + + // Client-side event type filter — skip before idempotency so filtered events aren't cached + const computedEventType = determineEventType(event) + if (eventTypeFilter && computedEventType !== eventTypeFilter) { + continue + } + + try { + // Idempotency key includes `updated` so re-edits of the same event re-trigger + const idempotencyKey = `${webhookData.id}:${event.id}:${event.updated || event.created || ''}` + + await pollingIdempotency.executeWithIdempotency( + 'google-calendar', + idempotencyKey, + async () => { + const simplified = simplifyEvent(event, computedEventType) + + const payload: GoogleCalendarWebhookPayload = { + event: simplified, + calendarId, + timestamp: new Date().toISOString(), + } + + const result = await processPolledWebhookEvent( + webhookData, + workflowData, + payload, + requestId + ) + + if (!result.success) { + logger.error( + `[${requestId}] Failed to process webhook for event ${event.id}:`, + result.statusCode, + result.error + ) + throw new Error(`Webhook processing failed (${result.statusCode}): ${result.error}`) + } + + return { eventId: event.id, processed: true } + } + ) + + logger.info( + `[${requestId}] Successfully processed event ${event.id} for webhook ${webhookData.id}` + ) + processedCount++ + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Error processing event ${event.id}:`, errorMessage) + failedCount++ + } + } + + return { processedCount, failedCount, latestUpdated } +} diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts new file mode 100644 index 00000000000..4633c4c80ce --- /dev/null +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -0,0 +1,386 @@ +import { pollingIdempotency } from '@/lib/core/idempotency/service' +import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' +import { + markWebhookFailed, + markWebhookSuccess, + resolveOAuthCredential, + updateWebhookProviderConfig, +} from '@/lib/webhooks/polling/utils' +import { processPolledWebhookEvent } from '@/lib/webhooks/processor' + +const MAX_FILES_PER_POLL = 50 +const MAX_KNOWN_FILE_IDS = 1000 +const MAX_PAGES = 10 +const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3' + +type DriveEventTypeFilter = '' | 'created' | 'modified' | 'deleted' | 'created_or_modified' + +interface GoogleDriveWebhookConfig { + folderId?: string + mimeTypeFilter?: string + includeSharedDrives?: boolean + eventTypeFilter?: DriveEventTypeFilter + maxFilesPerPoll?: number + pageToken?: string + knownFileIds?: string[] +} + +interface DriveChangeEntry { + kind: string + type: string + changeType?: string + time: string + removed: boolean + fileId: string + file?: DriveFileMetadata +} + +interface DriveFileMetadata { + id: string + name: string + mimeType: string + modifiedTime: string + createdTime?: string + size?: string + webViewLink?: string + parents?: string[] + lastModifyingUser?: { displayName?: string; emailAddress?: string } + shared?: boolean + starred?: boolean + trashed?: boolean +} + +export interface GoogleDriveWebhookPayload { + file: DriveFileMetadata | { id: string } + eventType: 'created' | 'modified' | 'deleted' + timestamp: string +} + +const FILE_FIELDS = [ + 'id', + 'name', + 'mimeType', + 'modifiedTime', + 'createdTime', + 'size', + 'webViewLink', + 'parents', + 'lastModifyingUser', + 'shared', + 'starred', + 'trashed', +].join(',') + +export const googleDrivePollingHandler: PollingProviderHandler = { + provider: 'google-drive', + label: 'Google Drive', + + async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { + const { webhookData, workflowData, requestId, logger } = ctx + const webhookId = webhookData.id + + try { + const accessToken = await resolveOAuthCredential( + webhookData, + 'google-drive', + requestId, + logger + ) + + const config = webhookData.providerConfig as unknown as GoogleDriveWebhookConfig + const now = new Date() + + // First poll: get startPageToken and seed state + if (!config.pageToken) { + const startPageToken = await getStartPageToken(accessToken, config, requestId, logger) + + await updateWebhookProviderConfig( + webhookId, + { pageToken: startPageToken, knownFileIds: [] }, + logger + ) + await markWebhookSuccess(webhookId, logger) + logger.info( + `[${requestId}] First poll for webhook ${webhookId}, seeded pageToken: ${startPageToken}` + ) + return 'success' + } + + // Fetch changes since last pageToken + const { changes, newStartPageToken } = await fetchChanges( + accessToken, + config, + requestId, + logger + ) + + if (!changes.length) { + await updateWebhookProviderConfig(webhookId, { pageToken: newStartPageToken }, logger) + await markWebhookSuccess(webhookId, logger) + logger.info(`[${requestId}] No changes found for webhook ${webhookId}`) + return 'success' + } + + // Filter changes client-side (folder, MIME type, trashed) + const filteredChanges = filterChanges(changes, config) + + if (!filteredChanges.length) { + await updateWebhookProviderConfig(webhookId, { pageToken: newStartPageToken }, logger) + await markWebhookSuccess(webhookId, logger) + logger.info( + `[${requestId}] ${changes.length} changes found but none match filters for webhook ${webhookId}` + ) + return 'success' + } + + logger.info( + `[${requestId}] Found ${filteredChanges.length} matching changes for webhook ${webhookId}` + ) + + const { processedCount, failedCount, newKnownFileIds } = await processChanges( + filteredChanges, + config, + webhookData, + workflowData, + requestId, + logger + ) + + // Update state: new pageToken and rolling knownFileIds + const existingKnownIds = config.knownFileIds || [] + const mergedKnownIds = [...new Set([...newKnownFileIds, ...existingKnownIds])].slice( + 0, + MAX_KNOWN_FILE_IDS + ) + + await updateWebhookProviderConfig( + webhookId, + { pageToken: newStartPageToken, knownFileIds: mergedKnownIds }, + logger + ) + + if (failedCount > 0 && processedCount === 0) { + await markWebhookFailed(webhookId, logger) + logger.warn( + `[${requestId}] All ${failedCount} changes failed to process for webhook ${webhookId}` + ) + return 'failure' + } + + await markWebhookSuccess(webhookId, logger) + logger.info( + `[${requestId}] Successfully processed ${processedCount} changes for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + ) + return 'success' + } catch (error) { + logger.error(`[${requestId}] Error processing Google Drive webhook ${webhookId}:`, error) + await markWebhookFailed(webhookId, logger) + return 'failure' + } + }, +} + +async function getStartPageToken( + accessToken: string, + config: GoogleDriveWebhookConfig, + requestId: string, + logger: ReturnType +): Promise { + const params = new URLSearchParams() + if (config.includeSharedDrives) { + params.set('supportsAllDrives', 'true') + } + + const url = `${DRIVE_API_BASE}/changes/startPageToken?${params.toString()}` + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + `Failed to get startPageToken: ${response.status} - ${JSON.stringify(errorData)}` + ) + } + + const data = await response.json() + return data.startPageToken as string +} + +async function fetchChanges( + accessToken: string, + config: GoogleDriveWebhookConfig, + requestId: string, + logger: ReturnType +): Promise<{ changes: DriveChangeEntry[]; newStartPageToken: string }> { + const allChanges: DriveChangeEntry[] = [] + let currentPageToken = config.pageToken! + let newStartPageToken = currentPageToken + const maxFiles = config.maxFilesPerPoll || MAX_FILES_PER_POLL + let pages = 0 + + // eslint-disable-next-line no-constant-condition + while (true) { + pages++ + const params = new URLSearchParams({ + pageToken: currentPageToken, + pageSize: String(Math.min(maxFiles, 100)), + fields: `nextPageToken,newStartPageToken,changes(kind,type,time,removed,fileId,file(${FILE_FIELDS}))`, + restrictToMyDrive: config.includeSharedDrives ? 'false' : 'true', + }) + + if (config.includeSharedDrives) { + params.set('supportsAllDrives', 'true') + params.set('includeItemsFromAllDrives', 'true') + } + + const url = `${DRIVE_API_BASE}/changes?${params.toString()}` + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(`Failed to fetch changes: ${response.status} - ${JSON.stringify(errorData)}`) + } + + const data = await response.json() + const changes = (data.changes || []) as DriveChangeEntry[] + allChanges.push(...changes) + + if (data.newStartPageToken) { + newStartPageToken = data.newStartPageToken as string + } + + // Stop if no more pages or we have enough changes. + // Always use newStartPageToken (not nextPageToken) as the resume point — + // nextPageToken paginates the current query but newStartPageToken is the + // correct cursor for the next poll cycle. + if (!data.nextPageToken || allChanges.length >= maxFiles || pages >= MAX_PAGES) { + break + } + + currentPageToken = data.nextPageToken as string + } + + return { changes: allChanges.slice(0, maxFiles), newStartPageToken } +} + +function filterChanges( + changes: DriveChangeEntry[], + config: GoogleDriveWebhookConfig +): DriveChangeEntry[] { + return changes.filter((change) => { + // Always include removals (deletions) + if (change.removed) return true + + const file = change.file + if (!file) return false + + // Exclude trashed files + if (file.trashed) return false + + // Folder filter: check if file is in the specified folder + if (config.folderId) { + if (!file.parents || !file.parents.includes(config.folderId)) { + return false + } + } + + // MIME type filter + if (config.mimeTypeFilter) { + // Support prefix matching (e.g., "image/" matches "image/png", "image/jpeg") + if (config.mimeTypeFilter.endsWith('/')) { + if (!file.mimeType.startsWith(config.mimeTypeFilter)) { + return false + } + } else if (file.mimeType !== config.mimeTypeFilter) { + return false + } + } + + return true + }) +} + +async function processChanges( + changes: DriveChangeEntry[], + config: GoogleDriveWebhookConfig, + webhookData: PollWebhookContext['webhookData'], + workflowData: PollWebhookContext['workflowData'], + requestId: string, + logger: ReturnType +): Promise<{ processedCount: number; failedCount: number; newKnownFileIds: string[] }> { + let processedCount = 0 + let failedCount = 0 + const newKnownFileIds: string[] = [] + const knownFileIdsSet = new Set(config.knownFileIds || []) + + for (const change of changes) { + // Determine event type before idempotency to avoid caching filter decisions + let eventType: 'created' | 'modified' | 'deleted' + if (change.removed) { + eventType = 'deleted' + } else if (!knownFileIdsSet.has(change.fileId)) { + eventType = 'created' + } else { + eventType = 'modified' + } + + // Track file as known regardless of filter (for future create/modify distinction) + if (!change.removed) { + newKnownFileIds.push(change.fileId) + } + + // Client-side event type filter — skip before idempotency so filtered events aren't cached + const filter = config.eventTypeFilter + if (filter) { + const skip = filter === 'created_or_modified' ? eventType === 'deleted' : eventType !== filter + if (skip) continue + } + + try { + const idempotencyKey = `${webhookData.id}:${change.fileId}:${change.time}` + + await pollingIdempotency.executeWithIdempotency('google-drive', idempotencyKey, async () => { + const payload: GoogleDriveWebhookPayload = { + file: change.file || { id: change.fileId }, + eventType, + timestamp: new Date().toISOString(), + } + + const result = await processPolledWebhookEvent( + webhookData, + workflowData, + payload, + requestId + ) + + if (!result.success) { + logger.error( + `[${requestId}] Failed to process webhook for file ${change.fileId}:`, + result.statusCode, + result.error + ) + throw new Error(`Webhook processing failed (${result.statusCode}): ${result.error}`) + } + + return { fileId: change.fileId, processed: true } + }) + + logger.info( + `[${requestId}] Successfully processed change for file ${change.fileId} for webhook ${webhookData.id}` + ) + processedCount++ + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error( + `[${requestId}] Error processing change for file ${change.fileId}:`, + errorMessage + ) + failedCount++ + } + } + + return { processedCount, failedCount, newKnownFileIds } +} diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts new file mode 100644 index 00000000000..5f1eb530f55 --- /dev/null +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -0,0 +1,435 @@ +import { pollingIdempotency } from '@/lib/core/idempotency/service' +import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' +import { + markWebhookFailed, + markWebhookSuccess, + resolveOAuthCredential, + updateWebhookProviderConfig, +} from '@/lib/webhooks/polling/utils' +import { processPolledWebhookEvent } from '@/lib/webhooks/processor' + +const MAX_ROWS_PER_POLL = 100 + +type ValueRenderOption = 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA' +type DateTimeRenderOption = 'SERIAL_NUMBER' | 'FORMATTED_STRING' + +interface GoogleSheetsWebhookConfig { + spreadsheetId: string + sheetName: string + includeHeaders: boolean + valueRenderOption?: ValueRenderOption + dateTimeRenderOption?: DateTimeRenderOption + lastKnownRowCount?: number + lastModifiedTime?: string + lastCheckedTimestamp?: string + maxRowsPerPoll?: number +} + +export interface GoogleSheetsWebhookPayload { + row: Record | null + rawRow: string[] + headers: string[] + rowNumber: number + spreadsheetId: string + sheetName: string + timestamp: string +} + +export const googleSheetsPollingHandler: PollingProviderHandler = { + provider: 'google-sheets', + label: 'Google Sheets', + + async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { + const { webhookData, workflowData, requestId, logger } = ctx + const webhookId = webhookData.id + + try { + const accessToken = await resolveOAuthCredential( + webhookData, + 'google-sheets', + requestId, + logger + ) + + const config = webhookData.providerConfig as unknown as GoogleSheetsWebhookConfig + const now = new Date() + + if (!config?.spreadsheetId || !config?.sheetName) { + logger.error(`[${requestId}] Missing spreadsheetId or sheetName for webhook ${webhookId}`) + await markWebhookFailed(webhookId, logger) + return 'failure' + } + + // Pre-check: use Drive API to see if the file was modified since last poll + const skipPoll = await isDriveFileUnchanged( + accessToken, + config.spreadsheetId, + config.lastModifiedTime, + requestId, + logger + ) + + if (skipPoll) { + await updateWebhookProviderConfig( + webhookId, + { lastCheckedTimestamp: now.toISOString() }, + logger + ) + await markWebhookSuccess(webhookId, logger) + logger.info(`[${requestId}] Sheet not modified since last poll for webhook ${webhookId}`) + return 'success' + } + + // Get current Drive modifiedTime for state update + const currentModifiedTime = await getDriveFileModifiedTime( + accessToken, + config.spreadsheetId, + logger + ) + + // Fetch current row count via column A + const currentRowCount = await getDataRowCount( + accessToken, + config.spreadsheetId, + config.sheetName, + requestId, + logger + ) + + // First poll: seed state, emit nothing + if (config.lastKnownRowCount === undefined) { + await updateWebhookProviderConfig( + webhookId, + { + lastKnownRowCount: currentRowCount, + lastModifiedTime: currentModifiedTime, + lastCheckedTimestamp: now.toISOString(), + }, + logger + ) + await markWebhookSuccess(webhookId, logger) + logger.info( + `[${requestId}] First poll for webhook ${webhookId}, seeded row count: ${currentRowCount}` + ) + return 'success' + } + + // Rows deleted or unchanged + if (currentRowCount <= config.lastKnownRowCount) { + if (currentRowCount < config.lastKnownRowCount) { + logger.warn( + `[${requestId}] Row count decreased from ${config.lastKnownRowCount} to ${currentRowCount} for webhook ${webhookId}` + ) + } + await updateWebhookProviderConfig( + webhookId, + { + lastKnownRowCount: currentRowCount, + lastModifiedTime: currentModifiedTime, + lastCheckedTimestamp: now.toISOString(), + }, + logger + ) + await markWebhookSuccess(webhookId, logger) + logger.info(`[${requestId}] No new rows for webhook ${webhookId}`) + return 'success' + } + + // New rows detected + const newRowCount = currentRowCount - config.lastKnownRowCount + const maxRows = config.maxRowsPerPoll || MAX_ROWS_PER_POLL + const rowsToFetch = Math.min(newRowCount, maxRows) + const startRow = config.lastKnownRowCount + 1 + const endRow = config.lastKnownRowCount + rowsToFetch + + logger.info( + `[${requestId}] Found ${newRowCount} new rows for webhook ${webhookId}, processing rows ${startRow}-${endRow}` + ) + + // Resolve render options + const valueRender = config.valueRenderOption || 'FORMATTED_VALUE' + const dateTimeRender = config.dateTimeRenderOption || 'SERIAL_NUMBER' + + // Fetch headers (row 1) if includeHeaders is enabled + let headers: string[] = [] + if (config.includeHeaders !== false) { + headers = await fetchHeaderRow( + accessToken, + config.spreadsheetId, + config.sheetName, + valueRender, + dateTimeRender, + requestId, + logger + ) + } + + // Fetch new rows — startRow/endRow are already 1-indexed sheet row numbers + // because lastKnownRowCount includes the header row + const newRows = await fetchRowRange( + accessToken, + config.spreadsheetId, + config.sheetName, + startRow, + endRow, + valueRender, + dateTimeRender, + requestId, + logger + ) + + const { processedCount, failedCount } = await processRows( + newRows, + headers, + startRow, + config, + webhookData, + workflowData, + requestId, + logger + ) + + // Update state: advance row count by the number we fetched (not total new rows) + // so remaining rows are picked up in the next poll. + // When batching (more rows than maxRowsPerPoll), keep the old lastModifiedTime + // so the Drive pre-check doesn't skip remaining rows on the next poll. + const newLastKnownRowCount = config.lastKnownRowCount + rowsToFetch + const hasRemainingRows = rowsToFetch < newRowCount + await updateWebhookProviderConfig( + webhookId, + { + lastKnownRowCount: newLastKnownRowCount, + lastModifiedTime: hasRemainingRows ? config.lastModifiedTime : currentModifiedTime, + lastCheckedTimestamp: now.toISOString(), + }, + logger + ) + + if (failedCount > 0 && processedCount === 0) { + await markWebhookFailed(webhookId, logger) + logger.warn( + `[${requestId}] All ${failedCount} rows failed to process for webhook ${webhookId}` + ) + return 'failure' + } + + await markWebhookSuccess(webhookId, logger) + logger.info( + `[${requestId}] Successfully processed ${processedCount} rows for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}` + ) + return 'success' + } catch (error) { + logger.error(`[${requestId}] Error processing Google Sheets webhook ${webhookId}:`, error) + await markWebhookFailed(webhookId, logger) + return 'failure' + } + }, +} + +async function isDriveFileUnchanged( + accessToken: string, + spreadsheetId: string, + lastModifiedTime: string | undefined, + requestId: string, + logger: ReturnType +): Promise { + if (!lastModifiedTime) return false + + try { + const currentModifiedTime = await getDriveFileModifiedTime(accessToken, spreadsheetId, logger) + return currentModifiedTime === lastModifiedTime + } catch (error) { + // If Drive check fails, proceed with Sheets API (don't skip) + logger.warn(`[${requestId}] Drive modifiedTime check failed, proceeding with Sheets API`) + return false + } +} + +async function getDriveFileModifiedTime( + accessToken: string, + fileId: string, + logger: ReturnType +): Promise { + try { + const response = await fetch( + `https://www.googleapis.com/drive/v3/files/${fileId}?fields=modifiedTime`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + if (!response.ok) return undefined + const data = await response.json() + return data.modifiedTime as string | undefined + } catch { + return undefined + } +} + +async function getDataRowCount( + accessToken: string, + spreadsheetId: string, + sheetName: string, + requestId: string, + logger: ReturnType +): Promise { + const encodedSheet = encodeURIComponent(sheetName) + const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedSheet}!A:A?majorDimension=COLUMNS&fields=values` + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + `Failed to fetch row count: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}` + ) + } + + const data = await response.json() + // values is [[cell1, cell2, ...]] when majorDimension=COLUMNS + const columnValues = data.values?.[0] as string[] | undefined + return columnValues?.length ?? 0 +} + +async function fetchHeaderRow( + accessToken: string, + spreadsheetId: string, + sheetName: string, + valueRenderOption: ValueRenderOption, + dateTimeRenderOption: DateTimeRenderOption, + requestId: string, + logger: ReturnType +): Promise { + const encodedSheet = encodeURIComponent(sheetName) + const params = new URLSearchParams({ + fields: 'values', + valueRenderOption, + dateTimeRenderOption, + }) + const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedSheet}!1:1?${params.toString()}` + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + logger.warn(`[${requestId}] Failed to fetch header row, proceeding without headers`) + return [] + } + + const data = await response.json() + return (data.values?.[0] as string[]) ?? [] +} + +async function fetchRowRange( + accessToken: string, + spreadsheetId: string, + sheetName: string, + startRow: number, + endRow: number, + valueRenderOption: ValueRenderOption, + dateTimeRenderOption: DateTimeRenderOption, + requestId: string, + logger: ReturnType +): Promise { + const encodedSheet = encodeURIComponent(sheetName) + const params = new URLSearchParams({ + fields: 'values', + valueRenderOption, + dateTimeRenderOption, + }) + const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodedSheet}!${startRow}:${endRow}?${params.toString()}` + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error( + `Failed to fetch rows ${startRow}-${endRow}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}` + ) + } + + const data = await response.json() + return (data.values as string[][]) ?? [] +} + +async function processRows( + rows: string[][], + headers: string[], + startRowIndex: number, + config: GoogleSheetsWebhookConfig, + webhookData: PollWebhookContext['webhookData'], + workflowData: PollWebhookContext['workflowData'], + requestId: string, + logger: ReturnType +): Promise<{ processedCount: number; failedCount: number }> { + let processedCount = 0 + let failedCount = 0 + + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + const rowNumber = startRowIndex + i // startRowIndex is already the 1-indexed sheet row + + try { + await pollingIdempotency.executeWithIdempotency( + 'google-sheets', + `${webhookData.id}:${config.spreadsheetId}:${config.sheetName}:row${rowNumber}:${row.join('|')}`, + async () => { + // Map row values to headers + let mappedRow: Record | null = null + if (headers.length > 0 && config.includeHeaders !== false) { + mappedRow = {} + for (let j = 0; j < headers.length; j++) { + const header = headers[j] || `Column ${j + 1}` + mappedRow[header] = row[j] ?? '' + } + // Include any extra columns beyond headers + for (let j = headers.length; j < row.length; j++) { + mappedRow[`Column ${j + 1}`] = row[j] ?? '' + } + } + + const payload: GoogleSheetsWebhookPayload = { + row: mappedRow, + rawRow: row, + headers, + rowNumber, + spreadsheetId: config.spreadsheetId, + sheetName: config.sheetName, + timestamp: new Date().toISOString(), + } + + const result = await processPolledWebhookEvent( + webhookData, + workflowData, + payload, + requestId + ) + + if (!result.success) { + logger.error( + `[${requestId}] Failed to process webhook for row ${rowNumber}:`, + result.statusCode, + result.error + ) + throw new Error(`Webhook processing failed (${result.statusCode}): ${result.error}`) + } + + return { rowNumber, processed: true } + } + ) + + logger.info( + `[${requestId}] Successfully processed row ${rowNumber} for webhook ${webhookData.id}` + ) + processedCount++ + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Error processing row ${rowNumber}:`, errorMessage) + failedCount++ + } + } + + return { processedCount, failedCount } +} diff --git a/apps/sim/lib/webhooks/polling/registry.ts b/apps/sim/lib/webhooks/polling/registry.ts index fe2db69ed4f..a0b81d25a1c 100644 --- a/apps/sim/lib/webhooks/polling/registry.ts +++ b/apps/sim/lib/webhooks/polling/registry.ts @@ -1,4 +1,7 @@ import { gmailPollingHandler } from '@/lib/webhooks/polling/gmail' +import { googleCalendarPollingHandler } from '@/lib/webhooks/polling/google-calendar' +import { googleDrivePollingHandler } from '@/lib/webhooks/polling/google-drive' +import { googleSheetsPollingHandler } from '@/lib/webhooks/polling/google-sheets' import { imapPollingHandler } from '@/lib/webhooks/polling/imap' import { outlookPollingHandler } from '@/lib/webhooks/polling/outlook' import { rssPollingHandler } from '@/lib/webhooks/polling/rss' @@ -6,6 +9,9 @@ import type { PollingProviderHandler } from '@/lib/webhooks/polling/types' const POLLING_HANDLERS: Record = { gmail: gmailPollingHandler, + 'google-calendar': googleCalendarPollingHandler, + 'google-drive': googleDrivePollingHandler, + 'google-sheets': googleSheetsPollingHandler, imap: imapPollingHandler, outlook: outlookPollingHandler, rss: rssPollingHandler, diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index feff397f4cf..800ee7e7094 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -42,7 +42,15 @@ export const MAX_CONSECUTIVE_FAILURES = 100 * Used to route execution: polling providers use the full job queue * (Trigger.dev), non-polling providers execute inline. */ -export const POLLING_PROVIDERS = new Set(['gmail', 'outlook', 'rss', 'imap']) +export const POLLING_PROVIDERS = new Set([ + 'gmail', + 'google-calendar', + 'google-drive', + 'google-sheets', + 'imap', + 'outlook', + 'rss', +]) export function isPollingWebhookProvider(provider: string): boolean { return POLLING_PROVIDERS.has(provider) diff --git a/apps/sim/triggers/google-calendar/index.ts b/apps/sim/triggers/google-calendar/index.ts new file mode 100644 index 00000000000..ac7e7a7bdb5 --- /dev/null +++ b/apps/sim/triggers/google-calendar/index.ts @@ -0,0 +1 @@ +export { googleCalendarPollingTrigger } from './poller' diff --git a/apps/sim/triggers/google-calendar/poller.ts b/apps/sim/triggers/google-calendar/poller.ts new file mode 100644 index 00000000000..15dcf4bc89b --- /dev/null +++ b/apps/sim/triggers/google-calendar/poller.ts @@ -0,0 +1,200 @@ +import { createLogger } from '@sim/logger' +import { GoogleCalendarIcon } from '@/components/icons' +import { isCredentialSetValue } from '@/executor/constants' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import type { TriggerConfig } from '@/triggers/types' + +const logger = createLogger('GoogleCalendarPollingTrigger') + +const DEFAULT_CALENDARS = [{ id: 'primary', label: 'Primary Calendar' }] + +export const googleCalendarPollingTrigger: TriggerConfig = { + id: 'google_calendar_poller', + name: 'Google Calendar Event Trigger', + provider: 'google-calendar', + description: 'Triggers when events are created, updated, or cancelled in Google Calendar', + version: '1.0.0', + icon: GoogleCalendarIcon, + polling: true, + + subBlocks: [ + { + id: 'triggerCredentials', + title: 'Credentials', + type: 'oauth-input', + description: 'Connect your Google account to access Google Calendar.', + serviceId: 'google-calendar', + requiredScopes: [], + required: true, + mode: 'trigger', + supportsCredentialSets: true, + }, + { + id: 'calendarId', + title: 'Calendar', + type: 'dropdown', + placeholder: 'Select a calendar', + description: 'The calendar to monitor for event changes.', + required: false, + defaultValue: 'primary', + options: [], + fetchOptions: async (blockId: string) => { + const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + | string + | null + + if (!credentialId) { + throw new Error('No Google Calendar credential selected') + } + + // Credential sets can't fetch user-specific calendars + if (isCredentialSetValue(credentialId)) { + return DEFAULT_CALENDARS + } + + try { + const response = await fetch( + `/api/tools/google_calendar/calendars?credentialId=${credentialId}` + ) + if (!response.ok) { + throw new Error('Failed to fetch calendars') + } + const data = await response.json() + if (data.calendars && Array.isArray(data.calendars)) { + return data.calendars.map( + (calendar: { id: string; summary: string; primary: boolean }) => ({ + id: calendar.id, + label: calendar.primary ? `${calendar.summary} (Primary)` : calendar.summary, + }) + ) + } + return DEFAULT_CALENDARS + } catch (error) { + logger.error('Error fetching calendars:', error) + throw error + } + }, + dependsOn: ['triggerCredentials'], + mode: 'trigger', + }, + { + id: 'eventTypeFilter', + title: 'Event Type', + type: 'dropdown', + options: [ + { id: '', label: 'All Events' }, + { id: 'created', label: 'Created' }, + { id: 'updated', label: 'Updated' }, + { id: 'cancelled', label: 'Cancelled' }, + ], + defaultValue: '', + description: 'Only trigger for specific event types. Defaults to all events.', + required: false, + mode: 'trigger', + }, + { + id: 'searchTerm', + title: 'Search Term', + type: 'short-input', + placeholder: 'e.g., team meeting, standup', + description: + 'Optional: Filter events by text match across title, description, location, and attendees.', + required: false, + mode: 'trigger', + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'google_calendar_poller', + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: [ + 'Connect your Google account using OAuth credentials', + 'Select the calendar to monitor (defaults to your primary calendar)', + 'The system will automatically detect new, updated, and cancelled events', + ] + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join(''), + mode: 'trigger', + }, + ], + + outputs: { + event: { + id: { + type: 'string', + description: 'Calendar event ID', + }, + status: { + type: 'string', + description: 'Event status (confirmed, tentative, cancelled)', + }, + eventType: { + type: 'string', + description: 'Change type: "created", "updated", or "cancelled"', + }, + summary: { + type: 'string', + description: 'Event title', + }, + eventDescription: { + type: 'string', + description: 'Event description', + }, + location: { + type: 'string', + description: 'Event location', + }, + htmlLink: { + type: 'string', + description: 'Link to event in Google Calendar', + }, + start: { + type: 'json', + description: 'Event start time', + }, + end: { + type: 'json', + description: 'Event end time', + }, + created: { + type: 'string', + description: 'Event creation time', + }, + updated: { + type: 'string', + description: 'Event last updated time', + }, + attendees: { + type: 'json', + description: 'Event attendees', + }, + creator: { + type: 'json', + description: 'Event creator', + }, + organizer: { + type: 'json', + description: 'Event organizer', + }, + }, + calendarId: { + type: 'string', + description: 'Calendar ID', + }, + timestamp: { + type: 'string', + description: 'Event processing timestamp in ISO format', + }, + }, +} diff --git a/apps/sim/triggers/google-sheets/index.ts b/apps/sim/triggers/google-sheets/index.ts new file mode 100644 index 00000000000..3be8d3bc6f8 --- /dev/null +++ b/apps/sim/triggers/google-sheets/index.ts @@ -0,0 +1 @@ +export { googleSheetsPollingTrigger } from './poller' diff --git a/apps/sim/triggers/google-sheets/poller.ts b/apps/sim/triggers/google-sheets/poller.ts new file mode 100644 index 00000000000..0655bc5ca0c --- /dev/null +++ b/apps/sim/triggers/google-sheets/poller.ts @@ -0,0 +1,185 @@ +import { createLogger } from '@sim/logger' +import { GoogleSheetsIcon } from '@/components/icons' +import { isCredentialSetValue } from '@/executor/constants' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import type { TriggerConfig } from '@/triggers/types' + +const logger = createLogger('GoogleSheetsPollingTrigger') + +export const googleSheetsPollingTrigger: TriggerConfig = { + id: 'google_sheets_poller', + name: 'Google Sheets New Row Trigger', + provider: 'google-sheets', + description: 'Triggers when new rows are added to a Google Sheet', + version: '1.0.0', + icon: GoogleSheetsIcon, + polling: true, + + subBlocks: [ + { + id: 'triggerCredentials', + title: 'Credentials', + type: 'oauth-input', + description: 'Connect your Google account to access Google Sheets.', + serviceId: 'google-sheets', + requiredScopes: [], + required: true, + mode: 'trigger', + supportsCredentialSets: true, + }, + { + id: 'spreadsheetId', + title: 'Spreadsheet ID', + type: 'short-input', + placeholder: 'e.g., 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms', + description: + 'The spreadsheet ID from the URL: docs.google.com/spreadsheets/d/{spreadsheetId}/edit', + required: true, + mode: 'trigger', + }, + { + id: 'sheetName', + title: 'Sheet Tab', + type: 'dropdown', + placeholder: 'Select a sheet tab', + description: 'The sheet tab to monitor for new rows.', + required: true, + options: [], + fetchOptions: async (blockId: string) => { + const subBlockStore = useSubBlockStore.getState() + const credentialId = subBlockStore.getValue(blockId, 'triggerCredentials') as string | null + const spreadsheetId = subBlockStore.getValue(blockId, 'spreadsheetId') as string | null + + if (!credentialId) { + throw new Error('No Google Sheets credential selected') + } + if (!spreadsheetId) { + throw new Error('No spreadsheet ID provided') + } + + // Credential sets can't fetch user-specific data; return empty to allow manual entry + if (isCredentialSetValue(credentialId)) { + return [] + } + + try { + const response = await fetch( + `/api/tools/google_sheets/sheets?credentialId=${credentialId}&spreadsheetId=${spreadsheetId}` + ) + if (!response.ok) { + throw new Error('Failed to fetch sheet tabs') + } + const data = await response.json() + if (data.sheets && Array.isArray(data.sheets)) { + return data.sheets.map((sheet: { id: string; name: string }) => ({ + id: sheet.id, + label: sheet.name, + })) + } + return [] + } catch (error) { + logger.error('Error fetching sheet tabs:', error) + throw error + } + }, + dependsOn: ['triggerCredentials', 'spreadsheetId'], + mode: 'trigger', + }, + { + id: 'includeHeaders', + title: 'Map Row Values to Headers', + type: 'switch', + defaultValue: true, + description: + 'When enabled, each row is returned as a key-value object mapped to column headers from row 1.', + required: false, + mode: 'trigger', + }, + { + id: 'valueRenderOption', + title: 'Value Render', + type: 'dropdown', + options: [ + { id: 'FORMATTED_VALUE', label: 'Formatted Value' }, + { id: 'UNFORMATTED_VALUE', label: 'Unformatted Value' }, + { id: 'FORMULA', label: 'Formula' }, + ], + defaultValue: 'FORMATTED_VALUE', + description: + 'How values are rendered. Formatted returns display strings, Unformatted returns raw numbers/booleans, Formula returns the formula text.', + required: false, + mode: 'trigger', + }, + { + id: 'dateTimeRenderOption', + title: 'Date/Time Render', + type: 'dropdown', + options: [ + { id: 'SERIAL_NUMBER', label: 'Serial Number' }, + { id: 'FORMATTED_STRING', label: 'Formatted String' }, + ], + defaultValue: 'SERIAL_NUMBER', + description: + 'How dates and times are rendered. Only applies when Value Render is not "Formatted Value".', + required: false, + mode: 'trigger', + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'google_sheets_poller', + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: [ + 'Connect your Google account using OAuth credentials', + 'Enter the Spreadsheet ID from your Google Sheets URL', + 'Select the sheet tab to monitor', + 'The system will automatically detect new rows appended to the sheet', + ] + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join(''), + mode: 'trigger', + }, + ], + + outputs: { + row: { + type: 'json', + description: 'Row data mapped to column headers (when header mapping is enabled)', + }, + rawRow: { + type: 'json', + description: 'Raw row values as an array', + }, + headers: { + type: 'json', + description: 'Column headers from row 1', + }, + rowNumber: { + type: 'number', + description: 'The 1-based row number of the new row', + }, + spreadsheetId: { + type: 'string', + description: 'The spreadsheet ID', + }, + sheetName: { + type: 'string', + description: 'The sheet tab name', + }, + timestamp: { + type: 'string', + description: 'Event timestamp in ISO format', + }, + }, +} diff --git a/apps/sim/triggers/google_drive/index.ts b/apps/sim/triggers/google_drive/index.ts new file mode 100644 index 00000000000..b93f7834101 --- /dev/null +++ b/apps/sim/triggers/google_drive/index.ts @@ -0,0 +1 @@ +export { googleDrivePollingTrigger } from './poller' diff --git a/apps/sim/triggers/google_drive/poller.ts b/apps/sim/triggers/google_drive/poller.ts new file mode 100644 index 00000000000..3169a794df2 --- /dev/null +++ b/apps/sim/triggers/google_drive/poller.ts @@ -0,0 +1,167 @@ +import { createLogger } from '@sim/logger' +import { GoogleDriveIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' + +const logger = createLogger('GoogleDrivePollingTrigger') + +const MIME_TYPE_OPTIONS = [ + { id: '', label: 'All Files' }, + { id: 'application/vnd.google-apps.document', label: 'Google Docs' }, + { id: 'application/vnd.google-apps.spreadsheet', label: 'Google Sheets' }, + { id: 'application/vnd.google-apps.presentation', label: 'Google Slides' }, + { id: 'application/pdf', label: 'PDFs' }, + { id: 'image/', label: 'Images' }, + { id: 'application/vnd.google-apps.folder', label: 'Folders' }, +] as const + +export const googleDrivePollingTrigger: TriggerConfig = { + id: 'google_drive_poller', + name: 'Google Drive File Trigger', + provider: 'google-drive', + description: 'Triggers when files are created, modified, or deleted in Google Drive', + version: '1.0.0', + icon: GoogleDriveIcon, + polling: true, + + subBlocks: [ + { + id: 'triggerCredentials', + title: 'Credentials', + type: 'oauth-input', + description: 'Connect your Google account to access Google Drive.', + serviceId: 'google-drive', + requiredScopes: [], + required: true, + mode: 'trigger', + supportsCredentialSets: true, + }, + { + id: 'folderId', + title: 'Folder ID', + type: 'short-input', + placeholder: 'Leave empty to monitor entire Drive', + description: + 'Optional: The folder ID from the Google Drive URL to monitor. Leave empty to monitor all files.', + required: false, + mode: 'trigger', + }, + { + id: 'mimeTypeFilter', + title: 'File Type Filter', + type: 'dropdown', + options: [...MIME_TYPE_OPTIONS], + defaultValue: '', + description: 'Optional: Only trigger for specific file types.', + required: false, + mode: 'trigger', + }, + { + id: 'eventTypeFilter', + title: 'Event Type', + type: 'dropdown', + options: [ + { id: '', label: 'All Changes' }, + { id: 'created', label: 'File Created' }, + { id: 'modified', label: 'File Modified' }, + { id: 'deleted', label: 'File Deleted' }, + { id: 'created_or_modified', label: 'Created or Modified' }, + ], + defaultValue: '', + description: 'Only trigger for specific change types. Defaults to all changes.', + required: false, + mode: 'trigger', + }, + { + id: 'includeSharedDrives', + title: 'Include Shared Drives', + type: 'switch', + defaultValue: false, + description: 'Include files from shared (team) drives.', + required: false, + mode: 'trigger', + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'google_drive_poller', + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: [ + 'Connect your Google account using OAuth credentials', + 'Optionally specify a folder ID to monitor a specific folder', + 'Optionally filter by file type', + 'The system will automatically detect new, modified, and deleted files', + ] + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join(''), + mode: 'trigger', + }, + ], + + outputs: { + file: { + id: { + type: 'string', + description: 'Google Drive file ID', + }, + name: { + type: 'string', + description: 'File name', + }, + mimeType: { + type: 'string', + description: 'File MIME type', + }, + modifiedTime: { + type: 'string', + description: 'Last modified time (ISO)', + }, + createdTime: { + type: 'string', + description: 'File creation time (ISO)', + }, + size: { + type: 'string', + description: 'File size in bytes', + }, + webViewLink: { + type: 'string', + description: 'URL to view file in browser', + }, + parents: { + type: 'json', + description: 'Parent folder IDs', + }, + lastModifyingUser: { + type: 'json', + description: 'User who last modified the file', + }, + shared: { + type: 'boolean', + description: 'Whether file is shared', + }, + starred: { + type: 'boolean', + description: 'Whether file is starred', + }, + }, + eventType: { + type: 'string', + description: 'Change type: "created", "modified", or "deleted"', + }, + timestamp: { + type: 'string', + description: 'Event timestamp in ISO format', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 1e7cf2b3c8b..6a6bf8d85bc 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -90,6 +90,9 @@ import { } from '@/triggers/github' import { gmailPollingTrigger } from '@/triggers/gmail' import { gongCallCompletedTrigger, gongWebhookTrigger } from '@/triggers/gong' +import { googleDrivePollingTrigger } from '@/triggers/google_drive' +import { googleCalendarPollingTrigger } from '@/triggers/google-calendar' +import { googleSheetsPollingTrigger } from '@/triggers/google-sheets' import { googleFormsWebhookTrigger } from '@/triggers/googleforms' import { grainHighlightCreatedTrigger, @@ -359,6 +362,9 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { fathom_new_meeting: fathomNewMeetingTrigger, fathom_webhook: fathomWebhookTrigger, gmail_poller: gmailPollingTrigger, + google_calendar_poller: googleCalendarPollingTrigger, + google_drive_poller: googleDrivePollingTrigger, + google_sheets_poller: googleSheetsPollingTrigger, gong_call_completed: gongCallCompletedTrigger, gong_webhook: gongWebhookTrigger, grain_webhook: grainWebhookTrigger, diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 73e0a0b017d..8d9d6906781 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -968,6 +968,33 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + googleSheetsWebhookPoll: + enabled: true + name: google-sheets-webhook-poll + schedule: "*/1 * * * *" + path: "/api/webhooks/poll/google-sheets" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + + googleDriveWebhookPoll: + enabled: true + name: google-drive-webhook-poll + schedule: "*/1 * * * *" + path: "/api/webhooks/poll/google-drive" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + + googleCalendarWebhookPoll: + enabled: true + name: google-calendar-webhook-poll + schedule: "*/1 * * * *" + path: "/api/webhooks/poll/google-calendar" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + renewSubscriptions: enabled: true name: renew-subscriptions From a89841392f39c068086d6d166f733f8699d1a0ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Apr 2026 14:14:03 -0700 Subject: [PATCH 02/19] fix(polling): address PR review feedback for Google polling triggers - Fix Drive cursor stall: use nextPageToken as resume point when breaking early from pagination instead of re-using the original token - Eliminate redundant Drive API call in Sheets poller by returning modifiedTime from the pre-check function - Add 403/429 rate-limit handling to Sheets API calls matching the Calendar handler pattern - Remove unused changeType field from DriveChangeEntry interface - Rename triggers/google_drive to triggers/google-drive for consistency Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/polling/google-drive.ts | 19 +++++--- .../sim/lib/webhooks/polling/google-sheets.ts | 46 +++++++++++++------ .../{google_drive => google-drive}/index.ts | 0 .../{google_drive => google-drive}/poller.ts | 0 apps/sim/triggers/registry.ts | 2 +- 5 files changed, 44 insertions(+), 23 deletions(-) rename apps/sim/triggers/{google_drive => google-drive}/index.ts (100%) rename apps/sim/triggers/{google_drive => google-drive}/poller.ts (100%) diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index 4633c4c80ce..b4ecc06941a 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -28,7 +28,6 @@ interface GoogleDriveWebhookConfig { interface DriveChangeEntry { kind: string type: string - changeType?: string time: string removed: boolean fileId: string @@ -215,7 +214,8 @@ async function fetchChanges( ): Promise<{ changes: DriveChangeEntry[]; newStartPageToken: string }> { const allChanges: DriveChangeEntry[] = [] let currentPageToken = config.pageToken! - let newStartPageToken = currentPageToken + let newStartPageToken: string | undefined + let lastNextPageToken: string | undefined const maxFiles = config.maxFilesPerPoll || MAX_FILES_PER_POLL let pages = 0 @@ -252,10 +252,10 @@ async function fetchChanges( newStartPageToken = data.newStartPageToken as string } - // Stop if no more pages or we have enough changes. - // Always use newStartPageToken (not nextPageToken) as the resume point — - // nextPageToken paginates the current query but newStartPageToken is the - // correct cursor for the next poll cycle. + if (data.nextPageToken) { + lastNextPageToken = data.nextPageToken as string + } + if (!data.nextPageToken || allChanges.length >= maxFiles || pages >= MAX_PAGES) { break } @@ -263,7 +263,12 @@ async function fetchChanges( currentPageToken = data.nextPageToken as string } - return { changes: allChanges.slice(0, maxFiles), newStartPageToken } + // If we exhausted all pages the API returns newStartPageToken on the final page. + // If we broke early, fall back to the last nextPageToken so we resume from where + // we stopped rather than re-fetching from the original cursor. + const resumeToken = newStartPageToken ?? lastNextPageToken ?? config.pageToken! + + return { changes: allChanges.slice(0, maxFiles), newStartPageToken: resumeToken } } function filterChanges( diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index 5f1eb530f55..be9d192b1e2 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -61,7 +61,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { } // Pre-check: use Drive API to see if the file was modified since last poll - const skipPoll = await isDriveFileUnchanged( + const { unchanged: skipPoll, currentModifiedTime } = await isDriveFileUnchanged( accessToken, config.spreadsheetId, config.lastModifiedTime, @@ -80,13 +80,6 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { return 'success' } - // Get current Drive modifiedTime for state update - const currentModifiedTime = await getDriveFileModifiedTime( - accessToken, - config.spreadsheetId, - logger - ) - // Fetch current row count via column A const currentRowCount = await getDataRowCount( accessToken, @@ -232,16 +225,16 @@ async function isDriveFileUnchanged( lastModifiedTime: string | undefined, requestId: string, logger: ReturnType -): Promise { - if (!lastModifiedTime) return false +): Promise<{ unchanged: boolean; currentModifiedTime?: string }> { + if (!lastModifiedTime) return { unchanged: false } try { const currentModifiedTime = await getDriveFileModifiedTime(accessToken, spreadsheetId, logger) - return currentModifiedTime === lastModifiedTime + return { unchanged: currentModifiedTime === lastModifiedTime, currentModifiedTime } } catch (error) { // If Drive check fails, proceed with Sheets API (don't skip) logger.warn(`[${requestId}] Drive modifiedTime check failed, proceeding with Sheets API`) - return false + return { unchanged: false } } } @@ -278,9 +271,17 @@ async function getDataRowCount( }) if (!response.ok) { + const status = response.status const errorData = await response.json().catch(() => ({})) + + if (status === 403 || status === 429) { + throw new Error( + `Sheets API rate limit (${status}) — skipping to retry next poll cycle: ${JSON.stringify(errorData)}` + ) + } + throw new Error( - `Failed to fetch row count: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}` + `Failed to fetch row count: ${status} ${response.statusText} - ${JSON.stringify(errorData)}` ) } @@ -312,7 +313,14 @@ async function fetchHeaderRow( }) if (!response.ok) { - logger.warn(`[${requestId}] Failed to fetch header row, proceeding without headers`) + const status = response.status + if (status === 403 || status === 429) { + logger.warn( + `[${requestId}] Sheets API rate limit (${status}) fetching header row, proceeding without headers` + ) + } else { + logger.warn(`[${requestId}] Failed to fetch header row, proceeding without headers`) + } return [] } @@ -344,9 +352,17 @@ async function fetchRowRange( }) if (!response.ok) { + const status = response.status const errorData = await response.json().catch(() => ({})) + + if (status === 403 || status === 429) { + throw new Error( + `Sheets API rate limit (${status}) — skipping to retry next poll cycle: ${JSON.stringify(errorData)}` + ) + } + throw new Error( - `Failed to fetch rows ${startRow}-${endRow}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}` + `Failed to fetch rows ${startRow}-${endRow}: ${status} ${response.statusText} - ${JSON.stringify(errorData)}` ) } diff --git a/apps/sim/triggers/google_drive/index.ts b/apps/sim/triggers/google-drive/index.ts similarity index 100% rename from apps/sim/triggers/google_drive/index.ts rename to apps/sim/triggers/google-drive/index.ts diff --git a/apps/sim/triggers/google_drive/poller.ts b/apps/sim/triggers/google-drive/poller.ts similarity index 100% rename from apps/sim/triggers/google_drive/poller.ts rename to apps/sim/triggers/google-drive/poller.ts diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 6a6bf8d85bc..636fcdb7d1f 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -90,7 +90,7 @@ import { } from '@/triggers/github' import { gmailPollingTrigger } from '@/triggers/gmail' import { gongCallCompletedTrigger, gongWebhookTrigger } from '@/triggers/gong' -import { googleDrivePollingTrigger } from '@/triggers/google_drive' +import { googleDrivePollingTrigger } from '@/triggers/google-drive' import { googleCalendarPollingTrigger } from '@/triggers/google-calendar' import { googleSheetsPollingTrigger } from '@/triggers/google-sheets' import { googleFormsWebhookTrigger } from '@/triggers/googleforms' From 43ce2cc5cd386206ea637a239e814aa42428fdb2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Apr 2026 14:18:44 -0700 Subject: [PATCH 03/19] fix(polling): fix Drive pre-check never activating in Sheets poller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isDriveFileUnchanged short-circuited when lastModifiedTime was undefined, never calling the Drive API — so currentModifiedTime was never populated, creating a permanent chicken-and-egg loop. Now always calls the Drive API and returns the modifiedTime regardless of whether there's a previous value to compare against. Co-Authored-By: Claude Opus 4.6 --- apps/sim/lib/webhooks/polling/google-sheets.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index be9d192b1e2..0afe61d9a55 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -226,13 +226,13 @@ async function isDriveFileUnchanged( requestId: string, logger: ReturnType ): Promise<{ unchanged: boolean; currentModifiedTime?: string }> { - if (!lastModifiedTime) return { unchanged: false } - try { const currentModifiedTime = await getDriveFileModifiedTime(accessToken, spreadsheetId, logger) + if (!lastModifiedTime || !currentModifiedTime) { + return { unchanged: false, currentModifiedTime } + } return { unchanged: currentModifiedTime === lastModifiedTime, currentModifiedTime } } catch (error) { - // If Drive check fails, proceed with Sheets API (don't skip) logger.warn(`[${requestId}] Drive modifiedTime check failed, proceeding with Sheets API`) return { unchanged: false } } From e563f6d51a801faff8a47e7c7b0a3e4ddb3b0cba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Apr 2026 14:19:14 -0700 Subject: [PATCH 04/19] chore(lint): fix import ordering in triggers registry Co-Authored-By: Claude Opus 4.6 --- apps/sim/triggers/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 636fcdb7d1f..c1895086494 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -90,8 +90,8 @@ import { } from '@/triggers/github' import { gmailPollingTrigger } from '@/triggers/gmail' import { gongCallCompletedTrigger, gongWebhookTrigger } from '@/triggers/gong' -import { googleDrivePollingTrigger } from '@/triggers/google-drive' import { googleCalendarPollingTrigger } from '@/triggers/google-calendar' +import { googleDrivePollingTrigger } from '@/triggers/google-drive' import { googleSheetsPollingTrigger } from '@/triggers/google-sheets' import { googleFormsWebhookTrigger } from '@/triggers/googleforms' import { From cfcc208728ed5e6039573fc3296fdcd22c326cc2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Apr 2026 14:37:45 -0700 Subject: [PATCH 05/19] fix(polling): address PR review feedback for Google polling handlers - Fix fetchHeaderRow to throw on 403/429 rate limits instead of silently returning empty headers (prevents rows from being processed without headers and lastKnownRowCount from advancing past them permanently) - Fix Drive pagination to avoid advancing resume cursor past sliced changes (prevents permanent change loss when allChanges > maxFiles) - Remove unused logger import from Google Drive trigger config --- apps/sim/lib/webhooks/polling/google-drive.ts | 28 +++++++++++++------ .../sim/lib/webhooks/polling/google-sheets.ts | 8 +++--- apps/sim/triggers/google-drive/poller.ts | 3 -- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index b4ecc06941a..d6c75b55382 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -252,20 +252,32 @@ async function fetchChanges( newStartPageToken = data.newStartPageToken as string } - if (data.nextPageToken) { - lastNextPageToken = data.nextPageToken as string - } - - if (!data.nextPageToken || allChanges.length >= maxFiles || pages >= MAX_PAGES) { + // Only advance the resume cursor when we'll actually use all changes from this page. + // If allChanges exceeds maxFiles, we'll slice off the extras — so we must NOT + // advance past this page, otherwise the sliced changes are lost permanently. + const hasMore = !!data.nextPageToken + const overLimit = allChanges.length >= maxFiles + + if (!hasMore || overLimit || pages >= MAX_PAGES) { + // If we stopped mid-stream and haven't consumed all changes from this page, + // keep currentPageToken so the next poll re-fetches this page. + // If we consumed everything on this page but there are more pages, + // advance to nextPageToken so we don't re-process this page. + if (hasMore && !overLimit) { + lastNextPageToken = data.nextPageToken as string + } else if (hasMore && overLimit && allChanges.length > maxFiles) { + // We got more changes than maxFiles from this page — don't advance, + // re-fetch this page next time (idempotency deduplicates already-processed ones) + } else if (hasMore) { + lastNextPageToken = data.nextPageToken as string + } break } + lastNextPageToken = data.nextPageToken as string currentPageToken = data.nextPageToken as string } - // If we exhausted all pages the API returns newStartPageToken on the final page. - // If we broke early, fall back to the last nextPageToken so we resume from where - // we stopped rather than re-fetching from the original cursor. const resumeToken = newStartPageToken ?? lastNextPageToken ?? config.pageToken! return { changes: allChanges.slice(0, maxFiles), newStartPageToken: resumeToken } diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index 0afe61d9a55..f62d8f42d8f 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -315,12 +315,12 @@ async function fetchHeaderRow( if (!response.ok) { const status = response.status if (status === 403 || status === 429) { - logger.warn( - `[${requestId}] Sheets API rate limit (${status}) fetching header row, proceeding without headers` + const errorData = await response.json().catch(() => ({})) + throw new Error( + `Sheets API rate limit (${status}) fetching header row — skipping to retry next poll cycle: ${JSON.stringify(errorData)}` ) - } else { - logger.warn(`[${requestId}] Failed to fetch header row, proceeding without headers`) } + logger.warn(`[${requestId}] Failed to fetch header row, proceeding without headers`) return [] } diff --git a/apps/sim/triggers/google-drive/poller.ts b/apps/sim/triggers/google-drive/poller.ts index 3169a794df2..51289233a70 100644 --- a/apps/sim/triggers/google-drive/poller.ts +++ b/apps/sim/triggers/google-drive/poller.ts @@ -1,9 +1,6 @@ -import { createLogger } from '@sim/logger' import { GoogleDriveIcon } from '@/components/icons' import type { TriggerConfig } from '@/triggers/types' -const logger = createLogger('GoogleDrivePollingTrigger') - const MIME_TYPE_OPTIONS = [ { id: '', label: 'All Files' }, { id: 'application/vnd.google-apps.document', label: 'Google Docs' }, From 9df3c078ae30210b186e5bf5e1a4e73b70173825 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Apr 2026 14:43:21 -0700 Subject: [PATCH 06/19] fix(polling): prevent data loss on partial row failures and harden idempotency key - Sheets: only advance lastKnownRowCount by processedCount when there are failures, so failed rows are retried on the next poll cycle (idempotency deduplicates already-processed rows on re-fetch) - Drive: add fallback for change.time in idempotency key to prevent key collisions if the field is ever absent from the API response --- apps/sim/lib/webhooks/polling/google-drive.ts | 2 +- apps/sim/lib/webhooks/polling/google-sheets.ts | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index d6c75b55382..7cfadea76ce 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -357,7 +357,7 @@ async function processChanges( } try { - const idempotencyKey = `${webhookData.id}:${change.fileId}:${change.time}` + const idempotencyKey = `${webhookData.id}:${change.fileId}:${change.time || change.fileId}` await pollingIdempotency.executeWithIdempotency('google-drive', idempotencyKey, async () => { const payload: GoogleDriveWebhookPayload = { diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index f62d8f42d8f..6216e65378d 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -182,17 +182,20 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { logger ) - // Update state: advance row count by the number we fetched (not total new rows) - // so remaining rows are picked up in the next poll. - // When batching (more rows than maxRowsPerPoll), keep the old lastModifiedTime - // so the Drive pre-check doesn't skip remaining rows on the next poll. - const newLastKnownRowCount = config.lastKnownRowCount + rowsToFetch - const hasRemainingRows = rowsToFetch < newRowCount + // Advance row count only by successfully processed rows so failed rows + // can be retried on the next poll cycle. Idempotency deduplicates the + // already-processed rows when they are re-fetched. + const rowsAdvanced = failedCount > 0 ? processedCount : rowsToFetch + const newLastKnownRowCount = config.lastKnownRowCount + rowsAdvanced + // When batching (more rows than maxRowsPerPoll) or retrying failed rows, + // keep the old lastModifiedTime so the Drive pre-check doesn't skip + // remaining/retried rows on the next poll. + const hasRemainingOrFailed = rowsAdvanced < newRowCount await updateWebhookProviderConfig( webhookId, { lastKnownRowCount: newLastKnownRowCount, - lastModifiedTime: hasRemainingRows ? config.lastModifiedTime : currentModifiedTime, + lastModifiedTime: hasRemainingOrFailed ? config.lastModifiedTime : currentModifiedTime, lastCheckedTimestamp: now.toISOString(), }, logger From d32e1cbb81aab2d0a1d7dda7f15729967ad1adff Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Apr 2026 14:52:32 -0700 Subject: [PATCH 07/19] fix(polling): remove unused variable and preserve lastModifiedTime on Drive API failure - Remove unused `now` variable from Google Drive polling handler - Preserve stored lastModifiedTime when Drive API pre-check fails (previously wrote undefined, disabling the optimization until the next successful Drive API call) --- apps/sim/lib/webhooks/polling/google-drive.ts | 1 - apps/sim/lib/webhooks/polling/google-sheets.ts | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index 7cfadea76ce..2ea50a42748 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -87,7 +87,6 @@ export const googleDrivePollingHandler: PollingProviderHandler = { ) const config = webhookData.providerConfig as unknown as GoogleDriveWebhookConfig - const now = new Date() // First poll: get startPageToken and seed state if (!config.pageToken) { diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index 6216e65378d..636a4d06547 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -95,7 +95,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { webhookId, { lastKnownRowCount: currentRowCount, - lastModifiedTime: currentModifiedTime, + lastModifiedTime: currentModifiedTime ?? config.lastModifiedTime, lastCheckedTimestamp: now.toISOString(), }, logger @@ -118,7 +118,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { webhookId, { lastKnownRowCount: currentRowCount, - lastModifiedTime: currentModifiedTime, + lastModifiedTime: currentModifiedTime ?? config.lastModifiedTime, lastCheckedTimestamp: now.toISOString(), }, logger @@ -195,7 +195,9 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { webhookId, { lastKnownRowCount: newLastKnownRowCount, - lastModifiedTime: hasRemainingOrFailed ? config.lastModifiedTime : currentModifiedTime, + lastModifiedTime: hasRemainingOrFailed + ? config.lastModifiedTime + : (currentModifiedTime ?? config.lastModifiedTime), lastCheckedTimestamp: now.toISOString(), }, logger From a8baa494508d440f644ababc36715e35ad9aa531 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 16:33:39 -0700 Subject: [PATCH 08/19] fix(polling): don't advance state when all events fail across sheets, calendar, drive handlers --- apps/sim/lib/webhooks/polling/google-calendar.ts | 6 ++++-- apps/sim/lib/webhooks/polling/google-drive.ts | 6 +++++- apps/sim/lib/webhooks/polling/google-sheets.ts | 8 +------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index abb06c8243c..b57400d109a 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -140,8 +140,10 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { logger ) - // Use the latest `updated` value from response to avoid clock skew - const newTimestamp = latestUpdated || now.toISOString() + const newTimestamp = + processedCount === 0 && failedCount > 0 + ? config.lastCheckedTimestamp + : latestUpdated || now.toISOString() await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: newTimestamp }, logger) if (failedCount > 0 && processedCount === 0) { diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index 2ea50a42748..5e2be5b4b9b 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -151,9 +151,13 @@ export const googleDrivePollingHandler: PollingProviderHandler = { MAX_KNOWN_FILE_IDS ) + const allFailed = processedCount === 0 && failedCount > 0 await updateWebhookProviderConfig( webhookId, - { pageToken: newStartPageToken, knownFileIds: mergedKnownIds }, + { + pageToken: allFailed ? config.pageToken : newStartPageToken, + knownFileIds: allFailed ? existingKnownIds : mergedKnownIds, + }, logger ) diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index 636a4d06547..8a51615e865 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -182,14 +182,8 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { logger ) - // Advance row count only by successfully processed rows so failed rows - // can be retried on the next poll cycle. Idempotency deduplicates the - // already-processed rows when they are re-fetched. - const rowsAdvanced = failedCount > 0 ? processedCount : rowsToFetch + const rowsAdvanced = failedCount > 0 ? 0 : rowsToFetch const newLastKnownRowCount = config.lastKnownRowCount + rowsAdvanced - // When batching (more rows than maxRowsPerPoll) or retrying failed rows, - // keep the old lastModifiedTime so the Drive pre-check doesn't skip - // remaining/retried rows on the next poll. const hasRemainingOrFailed = rowsAdvanced < newRowCount await updateWebhookProviderConfig( webhookId, From 9c8640f57831fbe92556970434a86689b3fde218 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 17:04:06 -0700 Subject: [PATCH 09/19] fix(polling): retry failed idempotency keys, fix drive cursor overshoot, fix calendar inclusive updatedMin --- apps/sim/lib/core/idempotency/service.ts | 37 ++++++++++++++++--- .../lib/webhooks/polling/google-calendar.ts | 4 +- apps/sim/lib/webhooks/polling/google-drive.ts | 17 ++------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index f5b59d3c8f8..a96627bba34 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -13,6 +13,8 @@ const logger = createLogger('IdempotencyService') export interface IdempotencyConfig { ttlSeconds?: number namespace?: string + /** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */ + retryFailures?: boolean } export interface IdempotencyResult { @@ -58,6 +60,7 @@ export class IdempotencyService { this.config = { ttlSeconds: config.ttlSeconds ?? DEFAULT_TTL, namespace: config.namespace ?? 'default', + retryFailures: config.retryFailures ?? false, } this.storageMethod = getStorageMethod() logger.info(`IdempotencyService using ${this.storageMethod} storage`, { @@ -340,6 +343,21 @@ export class IdempotencyService { logger.debug(`Stored idempotency result in database: ${normalizedKey}`) } + private async deleteKey( + normalizedKey: string, + storageMethod: 'redis' | 'database' + ): Promise { + if (storageMethod === 'redis') { + const redis = getRedisClient() + if (redis) await redis.del(`${REDIS_KEY_PREFIX}${normalizedKey}`).catch(() => {}) + } else { + await db + .delete(idempotencyKey) + .where(eq(idempotencyKey.key, normalizedKey)) + .catch(() => {}) + } + } + async executeWithIdempotency( provider: string, identifier: string, @@ -360,6 +378,10 @@ export class IdempotencyService { } if (existingResult?.status === 'failed') { + if (this.config.retryFailures) { + await this.deleteKey(claimResult.normalizedKey, claimResult.storageMethod) + return this.executeWithIdempotency(provider, identifier, operation, additionalContext) + } logger.info(`Previous operation failed for: ${claimResult.normalizedKey}`) throw new Error(existingResult.error || 'Previous operation failed') } @@ -391,11 +413,15 @@ export class IdempotencyService { } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' - await this.storeResult( - claimResult.normalizedKey, - { success: false, error: errorMessage, status: 'failed' }, - claimResult.storageMethod - ) + if (this.config.retryFailures) { + await this.deleteKey(claimResult.normalizedKey, claimResult.storageMethod) + } else { + await this.storeResult( + claimResult.normalizedKey, + { success: false, error: errorMessage, status: 'failed' }, + claimResult.storageMethod + ) + } logger.warn(`Operation failed: ${claimResult.normalizedKey} - ${errorMessage}`) throw error @@ -454,4 +480,5 @@ export const webhookIdempotency = new IdempotencyService({ export const pollingIdempotency = new IdempotencyService({ namespace: 'polling', ttlSeconds: 60 * 60 * 24 * 3, // 3 days + retryFailures: true, }) diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index b57400d109a..cf22ab53b8c 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -143,7 +143,9 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { const newTimestamp = processedCount === 0 && failedCount > 0 ? config.lastCheckedTimestamp - : latestUpdated || now.toISOString() + : latestUpdated + ? new Date(new Date(latestUpdated).getTime() + 1).toISOString() + : now.toISOString() await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: newTimestamp }, logger) if (failedCount > 0 && processedCount === 0) { diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index 5e2be5b4b9b..c397c7cb69b 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -255,24 +255,12 @@ async function fetchChanges( newStartPageToken = data.newStartPageToken as string } - // Only advance the resume cursor when we'll actually use all changes from this page. - // If allChanges exceeds maxFiles, we'll slice off the extras — so we must NOT - // advance past this page, otherwise the sliced changes are lost permanently. const hasMore = !!data.nextPageToken const overLimit = allChanges.length >= maxFiles if (!hasMore || overLimit || pages >= MAX_PAGES) { - // If we stopped mid-stream and haven't consumed all changes from this page, - // keep currentPageToken so the next poll re-fetches this page. - // If we consumed everything on this page but there are more pages, - // advance to nextPageToken so we don't re-process this page. if (hasMore && !overLimit) { lastNextPageToken = data.nextPageToken as string - } else if (hasMore && overLimit && allChanges.length > maxFiles) { - // We got more changes than maxFiles from this page — don't advance, - // re-fetch this page next time (idempotency deduplicates already-processed ones) - } else if (hasMore) { - lastNextPageToken = data.nextPageToken as string } break } @@ -281,7 +269,10 @@ async function fetchChanges( currentPageToken = data.nextPageToken as string } - const resumeToken = newStartPageToken ?? lastNextPageToken ?? config.pageToken! + const slicingOccurs = allChanges.length > maxFiles + const resumeToken = slicingOccurs + ? (lastNextPageToken ?? config.pageToken!) + : (newStartPageToken ?? lastNextPageToken ?? config.pageToken!) return { changes: allChanges.slice(0, maxFiles), newStartPageToken: resumeToken } } From 7d383be8e40054d30c60e0a4e419819ded95b1a0 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 17:26:27 -0700 Subject: [PATCH 10/19] fix(polling): revert calendar timestamp on any failure, not just all-fail --- apps/sim/lib/webhooks/polling/google-calendar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index cf22ab53b8c..0399939d48e 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -141,7 +141,7 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { ) const newTimestamp = - processedCount === 0 && failedCount > 0 + failedCount > 0 ? config.lastCheckedTimestamp : latestUpdated ? new Date(new Date(latestUpdated).getTime() + 1).toISOString() From f89a70a88a59e2fa0a054fe174337fc694d6ebf6 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 17:28:40 -0700 Subject: [PATCH 11/19] fix(polling): revert drive cursor on any failure, not just all-fail --- apps/sim/lib/webhooks/polling/google-drive.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index c397c7cb69b..aa4f00a96a1 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -151,12 +151,12 @@ export const googleDrivePollingHandler: PollingProviderHandler = { MAX_KNOWN_FILE_IDS ) - const allFailed = processedCount === 0 && failedCount > 0 + const anyFailed = failedCount > 0 await updateWebhookProviderConfig( webhookId, { - pageToken: allFailed ? config.pageToken : newStartPageToken, - knownFileIds: allFailed ? existingKnownIds : mergedKnownIds, + pageToken: anyFailed ? config.pageToken : newStartPageToken, + knownFileIds: anyFailed ? existingKnownIds : mergedKnownIds, }, logger ) From 218012751305000678dd74f215815328c85819d1 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 18:23:33 -0700 Subject: [PATCH 12/19] feat(triggers): add canonical selector toggle to google polling triggers - Add 'trigger-advanced' mode to SubBlockConfig so canonical pairs work in trigger mode - Fix buildCanonicalIndex: trigger-mode subblocks don't overwrite non-trigger basicId, deduplicate advancedIds from block spreads - Update editor, subblock layout, and trigger config aggregation to include trigger-advanced subblocks - Replace dropdown+fetchOptions in Calendar/Sheets/Drive pollers with file-selector (basic) + short-input (advanced) canonical pairs - Add canonicalParamId: 'oauthCredential' to triggerCredentials for selector context resolution - Update polling handlers to read canonical fallbacks (calendarId||manualCalendarId, etc.) --- .../panel/components/editor/editor.tsx | 4 +- .../hooks/use-editor-subblock-layout.ts | 10 ++- apps/sim/blocks/types.ts | 2 +- .../hooks/use-trigger-config-aggregation.ts | 12 ++- .../lib/webhooks/polling/google-calendar.ts | 5 +- apps/sim/lib/webhooks/polling/google-drive.ts | 6 +- .../sim/lib/webhooks/polling/google-sheets.ts | 34 +++++--- .../sim/lib/workflows/subblocks/visibility.ts | 13 ++- apps/sim/triggers/google-calendar/poller.ts | 65 ++++---------- apps/sim/triggers/google-drive/poller.ts | 17 +++- apps/sim/triggers/google-sheets/poller.ts | 86 ++++++++----------- 11 files changed, 126 insertions(+), 128 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index e6813822338..2423215f828 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -145,7 +145,9 @@ export function Editor() { if (!triggerMode) return subBlocks return subBlocks.filter( (subBlock) => - subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType) + subBlock.mode === 'trigger' || + subBlock.mode === 'trigger-advanced' || + subBlock.type === ('trigger-config' as SubBlockType) ) }, [blockConfig?.subBlocks, triggerMode]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts index 6f9d22a7841..ac2554bd577 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts @@ -102,7 +102,9 @@ export function useEditorSubblockLayout( const subBlocksForCanonical = displayTriggerMode ? (config.subBlocks || []).filter( (subBlock) => - subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType) + subBlock.mode === 'trigger' || + subBlock.mode === 'trigger-advanced' || + subBlock.type === ('trigger-config' as SubBlockType) ) : config.subBlocks || [] const canonicalIndex = buildCanonicalIndex(subBlocksForCanonical) @@ -137,12 +139,12 @@ export function useEditorSubblockLayout( } // Filter by mode if specified - if (block.mode === 'trigger') { + if (block.mode === 'trigger' || block.mode === 'trigger-advanced') { if (!displayTriggerMode) return false } - // When in trigger mode, hide blocks that don't have mode: 'trigger' - if (displayTriggerMode && block.mode !== 'trigger') { + // When in trigger mode, hide blocks that don't have mode: 'trigger' or 'trigger-advanced' + if (displayTriggerMode && block.mode !== 'trigger' && block.mode !== 'trigger-advanced') { return false } diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index d6969cc9c23..7ce603a8835 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -275,7 +275,7 @@ export interface SubBlockConfig { id: string title?: string type: SubBlockType - mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode + mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is for advanced canonical pair members shown in trigger mode canonicalParamId?: string /** Controls parameter visibility in agent/tool-input context */ paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden' diff --git a/apps/sim/hooks/use-trigger-config-aggregation.ts b/apps/sim/hooks/use-trigger-config-aggregation.ts index 5e15edf8e9d..b7dae7ecfd3 100644 --- a/apps/sim/hooks/use-trigger-config-aggregation.ts +++ b/apps/sim/hooks/use-trigger-config-aggregation.ts @@ -55,7 +55,11 @@ export function useTriggerConfigAggregation( let hasAnyValue = false triggerDef.subBlocks - .filter((sb) => sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id)) + .filter( + (sb) => + (sb.mode === 'trigger' || sb.mode === 'trigger-advanced') && + !SYSTEM_SUBBLOCK_IDS.includes(sb.id) + ) .forEach((subBlock) => { const fieldValue = subBlockStore.getValue(blockId, subBlock.id) @@ -117,7 +121,11 @@ export function populateTriggerFieldsFromConfig( const subBlockStore = useSubBlockStore.getState() triggerDef.subBlocks - .filter((sb) => sb.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(sb.id)) + .filter( + (sb) => + (sb.mode === 'trigger' || sb.mode === 'trigger-advanced') && + !SYSTEM_SUBBLOCK_IDS.includes(sb.id) + ) .forEach((subBlock) => { let configValue: any diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index 0399939d48e..16d5f23006e 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -15,7 +15,8 @@ const MAX_PAGES = 10 type CalendarEventTypeFilter = '' | 'created' | 'updated' | 'cancelled' interface GoogleCalendarWebhookConfig { - calendarId: string + calendarId?: string + manualCalendarId?: string eventTypeFilter?: CalendarEventTypeFilter searchTerm?: string lastCheckedTimestamp?: string @@ -99,7 +100,7 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { ) const config = webhookData.providerConfig as unknown as GoogleCalendarWebhookConfig - const calendarId = config.calendarId || 'primary' + const calendarId = config.calendarId || config.manualCalendarId || 'primary' const now = new Date() // First poll: seed timestamp, emit nothing diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index aa4f00a96a1..af47d406ce5 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -17,6 +17,7 @@ type DriveEventTypeFilter = '' | 'created' | 'modified' | 'deleted' | 'created_o interface GoogleDriveWebhookConfig { folderId?: string + manualFolderId?: string mimeTypeFilter?: string includeSharedDrives?: boolean eventTypeFilter?: DriveEventTypeFilter @@ -292,8 +293,9 @@ function filterChanges( if (file.trashed) return false // Folder filter: check if file is in the specified folder - if (config.folderId) { - if (!file.parents || !file.parents.includes(config.folderId)) { + const folderId = config.folderId || config.manualFolderId + if (folderId) { + if (!file.parents || !file.parents.includes(folderId)) { return false } } diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index 8a51615e865..c900befabbf 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -14,8 +14,10 @@ type ValueRenderOption = 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA' type DateTimeRenderOption = 'SERIAL_NUMBER' | 'FORMATTED_STRING' interface GoogleSheetsWebhookConfig { - spreadsheetId: string - sheetName: string + spreadsheetId?: string + manualSpreadsheetId?: string + sheetName?: string + manualSheetName?: string includeHeaders: boolean valueRenderOption?: ValueRenderOption dateTimeRenderOption?: DateTimeRenderOption @@ -52,9 +54,11 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { ) const config = webhookData.providerConfig as unknown as GoogleSheetsWebhookConfig + const spreadsheetId = config.spreadsheetId || config.manualSpreadsheetId + const sheetName = config.sheetName || config.manualSheetName const now = new Date() - if (!config?.spreadsheetId || !config?.sheetName) { + if (!spreadsheetId || !sheetName) { logger.error(`[${requestId}] Missing spreadsheetId or sheetName for webhook ${webhookId}`) await markWebhookFailed(webhookId, logger) return 'failure' @@ -63,7 +67,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { // Pre-check: use Drive API to see if the file was modified since last poll const { unchanged: skipPoll, currentModifiedTime } = await isDriveFileUnchanged( accessToken, - config.spreadsheetId, + spreadsheetId, config.lastModifiedTime, requestId, logger @@ -83,8 +87,8 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { // Fetch current row count via column A const currentRowCount = await getDataRowCount( accessToken, - config.spreadsheetId, - config.sheetName, + spreadsheetId, + sheetName, requestId, logger ) @@ -148,8 +152,8 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { if (config.includeHeaders !== false) { headers = await fetchHeaderRow( accessToken, - config.spreadsheetId, - config.sheetName, + spreadsheetId, + sheetName, valueRender, dateTimeRender, requestId, @@ -161,8 +165,8 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { // because lastKnownRowCount includes the header row const newRows = await fetchRowRange( accessToken, - config.spreadsheetId, - config.sheetName, + spreadsheetId, + sheetName, startRow, endRow, valueRender, @@ -175,6 +179,8 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { newRows, headers, startRow, + spreadsheetId, + sheetName, config, webhookData, workflowData, @@ -373,6 +379,8 @@ async function processRows( rows: string[][], headers: string[], startRowIndex: number, + spreadsheetId: string, + sheetName: string, config: GoogleSheetsWebhookConfig, webhookData: PollWebhookContext['webhookData'], workflowData: PollWebhookContext['workflowData'], @@ -389,7 +397,7 @@ async function processRows( try { await pollingIdempotency.executeWithIdempotency( 'google-sheets', - `${webhookData.id}:${config.spreadsheetId}:${config.sheetName}:row${rowNumber}:${row.join('|')}`, + `${webhookData.id}:${spreadsheetId}:${sheetName}:row${rowNumber}:${row.join('|')}`, async () => { // Map row values to headers let mappedRow: Record | null = null @@ -410,8 +418,8 @@ async function processRows( rawRow: row, headers, rowNumber, - spreadsheetId: config.spreadsheetId, - sheetName: config.sheetName, + spreadsheetId, + sheetName, timestamp: new Date().toISOString(), } diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 356ab0507bf..55c4de1c69b 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -58,10 +58,17 @@ export function buildCanonicalIndex(subBlocks: SubBlockConfig[]): CanonicalIndex groupsById[canonicalId] = { canonicalId, advancedIds: [] } } const group = groupsById[canonicalId] - if (subBlock.mode === 'advanced') { - group.advancedIds.push(subBlock.id) + if (subBlock.mode === 'advanced' || subBlock.mode === 'trigger-advanced') { + // Deduplicate: trigger spreads may repeat the same advanced ID as the regular block + if (!group.advancedIds.includes(subBlock.id)) { + group.advancedIds.push(subBlock.id) + } } else { - group.basicId = subBlock.id + // A trigger-mode subblock must not overwrite a basicId already claimed by a non-trigger subblock. + // Blocks spread their trigger's subBlocks after their own, so the regular subblock always wins. + if (!group.basicId || subBlock.mode !== 'trigger') { + group.basicId = subBlock.id + } } canonicalIdBySubBlockId[subBlock.id] = canonicalId }) diff --git a/apps/sim/triggers/google-calendar/poller.ts b/apps/sim/triggers/google-calendar/poller.ts index 15dcf4bc89b..2b39cf1ab8d 100644 --- a/apps/sim/triggers/google-calendar/poller.ts +++ b/apps/sim/triggers/google-calendar/poller.ts @@ -1,13 +1,6 @@ -import { createLogger } from '@sim/logger' import { GoogleCalendarIcon } from '@/components/icons' -import { isCredentialSetValue } from '@/executor/constants' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' -const logger = createLogger('GoogleCalendarPollingTrigger') - -const DEFAULT_CALENDARS = [{ id: 'primary', label: 'Primary Calendar' }] - export const googleCalendarPollingTrigger: TriggerConfig = { id: 'google_calendar_poller', name: 'Google Calendar Event Trigger', @@ -28,54 +21,30 @@ export const googleCalendarPollingTrigger: TriggerConfig = { required: true, mode: 'trigger', supportsCredentialSets: true, + canonicalParamId: 'oauthCredential', }, { id: 'calendarId', title: 'Calendar', - type: 'dropdown', - placeholder: 'Select a calendar', + type: 'file-selector', description: 'The calendar to monitor for event changes.', required: false, - defaultValue: 'primary', - options: [], - fetchOptions: async (blockId: string) => { - const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as - | string - | null - - if (!credentialId) { - throw new Error('No Google Calendar credential selected') - } - - // Credential sets can't fetch user-specific calendars - if (isCredentialSetValue(credentialId)) { - return DEFAULT_CALENDARS - } - - try { - const response = await fetch( - `/api/tools/google_calendar/calendars?credentialId=${credentialId}` - ) - if (!response.ok) { - throw new Error('Failed to fetch calendars') - } - const data = await response.json() - if (data.calendars && Array.isArray(data.calendars)) { - return data.calendars.map( - (calendar: { id: string; summary: string; primary: boolean }) => ({ - id: calendar.id, - label: calendar.primary ? `${calendar.summary} (Primary)` : calendar.summary, - }) - ) - } - return DEFAULT_CALENDARS - } catch (error) { - logger.error('Error fetching calendars:', error) - throw error - } - }, - dependsOn: ['triggerCredentials'], mode: 'trigger', + canonicalParamId: 'calendarId', + serviceId: 'google-calendar', + selectorKey: 'google.calendar', + selectorAllowSearch: false, + dependsOn: ['triggerCredentials'], + }, + { + id: 'manualCalendarId', + title: 'Calendar ID', + type: 'short-input', + placeholder: 'Enter calendar ID (e.g., primary or calendar@gmail.com)', + description: 'The calendar to monitor for event changes.', + required: false, + mode: 'trigger-advanced', + canonicalParamId: 'calendarId', }, { id: 'eventTypeFilter', diff --git a/apps/sim/triggers/google-drive/poller.ts b/apps/sim/triggers/google-drive/poller.ts index 51289233a70..6911643a6be 100644 --- a/apps/sim/triggers/google-drive/poller.ts +++ b/apps/sim/triggers/google-drive/poller.ts @@ -31,16 +31,31 @@ export const googleDrivePollingTrigger: TriggerConfig = { required: true, mode: 'trigger', supportsCredentialSets: true, + canonicalParamId: 'oauthCredential', }, { id: 'folderId', + title: 'Folder', + type: 'file-selector', + description: 'Optional: The folder to monitor. Leave empty to monitor all files in Drive.', + required: false, + mode: 'trigger', + canonicalParamId: 'folderId', + serviceId: 'google-drive', + selectorKey: 'google.drive', + mimeType: 'application/vnd.google-apps.folder', + dependsOn: ['triggerCredentials'], + }, + { + id: 'manualFolderId', title: 'Folder ID', type: 'short-input', placeholder: 'Leave empty to monitor entire Drive', description: 'Optional: The folder ID from the Google Drive URL to monitor. Leave empty to monitor all files.', required: false, - mode: 'trigger', + mode: 'trigger-advanced', + canonicalParamId: 'folderId', }, { id: 'mimeTypeFilter', diff --git a/apps/sim/triggers/google-sheets/poller.ts b/apps/sim/triggers/google-sheets/poller.ts index 0655bc5ca0c..8d2f6a97f51 100644 --- a/apps/sim/triggers/google-sheets/poller.ts +++ b/apps/sim/triggers/google-sheets/poller.ts @@ -1,11 +1,6 @@ -import { createLogger } from '@sim/logger' import { GoogleSheetsIcon } from '@/components/icons' -import { isCredentialSetValue } from '@/executor/constants' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' -const logger = createLogger('GoogleSheetsPollingTrigger') - export const googleSheetsPollingTrigger: TriggerConfig = { id: 'google_sheets_poller', name: 'Google Sheets New Row Trigger', @@ -26,64 +21,53 @@ export const googleSheetsPollingTrigger: TriggerConfig = { required: true, mode: 'trigger', supportsCredentialSets: true, + canonicalParamId: 'oauthCredential', }, { id: 'spreadsheetId', + title: 'Spreadsheet', + type: 'file-selector', + description: 'The spreadsheet to monitor for new rows.', + required: true, + mode: 'trigger', + canonicalParamId: 'spreadsheetId', + serviceId: 'google-sheets', + selectorKey: 'google.drive', + mimeType: 'application/vnd.google-apps.spreadsheet', + dependsOn: ['triggerCredentials'], + }, + { + id: 'manualSpreadsheetId', title: 'Spreadsheet ID', type: 'short-input', - placeholder: 'e.g., 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms', - description: - 'The spreadsheet ID from the URL: docs.google.com/spreadsheets/d/{spreadsheetId}/edit', + placeholder: 'ID from URL: docs.google.com/spreadsheets/d/{ID}/edit', + description: 'The spreadsheet to monitor for new rows.', required: true, - mode: 'trigger', + mode: 'trigger-advanced', + canonicalParamId: 'spreadsheetId', }, { id: 'sheetName', title: 'Sheet Tab', - type: 'dropdown', - placeholder: 'Select a sheet tab', + type: 'sheet-selector', description: 'The sheet tab to monitor for new rows.', required: true, - options: [], - fetchOptions: async (blockId: string) => { - const subBlockStore = useSubBlockStore.getState() - const credentialId = subBlockStore.getValue(blockId, 'triggerCredentials') as string | null - const spreadsheetId = subBlockStore.getValue(blockId, 'spreadsheetId') as string | null - - if (!credentialId) { - throw new Error('No Google Sheets credential selected') - } - if (!spreadsheetId) { - throw new Error('No spreadsheet ID provided') - } - - // Credential sets can't fetch user-specific data; return empty to allow manual entry - if (isCredentialSetValue(credentialId)) { - return [] - } - - try { - const response = await fetch( - `/api/tools/google_sheets/sheets?credentialId=${credentialId}&spreadsheetId=${spreadsheetId}` - ) - if (!response.ok) { - throw new Error('Failed to fetch sheet tabs') - } - const data = await response.json() - if (data.sheets && Array.isArray(data.sheets)) { - return data.sheets.map((sheet: { id: string; name: string }) => ({ - id: sheet.id, - label: sheet.name, - })) - } - return [] - } catch (error) { - logger.error('Error fetching sheet tabs:', error) - throw error - } - }, - dependsOn: ['triggerCredentials', 'spreadsheetId'], mode: 'trigger', + canonicalParamId: 'sheetName', + serviceId: 'google-sheets', + selectorKey: 'google.sheets', + selectorAllowSearch: false, + dependsOn: { all: ['triggerCredentials'], any: ['spreadsheetId', 'manualSpreadsheetId'] }, + }, + { + id: 'manualSheetName', + title: 'Sheet Tab Name', + type: 'short-input', + placeholder: 'Enter sheet tab name (e.g., Sheet1)', + description: 'The sheet tab to monitor for new rows.', + required: true, + mode: 'trigger-advanced', + canonicalParamId: 'sheetName', }, { id: 'includeHeaders', @@ -139,7 +123,7 @@ export const googleSheetsPollingTrigger: TriggerConfig = { type: 'text', defaultValue: [ 'Connect your Google account using OAuth credentials', - 'Enter the Spreadsheet ID from your Google Sheets URL', + 'Select the spreadsheet to monitor', 'Select the sheet tab to monitor', 'The system will automatically detect new rows appended to the sheet', ] From dddd642e41716f170202d17006c5e26920b3725c Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 18:50:29 -0700 Subject: [PATCH 13/19] test(blocks): handle trigger-advanced mode in canonical validation tests --- apps/sim/blocks/blocks.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 1eec521a040..3421cb166ca 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -423,7 +423,7 @@ describe.concurrent('Blocks Module', () => { }) it('should have valid mode values for subBlocks', () => { - const validModes = ['basic', 'advanced', 'both', 'trigger', undefined] + const validModes = ['basic', 'advanced', 'both', 'trigger', 'trigger-advanced', undefined] const blocks = getAllBlocks() for (const block of blocks) { for (const subBlock of block.subBlocks) { @@ -669,7 +669,9 @@ describe.concurrent('Blocks Module', () => { for (const block of blocks) { // Exclude trigger-mode subBlocks — they operate in a separate rendering context // and their IDs don't participate in canonical param resolution - const nonTriggerSubBlocks = block.subBlocks.filter((sb) => sb.mode !== 'trigger') + const nonTriggerSubBlocks = block.subBlocks.filter( + (sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) const allSubBlockIds = new Set(nonTriggerSubBlocks.map((sb) => sb.id)) const canonicalParamIds = new Set( nonTriggerSubBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId) @@ -795,6 +797,8 @@ describe.concurrent('Blocks Module', () => { >() for (const subBlock of block.subBlocks) { + // Skip trigger-mode subBlocks — they operate in a separate rendering context + if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') continue if (subBlock.canonicalParamId) { if (!canonicalGroups.has(subBlock.canonicalParamId)) { canonicalGroups.set(subBlock.canonicalParamId, []) @@ -861,7 +865,7 @@ describe.concurrent('Blocks Module', () => { continue } // Skip trigger-mode subBlocks — they operate in a separate rendering context - if (subBlock.mode === 'trigger') { + if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') { continue } const conditionKey = serializeCondition(subBlock.condition) @@ -895,8 +899,11 @@ describe.concurrent('Blocks Module', () => { if (!block.inputs) continue // Find all canonical groups (subBlocks with canonicalParamId) + // Skip trigger-mode subBlocks — they operate in a separate rendering context + // and are not wired to the block's inputs section const canonicalGroups = new Map() for (const subBlock of block.subBlocks) { + if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') continue if (subBlock.canonicalParamId) { if (!canonicalGroups.has(subBlock.canonicalParamId)) { canonicalGroups.set(subBlock.canonicalParamId, []) @@ -948,8 +955,10 @@ describe.concurrent('Blocks Module', () => { .replace(/\/\*[\s\S]*?\*\//g, '') // Remove multi-line comments // Find all canonical groups (subBlocks with canonicalParamId) + // Skip trigger-mode subBlocks — they are not passed through params function const canonicalGroups = new Map() for (const subBlock of block.subBlocks) { + if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') continue if (subBlock.canonicalParamId) { if (!canonicalGroups.has(subBlock.canonicalParamId)) { canonicalGroups.set(subBlock.canonicalParamId, []) @@ -995,8 +1004,11 @@ describe.concurrent('Blocks Module', () => { for (const block of blocks) { // Find all canonical groups (subBlocks with canonicalParamId) + // Skip trigger-mode subBlocks — they operate in a separate rendering context + // and may have different required semantics from their block counterparts const canonicalGroups = new Map() for (const subBlock of block.subBlocks) { + if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') continue if (subBlock.canonicalParamId) { if (!canonicalGroups.has(subBlock.canonicalParamId)) { canonicalGroups.set(subBlock.canonicalParamId, []) From 5c538228546da2f6a5ff77e4fad3706c2c43d0be Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 18:59:20 -0700 Subject: [PATCH 14/19] fix(triggers): handle trigger-advanced mode in deploy, preview, params, and copilot --- .../credential-selector/credential-selector.tsx | 2 +- .../components/preview-editor/preview-editor.tsx | 6 ++++-- .../preview-workflow/components/block/block.tsx | 6 +++--- .../server/blocks/get-blocks-metadata-tool.ts | 15 ++++++++++----- apps/sim/lib/webhooks/deploy.ts | 6 +++++- apps/sim/tools/params.ts | 4 ++-- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index ecfb61ba3d8..3eeb5173545 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -98,7 +98,7 @@ export function CredentialSelector({ ) const provider = effectiveProviderId - const isTriggerMode = subBlock.mode === 'trigger' + const isTriggerMode = subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' const { data: rawCredentials = [], diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 222eaccd171..fabf2e975a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -1153,8 +1153,10 @@ function PreviewEditorContent({ if (subBlock.type === ('trigger-config' as SubBlockType)) { return effectiveTrigger || isPureTriggerBlock } - if (subBlock.mode === 'trigger' && !effectiveTrigger) return false - if (effectiveTrigger && subBlock.mode !== 'trigger') return false + if ((subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') && !effectiveTrigger) + return false + if (effectiveTrigger && subBlock.mode !== 'trigger' && subBlock.mode !== 'trigger-advanced') + return false if (!isSubBlockFeatureEnabled(subBlock)) return false if ( !isSubBlockVisibleForMode( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index f6c0f9c5b5f..4fa893c1501 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -319,11 +319,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps if (effectiveTrigger) { const isValidTriggerSubblock = isPureTriggerBlock - ? subBlock.mode === 'trigger' || !subBlock.mode - : subBlock.mode === 'trigger' + ? subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' || !subBlock.mode + : subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' if (!isValidTriggerSubblock) return false } else { - if (subBlock.mode === 'trigger') return false + if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') return false } /** Skip value-dependent visibility checks in lightweight mode */ diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index aec442ceff6..91c907d7a72 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -185,7 +185,10 @@ export const getBlocksMetadataServerTool: BaseServerTool< const configFields: Record = {} for (const subBlock of trig.subBlocks) { - if (subBlock.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)) { + if ( + (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') && + !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id) + ) { const fieldDef: any = { type: subBlock.type, required: subBlock.required || false, @@ -227,7 +230,9 @@ export const getBlocksMetadataServerTool: BaseServerTool< const blockInputs = computeBlockLevelInputs(blockConfig) const { commonParameters, operationParameters } = splitParametersByOperation( Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger') + ? blockConfig.subBlocks.filter( + (sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' + ) : [], blockInputs ) @@ -424,7 +429,7 @@ function extractInputs(metadata: CopilotBlockMetadata): { for (const schema of metadata.inputSchema || []) { // Skip trigger subBlocks - they're handled separately in triggers.configFields - if (schema.mode === 'trigger') { + if (schema.mode === 'trigger' || schema.mode === 'trigger-advanced') { continue } @@ -910,7 +915,7 @@ function splitParametersByOperation( function computeBlockLevelInputs(blockConfig: BlockConfig): Record { const inputs = blockConfig.inputs || {} const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger') + ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') : [] const byParamKey: Record = {} @@ -945,7 +950,7 @@ function computeOperationLevelInputs( ): Record> { const inputs = blockConfig.inputs || {} const subBlocks = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger') + ? blockConfig.subBlocks.filter((sb) => sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced') : [] const opInputs: Record> = {} diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 43188e88f2b..4a189b681b8 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -183,7 +183,11 @@ function buildProviderConfig( ) triggerDef.subBlocks - .filter((subBlock) => subBlock.mode === 'trigger' && !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)) + .filter( + (subBlock) => + (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') && + !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id) + ) .forEach((subBlock) => { const valueToUse = getConfigValue(block, subBlock) if (valueToUse !== null && valueToUse !== undefined && valueToUse !== '') { diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 4c7daf69cfd..e830d1acecf 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -66,7 +66,7 @@ export interface UIComponentConfig { /** Canonical parameter ID if this is part of a canonical group */ canonicalParamId?: string /** The mode of the source subblock (basic/advanced/both) */ - mode?: 'basic' | 'advanced' | 'both' | 'trigger' + mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' /** The actual subblock ID this config was derived from */ actualSubBlockId?: string /** Wand configuration for AI assistance */ @@ -944,7 +944,7 @@ export function getSubBlocksForToolInput( if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue // Skip trigger-mode-only subblocks - if (sb.mode === 'trigger') continue + if (sb.mode === 'trigger' || sb.mode === 'trigger-advanced') continue // Hide tool API key fields when running on hosted Sim or when env var is set if (isSubBlockHidden(sb)) continue From 033d2eeb34d2bc5be2bc510c4195c96bca5ba8cf Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 19:04:37 -0700 Subject: [PATCH 15/19] fix(polling): use position-only idempotency key for sheets rows --- apps/sim/lib/webhooks/polling/google-sheets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index c900befabbf..00c4aa36ae7 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -397,7 +397,7 @@ async function processRows( try { await pollingIdempotency.executeWithIdempotency( 'google-sheets', - `${webhookData.id}:${spreadsheetId}:${sheetName}:row${rowNumber}:${row.join('|')}`, + `${webhookData.id}:${spreadsheetId}:${sheetName}:row${rowNumber}`, async () => { // Map row values to headers let mappedRow: Record | null = null From d1ae3b291acfc8281793de4781d86f654a6d4272 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 19:15:57 -0700 Subject: [PATCH 16/19] fix(polling): don't advance calendar timestamp to client clock on empty poll --- apps/sim/lib/webhooks/polling/google-calendar.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index 16d5f23006e..068b19e0117 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -101,13 +101,12 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { const config = webhookData.providerConfig as unknown as GoogleCalendarWebhookConfig const calendarId = config.calendarId || config.manualCalendarId || 'primary' - const now = new Date() // First poll: seed timestamp, emit nothing if (!config.lastCheckedTimestamp) { await updateWebhookProviderConfig( webhookId, - { lastCheckedTimestamp: now.toISOString() }, + { lastCheckedTimestamp: new Date().toISOString() }, logger ) await markWebhookSuccess(webhookId, logger) @@ -119,11 +118,10 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { const events = await fetchChangedEvents(accessToken, calendarId, config, requestId, logger) if (!events.length) { - await updateWebhookProviderConfig( - webhookId, - { lastCheckedTimestamp: now.toISOString() }, - logger - ) + // Do not advance the timestamp when no events are found — only server-side timestamps + // from actual event responses are used to advance the cursor. Advancing to the client + // clock risks skipping events whose server-side updated timestamp falls in any clock + // skew gap between the client and Google's servers. await markWebhookSuccess(webhookId, logger) logger.info(`[${requestId}] No changed events for webhook ${webhookId}`) return 'success' @@ -146,7 +144,7 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { ? config.lastCheckedTimestamp : latestUpdated ? new Date(new Date(latestUpdated).getTime() + 1).toISOString() - : now.toISOString() + : config.lastCheckedTimestamp await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: newTimestamp }, logger) if (failedCount > 0 && processedCount === 0) { From ef82ce635cbca852886ebf9e49186d3a9aade434 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 19:16:54 -0700 Subject: [PATCH 17/19] fix(polling): remove extraneous comment from calendar poller --- apps/sim/lib/webhooks/polling/google-calendar.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index 068b19e0117..ed3ed02b56e 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -118,10 +118,6 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { const events = await fetchChangedEvents(accessToken, calendarId, config, requestId, logger) if (!events.length) { - // Do not advance the timestamp when no events are found — only server-side timestamps - // from actual event responses are used to advance the cursor. Advancing to the client - // clock risks skipping events whose server-side updated timestamp falls in any clock - // skew gap between the client and Google's servers. await markWebhookSuccess(webhookId, logger) logger.info(`[${requestId}] No changed events for webhook ${webhookId}`) return 'success' From 66e0188177589cd368f5a278d825c0e138a6c4f9 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 22:35:16 -0700 Subject: [PATCH 18/19] fix(polling): drive cursor stall on full page, calendar latestUpdated past filtered events --- apps/sim/lib/webhooks/polling/google-calendar.ts | 12 ++++++------ apps/sim/lib/webhooks/polling/google-drive.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index ed3ed02b56e..d77f0ecb889 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -282,6 +282,12 @@ async function processEvents( let latestUpdated: string | null = null for (const event of events) { + // Client-side event type filter — skip before idempotency so filtered events aren't cached + const computedEventType = determineEventType(event) + if (eventTypeFilter && computedEventType !== eventTypeFilter) { + continue + } + // Track the latest `updated` timestamp for clock-skew-free state tracking if (event.updated) { if (!latestUpdated || event.updated > latestUpdated) { @@ -289,12 +295,6 @@ async function processEvents( } } - // Client-side event type filter — skip before idempotency so filtered events aren't cached - const computedEventType = determineEventType(event) - if (eventTypeFilter && computedEventType !== eventTypeFilter) { - continue - } - try { // Idempotency key includes `updated` so re-edits of the same event re-trigger const idempotencyKey = `${webhookData.id}:${event.id}:${event.updated || event.created || ''}` diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index af47d406ce5..f6a9034d3a1 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -260,7 +260,7 @@ async function fetchChanges( const overLimit = allChanges.length >= maxFiles if (!hasMore || overLimit || pages >= MAX_PAGES) { - if (hasMore && !overLimit) { + if (hasMore) { lastNextPageToken = data.nextPageToken as string } break From 9a33570bee95c97c0b665534683a9ea36d15ec52 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 9 Apr 2026 23:04:10 -0700 Subject: [PATCH 19/19] fix(polling): advance calendar cursor past fully-filtered event batches --- apps/sim/lib/webhooks/polling/google-calendar.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index d77f0ecb889..ed3ed02b56e 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -282,12 +282,6 @@ async function processEvents( let latestUpdated: string | null = null for (const event of events) { - // Client-side event type filter — skip before idempotency so filtered events aren't cached - const computedEventType = determineEventType(event) - if (eventTypeFilter && computedEventType !== eventTypeFilter) { - continue - } - // Track the latest `updated` timestamp for clock-skew-free state tracking if (event.updated) { if (!latestUpdated || event.updated > latestUpdated) { @@ -295,6 +289,12 @@ async function processEvents( } } + // Client-side event type filter — skip before idempotency so filtered events aren't cached + const computedEventType = determineEventType(event) + if (eventTypeFilter && computedEventType !== eventTypeFilter) { + continue + } + try { // Idempotency key includes `updated` so re-edits of the same event re-trigger const idempotencyKey = `${webhookData.id}:${event.id}:${event.updated || event.created || ''}`