From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/3] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From b0e0d944284acff41edc4e5bcc1ecd5ca8987609 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 15 May 2026 17:16:50 -0700 Subject: [PATCH 2/3] improvement(redis): strip idempotency body and cap mothership stream zsets --- .../copilot/request/session/buffer.test.ts | 8 +++-- .../sim/lib/copilot/request/session/buffer.ts | 1 + apps/sim/lib/core/idempotency/service.ts | 31 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/request/session/buffer.test.ts b/apps/sim/lib/copilot/request/session/buffer.test.ts index 951cdbc648d..8cfafa7e943 100644 --- a/apps/sim/lib/copilot/request/session/buffer.test.ts +++ b/apps/sim/lib/copilot/request/session/buffer.test.ts @@ -149,7 +149,7 @@ describe('mothership-stream-outbox', () => { expect(replayed.map((entry) => entry.payload.text)).toEqual(['world']) }) - it('does not trim active stream history while appending events', async () => { + it('trims active stream history to eventLimit on every append', async () => { const cursor = await allocateCursor('stream-1') await appendEvent( @@ -163,7 +163,11 @@ describe('mothership-stream-outbox', () => { }) ) - expect(mockRedis.zremrangebyrank).not.toHaveBeenCalled() + expect(mockRedis.zremrangebyrank).toHaveBeenCalledWith( + 'mothership_stream:stream-1:events', + 0, + expect.any(Number) + ) }) it('clears persisted stream state during teardown cleanup', async () => { diff --git a/apps/sim/lib/copilot/request/session/buffer.ts b/apps/sim/lib/copilot/request/session/buffer.ts index 6ee42bedc97..810b0cd5a88 100644 --- a/apps/sim/lib/copilot/request/session/buffer.ts +++ b/apps/sim/lib/copilot/request/session/buffer.ts @@ -144,6 +144,7 @@ export async function appendEvents( zaddArgs.push(envelope.seq, JSON.stringify(envelope)) } pipeline.zadd(key, ...(zaddArgs as [number, string, ...Array])) + pipeline.zremrangebyrank(key, 0, -config.eventLimit - 1) pipeline.expire(key, config.ttlSeconds) pipeline.set(seqKey, String(envelopes[envelopes.length - 1].seq), 'EX', config.ttlSeconds) await pipeline.exec() diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index b5428075a07..23a202140de 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -16,6 +16,16 @@ export interface IdempotencyConfig { namespace?: string /** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */ retryFailures?: boolean + /** + * When false, the operation's return value is not persisted alongside + * the dedupe marker — only `{ success, status, error? }` is stored. + * Duplicate calls still short-circuit, but `executeWithIdempotency` + * resolves to `undefined` on the dedupe path. Use for webhook/polling + * flows where the cached body is large (multi-KB execution results) + * and callers don't consume the value of a duplicated delivery. + * Defaults to true. + */ + storeResultBody?: boolean /** * Force a specific storage backend regardless of the environment's * auto-detection. Use `'database'` for correctness-critical flows @@ -77,6 +87,7 @@ export class IdempotencyService { ttlSeconds: config.ttlSeconds ?? DEFAULT_TTL, namespace: config.namespace ?? 'default', retryFailures: config.retryFailures ?? false, + storeResultBody: config.storeResultBody ?? true, } this.storageMethod = config.forceStorage ?? getStorageMethod() logger.info(`IdempotencyService using ${this.storageMethod} storage`, { @@ -441,7 +452,9 @@ export class IdempotencyService { await this.storeResult( claimResult.normalizedKey, - { success: true, result, status: 'completed' }, + this.config.storeResultBody + ? { success: true, result, status: 'completed' } + : { success: true, status: 'completed' }, claimResult.storageMethod ) @@ -510,15 +523,29 @@ export class IdempotencyService { } } +/** + * Webhook idempotency. We're the receiver of provider-initiated webhooks, + * not the originator — duplicate deliveries from the provider's retry + * machinery just need a "we saw this" marker, not a replayable response + * body. `storeResultBody: false` drops the cached workflow result from + * each key, eliminating the long tail of large gmail/outlook payloads + * that pushed Redis Cloud into OOM on 2026-05-15. + * + * TTL stays at 7 days because that's the longest provider retry window + * we care about (Gmail / Pub/Sub). With body-stripping the per-key cost + * is ~150 bytes, so the long TTL is essentially free. + */ export const webhookIdempotency = new IdempotencyService({ namespace: 'webhook', - ttlSeconds: 60 * 60 * 24 * 7, // 7 days + ttlSeconds: 60 * 60 * 24 * 7, // 7 days — must exceed Gmail/Pub-Sub retry window + storeResultBody: false, }) export const pollingIdempotency = new IdempotencyService({ namespace: 'polling', ttlSeconds: 60 * 60 * 24 * 3, // 3 days retryFailures: true, + storeResultBody: false, }) /** From 832b782f2f8cb87d0c013a0afde5c2f5deacc6ea Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 15 May 2026 17:17:49 -0700 Subject: [PATCH 3/3] chore(redis): trim verbose comments on idempotency body-strip --- apps/sim/lib/core/idempotency/service.ts | 25 ++++++++---------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index 23a202140de..c4a0e9f25fb 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -17,12 +17,10 @@ export interface IdempotencyConfig { /** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */ retryFailures?: boolean /** - * When false, the operation's return value is not persisted alongside - * the dedupe marker — only `{ success, status, error? }` is stored. - * Duplicate calls still short-circuit, but `executeWithIdempotency` - * resolves to `undefined` on the dedupe path. Use for webhook/polling - * flows where the cached body is large (multi-KB execution results) - * and callers don't consume the value of a duplicated delivery. + * When false, only `{ success, status, error? }` is persisted — not the + * operation's return value. Duplicate calls still short-circuit but + * resolve to `undefined`. Use when callers don't consume the cached + * body (e.g. webhook receivers, where the provider just wants a 2xx). * Defaults to true. */ storeResultBody?: boolean @@ -524,20 +522,13 @@ export class IdempotencyService { } /** - * Webhook idempotency. We're the receiver of provider-initiated webhooks, - * not the originator — duplicate deliveries from the provider's retry - * machinery just need a "we saw this" marker, not a replayable response - * body. `storeResultBody: false` drops the cached workflow result from - * each key, eliminating the long tail of large gmail/outlook payloads - * that pushed Redis Cloud into OOM on 2026-05-15. - * - * TTL stays at 7 days because that's the longest provider retry window - * we care about (Gmail / Pub/Sub). With body-stripping the per-key cost - * is ~150 bytes, so the long TTL is essentially free. + * As a webhook receiver we only need a "we saw this delivery" marker — + * the provider's retry just needs a 2xx, not our cached response body. + * TTL must exceed the longest provider retry window (Gmail / Pub-Sub: 7d). */ export const webhookIdempotency = new IdempotencyService({ namespace: 'webhook', - ttlSeconds: 60 * 60 * 24 * 7, // 7 days — must exceed Gmail/Pub-Sub retry window + ttlSeconds: 60 * 60 * 24 * 7, // 7 days storeResultBody: false, })